Import aus Monorepo (frischer Start)
All checks were successful
Build & Deploy / deploy (push) Successful in 26s

This commit is contained in:
Lucas Orth
2026-08-24 12:13:54 +02:00
commit 32aa0a549f
38 changed files with 7881 additions and 0 deletions

525
src/components/AppShell.tsx Normal file
View File

@@ -0,0 +1,525 @@
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: '', 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,
name: [c.first_name, c.last_name].filter(Boolean).join(' ') || f.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,
name: form.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.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('name', 'Name')}
{feld('email', 'E-Mail-Adresse', false, 'email')}
{feld('phone', 'Telefon', false, 'tel')}
{feld('street', 'Straße und Hausnummer')}
{feld('zip', 'PLZ')}
{feld('city', 'Stadt')}
<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>
<button className="sk-nav-item sk-nav-item--quiet" onClick={() => { open('angebot'); onNavigate?.(); }}>
<IconOffer />
<span>Individuelles Angebot</span>
</button>
<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>
);
}