All checks were successful
Build & Deploy / deploy (push) Successful in 21s
- Muster bestellen + individuelles Angebot: getrennte Felder Vorname/Nachname (statt einem Namensfeld). - Muster-Formular: Feld „Stadt" → „Ort". - „Individuelles Angebot" öffnet jetzt eine eigene Seite (/angebot) statt Popup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
529 lines
19 KiB
TypeScript
529 lines
19 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||
import {
|
||
ApiError,
|
||
fetchMyCustomer,
|
||
fetchProducts,
|
||
fetchStatusEvents,
|
||
getSeenAt,
|
||
loadSession,
|
||
logout,
|
||
markNotificationsSeen,
|
||
sendSampleRequest,
|
||
type Product,
|
||
type StatusEvent,
|
||
} from '../lib/api';
|
||
import { STATUS_LABEL } from '../lib/status';
|
||
import { useDialogs } from '../lib/dialogs';
|
||
import { useWizard } from '../lib/wizard';
|
||
import OfferForm from './OfferForm';
|
||
import {
|
||
IconBell,
|
||
IconCheck,
|
||
IconChevronDown,
|
||
IconClose,
|
||
IconHelp,
|
||
IconList,
|
||
IconMail,
|
||
IconMenu,
|
||
IconOffer,
|
||
IconPen,
|
||
IconPhone,
|
||
IconUser,
|
||
} from './Icons';
|
||
|
||
const KONTAKT = { mail: 'hello@skrift.de', tel: '+49 6872 2153' };
|
||
/** Routen, unter denen der Konfigurator läuft (Start + Direktlinks). */
|
||
const KONFIGURATOR_PFADE = ['/', '/briefe', '/postkarten', '/unterschriftenservice', '/follow_ups', '/followups', '/follow-ups'];
|
||
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
|
||
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
|
||
|
||
function Wordmark() {
|
||
const [logoFehlt, setLogoFehlt] = useState(false);
|
||
return (
|
||
<Link to="/" className="sk-wordmark" aria-label="Skrift – Startseite">
|
||
{logoFehlt ? 'Skrift' : <img src="/logo.svg" alt="Skrift" className="sk-logo" onError={() => setLogoFehlt(true)} />}
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
/** Konto-Dropdown in der Kopfleiste: Name/Rolle + Abmelden (bzw. Anmelden). */
|
||
function UserMenu() {
|
||
const navigate = useNavigate();
|
||
const angemeldet = !!loadSession();
|
||
const [offen, setOffen] = useState(false);
|
||
const [kunde, setKunde] = useState<{ name: string; email: string; rolle: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!angemeldet) return;
|
||
fetchMyCustomer()
|
||
.then((c) => {
|
||
if (!c) return;
|
||
setKunde({
|
||
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email,
|
||
email: c.email,
|
||
rolle: c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatkunde',
|
||
});
|
||
})
|
||
.catch(() => { /* Anzeige ist unkritisch */ });
|
||
}, [angemeldet]);
|
||
|
||
if (!angemeldet) {
|
||
return (
|
||
<Link to="/login" className="sk-btn sk-btn--ghost">
|
||
Anmelden
|
||
</Link>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div style={{ position: 'relative' }}>
|
||
<button className="sk-user" onClick={() => setOffen((v) => !v)} aria-expanded={offen}>
|
||
<span className="sk-user-avatar"><IconUser size={16} /></span>
|
||
<span className="sk-user-name">{kunde?.name ?? 'Konto'}</span>
|
||
<IconChevronDown size={16} className="sk-user-caret" />
|
||
</button>
|
||
|
||
{offen && (
|
||
<>
|
||
<div className="sk-menu-backdrop" onClick={() => setOffen(false)} />
|
||
<div className="sk-popover sk-popover--menu">
|
||
<div className="sk-menu-head">
|
||
<div className="sk-menu-name">{kunde?.name}</div>
|
||
<div className="sk-menu-sub">{kunde?.email}</div>
|
||
<div className="sk-menu-sub">{kunde?.rolle}</div>
|
||
</div>
|
||
<button
|
||
className="sk-menu-item"
|
||
onClick={() => { logout(); setOffen(false); navigate('/login', { replace: true }); }}
|
||
>
|
||
Abmelden
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Schlichtes Overlay für Hilfe und Angebotsanfrage. */
|
||
function Dialog({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||
return (
|
||
<div className="sk-overlay" role="dialog" aria-modal="true" aria-label={title} onClick={onClose}>
|
||
<div className="sk-dialog" onClick={(e) => e.stopPropagation()}>
|
||
<div className="sk-dialog-head">
|
||
<h2>{title}</h2>
|
||
<button className="sk-icon-btn" onClick={onClose} aria-label="Schließen">
|
||
<IconClose />
|
||
</button>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Konfigurator samt lokalem Cache zurücksetzen (Login bleibt erhalten). */
|
||
function konfiguratorZuruecksetzen() {
|
||
if (!window.confirm('Konfigurator zurücksetzen? Alle Eingaben und der lokale Zwischenspeicher werden gelöscht (die Anmeldung bleibt bestehen).')) return;
|
||
try {
|
||
Object.keys(localStorage)
|
||
.filter((k) => k.startsWith('skrift.') && k !== 'skrift.session')
|
||
.forEach((k) => localStorage.removeItem(k));
|
||
} catch { /* localStorage nicht verfügbar – dann reicht der Reload */ }
|
||
window.location.reload();
|
||
}
|
||
|
||
function Hilfe({ onClose }: { onClose: () => void }) {
|
||
return (
|
||
<Dialog title="Hilfe & Kontakt" onClose={onClose}>
|
||
<p className="sk-note">Sie erreichen uns direkt – wir melden uns werktags am selben Tag.</p>
|
||
<div className="sk-contact">
|
||
<a href={`mailto:${KONTAKT.mail}`} className="sk-contact-row">
|
||
<IconMail />
|
||
<span>{KONTAKT.mail}</span>
|
||
</a>
|
||
<a href={`tel:${KONTAKT.tel.replace(/\s/g, '')}`} className="sk-contact-row">
|
||
<IconPhone />
|
||
<span>{KONTAKT.tel}</span>
|
||
</a>
|
||
</div>
|
||
|
||
{/* Rettungsanker bei hängengebliebenem Zustand (z. B. falscher Zeilen-Hinweis). */}
|
||
<div className="sk-help-reset">
|
||
<p className="sk-note">Zeigt der Konfigurator unerwartetes Verhalten (z. B. einen falschen Zeilen-Hinweis)? Setzen Sie ihn hier zurück:</p>
|
||
<button type="button" className="sk-btn sk-btn--ghost" onClick={konfiguratorZuruecksetzen}>
|
||
Konfigurator zurücksetzen
|
||
</button>
|
||
</div>
|
||
|
||
<div className="sk-legal-links">
|
||
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">AGB</a>
|
||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutz</a>
|
||
<a href="https://skrift.de/impressum" target="_blank" rel="noreferrer">Impressum</a>
|
||
</div>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
function Angebot({ onClose, vorbelegung }: { onClose: () => void; vorbelegung?: string }) {
|
||
// vorbelegung = gewähltes Kontakt-Produkt; wird separat übermittelt, nicht im Text.
|
||
return (
|
||
<Dialog title="Individuelles Angebot anfordern" onClose={onClose}>
|
||
<OfferForm produkt={vorbelegung} onClose={onClose} />
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
/** Muster bestellen – kostenlos, mit Produktwahl und Lieferadresse.
|
||
* variant='page' rendert als eigene Seite (Direktlink /muster) statt als Popup. */
|
||
export function Muster({ onClose = () => {}, variant = 'dialog' }: { onClose?: () => void; variant?: 'dialog' | 'page' }) {
|
||
const [produkte, setProdukte] = useState<Product[]>([]);
|
||
const [form, setForm] = useState({
|
||
product: '', first_name: '', last_name: '', email: '', phone: '',
|
||
street: '', zip: '', city: '', country: 'Deutschland', message: '',
|
||
});
|
||
const [datenschutz, setDatenschutz] = useState(false);
|
||
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
|
||
const [fehler, setFehler] = useState<string[]>([]);
|
||
|
||
useEffect(() => {
|
||
fetchProducts()
|
||
.then((ps) => setProdukte(ps.filter((p) => p.type === 'letter' || p.type === 'postcard')))
|
||
.catch(() => setProdukte([]));
|
||
if (loadSession()) {
|
||
fetchMyCustomer()
|
||
.then((c) => c && setForm((f) => ({
|
||
...f,
|
||
first_name: c.first_name || f.first_name,
|
||
last_name: c.last_name || f.last_name,
|
||
email: c.email || f.email,
|
||
phone: c.phone || f.phone,
|
||
})))
|
||
.catch(() => { /* optional */ });
|
||
}
|
||
}, []);
|
||
|
||
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||
const roh = e.target.value;
|
||
setForm({ ...form, [k]: k === 'phone' ? roh.replace(/[^0-9+ ]/g, '') : k === 'zip' ? roh.replace(/[^0-9]/g, '') : roh });
|
||
};
|
||
|
||
async function absenden(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!istEmail(form.email)) { setFehler(['Bitte geben Sie eine gültige E-Mail-Adresse an.']); return; }
|
||
setFehler([]);
|
||
setStatus('sendet');
|
||
try {
|
||
await sendSampleRequest({
|
||
product: form.product,
|
||
first_name: form.first_name.trim(),
|
||
last_name: form.last_name.trim(),
|
||
email: form.email.trim(),
|
||
phone: form.phone.trim() || undefined,
|
||
street: form.street.trim(),
|
||
zip: form.zip.trim(),
|
||
city: form.city.trim(),
|
||
country: form.country,
|
||
message: form.message.trim() || undefined,
|
||
});
|
||
setStatus('fertig');
|
||
} catch (err) {
|
||
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage fehlgeschlagen.']);
|
||
setStatus('formular');
|
||
}
|
||
}
|
||
|
||
// Rahmen je nach Modus: Popup (Dialog) oder eigene Seite (sk-view).
|
||
const wrap = (titel: string, inhalt: React.ReactNode) =>
|
||
variant === 'page'
|
||
? <div className="sk-view"><h1>{titel}</h1>{inhalt}</div>
|
||
: <Dialog title={titel} onClose={onClose}>{inhalt}</Dialog>;
|
||
|
||
if (status === 'fertig') {
|
||
return wrap('Muster ist unterwegs', (
|
||
<>
|
||
<p>Vielen Dank – Ihr kostenloses Muster wird vorbereitet und verschickt.</p>
|
||
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
|
||
{variant === 'page' ? 'Zur Startseite' : 'Schließen'}
|
||
</button>
|
||
</>
|
||
));
|
||
}
|
||
|
||
const feld = (k: keyof typeof form, label: string, opt = false, type = 'text') => (
|
||
<div className="sk-field">
|
||
<label className={`sk-label${opt ? '' : ' sk-label--required'}`} htmlFor={`m-${k}`}>
|
||
{label} {opt && <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>}
|
||
</label>
|
||
<input id={`m-${k}`} className="sk-input" type={type} required={!opt}
|
||
value={form[k]} onChange={set(k)} />
|
||
</div>
|
||
);
|
||
|
||
const gueltig = form.product && form.first_name.trim() && form.last_name.trim() && istEmail(form.email) && form.phone.trim()
|
||
&& form.street.trim() && form.zip.trim() && form.city.trim() && datenschutz;
|
||
|
||
return wrap('Muster bestellen', (
|
||
<>
|
||
<p className="sk-note">Wir schicken Ihnen kostenlos ein Handschrift-Muster zum Anfassen.</p>
|
||
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
|
||
<div className="sk-field">
|
||
<label className="sk-label sk-label--required" htmlFor="m-product">
|
||
Von welchem Produkt möchten Sie ein Muster?
|
||
</label>
|
||
<select id="m-product" className="sk-input" required value={form.product} onChange={set('product')}>
|
||
<option value="" disabled>Bitte wählen …</option>
|
||
{produkte.map((p) => <option key={p.key} value={p.key}>{p.name}</option>)}
|
||
</select>
|
||
</div>
|
||
{feld('first_name', 'Vorname')}
|
||
{feld('last_name', 'Nachname')}
|
||
{feld('email', 'E-Mail-Adresse', false, 'email')}
|
||
{feld('phone', 'Telefon', false, 'tel')}
|
||
{feld('street', 'Straße und Hausnummer')}
|
||
{feld('zip', 'PLZ')}
|
||
{feld('city', 'Ort')}
|
||
<div className="sk-field">
|
||
<label className="sk-label sk-label--required" htmlFor="m-country">Land</label>
|
||
<select id="m-country" className="sk-input" value={form.country} onChange={set('country')}>
|
||
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="sk-field">
|
||
<label className="sk-label" htmlFor="m-message">
|
||
Nachricht <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>
|
||
</label>
|
||
<textarea id="m-message" className="sk-input" rows={3} maxLength={1000}
|
||
placeholder="Wünsche oder Fragen zum Muster?"
|
||
value={form.message} onChange={set('message')} />
|
||
</div>
|
||
|
||
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
|
||
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
|
||
<span>
|
||
Ich habe die{' '}
|
||
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">Datenschutzerklärung</a>{' '}
|
||
zur Kenntnis genommen und bin mit der Verarbeitung meiner Daten zur Bearbeitung meiner
|
||
Anfrage einverstanden.
|
||
</span>
|
||
</label>
|
||
|
||
{fehler.length > 0 && (
|
||
<ul className="sk-error" style={{ margin: '0 0 var(--sk-space-3)', paddingLeft: '1.1em' }}>
|
||
{fehler.map((f) => <li key={f}>{f}</li>)}
|
||
</ul>
|
||
)}
|
||
|
||
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={status === 'sendet' || !gueltig}>
|
||
{status === 'sendet' ? 'Wird gesendet …' : 'Muster anfordern'}
|
||
</button>
|
||
</form>
|
||
</>
|
||
));
|
||
}
|
||
|
||
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||
const { steps, current, goto } = useWizard();
|
||
const location = useLocation();
|
||
const { open } = useDialogs();
|
||
const angemeldet = !!loadSession();
|
||
|
||
// Ablauf nur im Konfigurator zeigen (Start + Direktlinks /briefe, /postkarten) –
|
||
// nicht in Aufträgen/Stammdaten.
|
||
const zeigeAblauf = KONFIGURATOR_PFADE.includes(location.pathname) && steps.length > 0;
|
||
|
||
const item = ({ isActive }: { isActive: boolean }) => `sk-nav-item${isActive ? ' sk-nav-item--active' : ''}`;
|
||
|
||
return (
|
||
<aside className="sk-sidebar">
|
||
<nav className="sk-nav">
|
||
<NavLink to="/" className={item} end onClick={onNavigate}>
|
||
<IconPen />
|
||
<span>Neuer Auftrag</span>
|
||
</NavLink>
|
||
<NavLink to="/auftraege" className={item} onClick={onNavigate}>
|
||
<IconList />
|
||
<span>Aufträge</span>
|
||
</NavLink>
|
||
<NavLink to="/stammdaten" className={item} onClick={onNavigate}>
|
||
<IconUser />
|
||
<span>Stammdaten</span>
|
||
</NavLink>
|
||
</nav>
|
||
|
||
{zeigeAblauf && (
|
||
<div className="sk-nav-section sk-nav-section--divider">
|
||
<div className="sk-nav-label">Ablauf</div>
|
||
<ol className="sk-process">
|
||
{steps.map((label, i) => {
|
||
const erledigt = i < current;
|
||
const klasse = `sk-process-item${
|
||
i === current ? ' sk-process-item--current' : erledigt ? ' sk-process-item--done' : ''
|
||
}`;
|
||
const inhalt = (
|
||
<>
|
||
<span className="sk-process-mark">{erledigt ? <IconCheck size={14} /> : i + 1}</span>
|
||
<span>{label}</span>
|
||
</>
|
||
);
|
||
// Erledigte Schritte sind anklickbar (Rücksprung); der aktuelle und
|
||
// kommende Schritte bleiben passiv.
|
||
return (
|
||
<li key={label}>
|
||
{erledigt ? (
|
||
<button
|
||
type="button"
|
||
className={`${klasse} sk-process-item--clickable`}
|
||
onClick={() => { goto(i); onNavigate?.(); }}
|
||
title="Zu diesem Schritt zurückspringen"
|
||
>
|
||
{inhalt}
|
||
</button>
|
||
) : (
|
||
<div className={klasse}>{inhalt}</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
{!angemeldet && (
|
||
<p className="sk-nav-hint">
|
||
Melden Sie sich an, um Aufträge und Stammdaten zu sehen.
|
||
</p>
|
||
)}
|
||
|
||
<div className="sk-sidebar-foot sk-nav-section--divider">
|
||
<NavLink to="/muster" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
|
||
<IconMail />
|
||
<span>Muster bestellen</span>
|
||
</NavLink>
|
||
<NavLink to="/angebot" className="sk-nav-item sk-nav-item--quiet" onClick={onNavigate}>
|
||
<IconOffer />
|
||
<span>Individuelles Angebot</span>
|
||
</NavLink>
|
||
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('hilfe'); onNavigate?.(); }}>
|
||
<IconHelp />
|
||
<span>Hilfe</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Benachrichtigungen speisen sich aus dem Statusverlauf der eigenen Aufträge –
|
||
* dafür braucht es keine eigene Collection. Als „gelesen" gilt alles, was älter
|
||
* ist als der zuletzt geöffnete Zeitpunkt (lokal gespeichert).
|
||
*/
|
||
function Notifications() {
|
||
const [events, setEvents] = useState<StatusEvent[]>([]);
|
||
const [offen, setOffen] = useState(false);
|
||
const [seenAt, setSeenAt] = useState(getSeenAt);
|
||
|
||
useEffect(() => {
|
||
fetchStatusEvents()
|
||
.then(setEvents)
|
||
.catch(() => {
|
||
/* Benachrichtigungen sind nicht kritisch */
|
||
});
|
||
}, []);
|
||
|
||
const ungelesen = events.filter((e) => new Date(e.timestamp).getTime() > seenAt).length;
|
||
|
||
function umschalten() {
|
||
const next = !offen;
|
||
setOffen(next);
|
||
if (next && ungelesen > 0) {
|
||
markNotificationsSeen();
|
||
setSeenAt(Date.now());
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<button
|
||
className="sk-icon-btn"
|
||
aria-label={ungelesen ? `${ungelesen} neue Benachrichtigungen` : 'Benachrichtigungen'}
|
||
onClick={umschalten}
|
||
>
|
||
<IconBell />
|
||
{ungelesen > 0 && <span className="sk-dot" aria-hidden="true" />}
|
||
</button>
|
||
|
||
{offen && (
|
||
<div className="sk-popover">
|
||
{events.length === 0 ? (
|
||
<p className="sk-note">Keine Benachrichtigungen.</p>
|
||
) : (
|
||
<ul className="sk-notif-list">
|
||
{events.slice(0, 5).map((e) => (
|
||
<li key={e.id} className="sk-notif">
|
||
<div className="sk-notif-title">
|
||
{e.order?.order_number ?? 'Auftrag'} · {STATUS_LABEL[e.status] ?? e.status}
|
||
</div>
|
||
{e.note && <div className="sk-notif-note">{e.note}</div>}
|
||
<div className="sk-notif-time">
|
||
{new Intl.DateTimeFormat('de-DE', { dateStyle: 'short', timeStyle: 'short' }).format(
|
||
new Date(e.timestamp),
|
||
)}
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function Topbar({ onMenu }: { onMenu: () => void }) {
|
||
const angemeldet = !!loadSession();
|
||
return (
|
||
<header className="sk-topbar">
|
||
<div className="sk-topbar-left">
|
||
{/* Menü-Umschalter nur auf schmalen Geräten sichtbar (CSS). */}
|
||
<button className="sk-icon-btn sk-menu-toggle" aria-label="Menü öffnen" onClick={onMenu}>
|
||
<IconMenu />
|
||
</button>
|
||
<Wordmark />
|
||
</div>
|
||
|
||
<div className="sk-topbar-right">
|
||
{angemeldet && <Notifications />}
|
||
<UserMenu />
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||
const [drawerOffen, setDrawerOffen] = useState(false);
|
||
const location = useLocation();
|
||
const { dialog, vorbelegung, close } = useDialogs();
|
||
|
||
// Beim Seitenwechsel das mobile Menü schließen.
|
||
useEffect(() => { setDrawerOffen(false); }, [location.pathname]);
|
||
|
||
return (
|
||
<div className="sk-app">
|
||
<Topbar onMenu={() => setDrawerOffen(true)} />
|
||
|
||
<div className={`sk-sidebar-wrap${drawerOffen ? ' sk-sidebar-wrap--open' : ''}`}>
|
||
<Sidebar onNavigate={() => setDrawerOffen(false)} />
|
||
</div>
|
||
{drawerOffen && <div className="sk-backdrop" onClick={() => setDrawerOffen(false)} />}
|
||
|
||
<main className="sk-content">{children}</main>
|
||
|
||
{/* Dialoge zentral – von Seitenleiste und Konfigurator aus geöffnet. */}
|
||
{dialog === 'hilfe' && <Hilfe onClose={close} />}
|
||
{dialog === 'angebot' && <Angebot onClose={close} vorbelegung={vorbelegung} />}
|
||
</div>
|
||
);
|
||
}
|