Files
skrift-app/src/components/AppShell.tsx
s4luorth bad4abc945
All checks were successful
Build & Deploy / deploy (push) Successful in 21s
Muster & Angebot: Vor-/Nachname getrennt, „Ort" statt „Stadt", Angebot als Seite
- 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>
2026-09-11 21:15:38 +02:00

529 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}