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

58
src/App.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import AppShell from './components/AppShell';
import { WizardProvider } from './lib/wizard';
import { DialogsProvider } from './lib/dialogs';
import Login from './routes/Login';
import Auftraege from './routes/Auftraege';
import Stammdaten from './routes/Stammdaten';
import Configurator from './routes/Configurator';
import MusterSeite from './routes/Muster';
import { loadSession } from './lib/api';
/**
* Produkt-Direktlinks (Slug → Produktschlüssel): wählen das Produkt vor, der
* Konfigurator startet auf Schritt 1. Mehrere freundliche Slugs erlaubt
* (z. B. /followups zeigt auf den Schlüssel follow_ups).
*/
const PRODUKT_PFADE: Record<string, string> = {
briefe: 'briefe',
postkarten: 'postkarten',
unterschriftenservice: 'unterschriftenservice',
follow_ups: 'follow_ups',
followups: 'follow_ups',
'follow-ups': 'follow_ups',
};
function Geschuetzt({ children }: { children: React.ReactNode }) {
return loadSession() ? <>{children}</> : <Navigate to="/login" replace />;
}
export default function App() {
return (
<DialogsProvider>
<WizardProvider>
<AppShell>
<Routes>
<Route path="/" element={<Configurator />} />
<Route path="/login" element={<Login />} />
{/* Muster als eigene Seite (kein Popup) – Direktlink /muster. */}
<Route path="/muster" element={<MusterSeite />} />
<Route
path="/auftraege"
element={<Geschuetzt><Auftraege /></Geschuetzt>}
/>
<Route
path="/stammdaten"
element={<Geschuetzt><Stammdaten /></Geschuetzt>}
/>
{/* Produkt-Direktlinks – Produktwahl entfällt, Personentyp bleibt. */}
{Object.entries(PRODUKT_PFADE).map(([slug, key]) => (
<Route key={slug} path={`/${slug}`} element={<Configurator vorwahlProdukt={key} />} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppShell>
</WizardProvider>
</DialogsProvider>
);
}

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>
);
}

111
src/components/Icons.tsx Normal file
View File

@@ -0,0 +1,111 @@
/**
* Single-Line-Icons als Inline-SVG.
*
* Bewusst ohne Icon-Bibliothek: keine externe Anfrage (DSGVO), kein zusätzliches
* Bundle, und eine einheitliche Strichstärke über alle Symbole.
*/
type Props = { size?: number; className?: string };
const base = (size: number) => ({
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
focusable: false,
});
export const IconPen = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
);
export const IconList = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M8 6h13M8 12h13M8 18h13" />
<path d="M3 6h.01M3 12h.01M3 18h.01" />
</svg>
);
export const IconUser = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
export const IconBell = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</svg>
);
export const IconHelp = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<circle cx="12" cy="12" r="9" />
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
<path d="M12 17h.01" />
</svg>
);
export const IconOffer = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" />
<path d="M14 3v5h5" />
<path d="M12 11v6M9 14h6" />
</svg>
);
export const IconCheck = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M20 6 9 17l-5-5" />
</svg>
);
export const IconMail = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
);
export const IconPhone = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2 4.2 2 2 0 0 1 4 2h3a2 2 0 0 1 2 1.7c.1.9.4 1.8.7 2.6a2 2 0 0 1-.5 2.1L8.1 9.6a16 16 0 0 0 6 6l1.2-1.1a2 2 0 0 1 2.1-.5c.8.3 1.7.6 2.6.7a2 2 0 0 1 1.7 2Z" />
</svg>
);
export const IconClose = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
);
export const IconBuilding = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M3 21h18" />
<path d="M5 21V5a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v16" />
<path d="M19 21V9a2 2 0 0 0-2-2h-2" />
<path d="M9 7h2M9 11h2M9 15h2" />
</svg>
);
export const IconMenu = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
);
export const IconChevronDown = ({ size = 18, className }: Props) => (
<svg {...base(size)} className={className}>
<path d="m6 9 6 6 6-6" />
</svg>
);

View File

@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
import { ApiError, fetchMyCustomer, loadSession, sendOfferRequest } from '../lib/api';
const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim());
/**
* Anfrageformular „Individuelles Angebot".
*
* Wird an zwei Stellen genutzt: im Konfigurator als eigene Seite (nach der Wahl
* eines Anfrageprodukts) und in der Seitenleiste als Dialog. Deshalb rendert es
* nur den Inhalt – die Umrahmung (Karte bzw. Overlay) kommt von außen.
*
* `onClose` blendet im Dialog den Schließen-Button ein; inline (im Konfigurator)
* bleibt er weg.
*/
export default function OfferForm({ produkt, onClose }: { produkt?: string; onClose?: () => void }) {
const gewaehltesProdukt = produkt?.trim() || '';
const [form, setForm] = useState({ name: '', email: '', phone: '', quantity: '', message: '' });
const [datenschutz, setDatenschutz] = useState(false);
const [status, setStatus] = useState<'formular' | 'sendet' | 'fertig'>('formular');
const [fehler, setFehler] = useState<string[]>([]);
// Bei angemeldeten Kunden Name und E-Mail vorausfüllen.
useEffect(() => {
if (!loadSession()) return;
fetchMyCustomer()
.then((c) => {
if (!c) return;
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(() => { /* Vorausfüllen ist optional */ });
}, []);
const set = (k: keyof typeof form) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const roh = e.target.value;
setForm({ ...form, [k]: k === 'phone' ? 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; }
if (!form.phone.trim()) { setFehler(['Bitte geben Sie eine Telefonnummer an.']); return; }
setFehler([]);
setStatus('sendet');
try {
await sendOfferRequest({
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim() || undefined,
quantity: form.quantity ? Number(form.quantity) : null,
message: form.message.trim(),
product: gewaehltesProdukt || undefined,
});
setStatus('fertig');
} catch (err) {
setFehler(err instanceof ApiError ? (err.details.length ? err.details : [err.message]) : ['Anfrage konnte nicht gesendet werden.']);
setStatus('formular');
}
}
if (status === 'fertig') {
return (
<>
<p>Vielen Dank – wir melden uns werktags innerhalb eines Tages mit einem Angebot bei Ihnen.</p>
{onClose && (
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} onClick={onClose}>
Schließen
</button>
)}
</>
);
}
return (
<>
<p className="sk-note">
Größere Mengen, Sonderformate, Unterschriftenservice oder automatisierte Follow-ups – schildern Sie
kurz Ihr Vorhaben.
</p>
{gewaehltesProdukt && (
<div className="sk-menu-head" style={{ borderRadius: 'var(--sk-radius)', border: '1px solid var(--sk-border)', marginTop: 'var(--sk-space-3)' }}>
<div className="sk-menu-sub">Ihre Anfrage betrifft</div>
<div className="sk-menu-name">{gewaehltesProdukt}</div>
</div>
)}
<form style={{ marginTop: 'var(--sk-space-4)' }} onSubmit={absenden}>
<div className="sk-field">
<label className="sk-label" htmlFor="a-name">Name</label>
<input id="a-name" className="sk-input" value={form.name} onChange={set('name')} required />
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="a-mail">E-Mail</label>
<input id="a-mail" className="sk-input" type="email" value={form.email} onChange={set('email')} required />
</div>
<div className="sk-field">
<label className="sk-label sk-label--required" htmlFor="a-tel">Telefon</label>
<input id="a-tel" className="sk-input" type="tel" value={form.phone} onChange={set('phone')} required />
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="a-menge">Ungefähre Menge <span className="sk-note">(optional)</span></label>
<input id="a-menge" className="sk-input" type="number" min={1} value={form.quantity} onChange={set('quantity')} />
</div>
<div className="sk-field">
<label className="sk-label" htmlFor="a-text">Ihr Vorhaben</label>
<textarea id="a-text" className="sk-input" rows={4} value={form.message} onChange={set('message')} required />
</div>
<label className="sk-check" style={{ marginBottom: 'var(--sk-space-4)' }}>
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} required />
<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' || !datenschutz || !form.phone.trim()}>
{status === 'sendet' ? 'Wird gesendet …' : 'Anfrage senden'}
</button>
</form>
</>
);
}

View File

@@ -0,0 +1,215 @@
import { useEffect, useMemo, useState } from 'react';
/**
* Live-Vorschau neben dem Formular.
*
* Die Vorschau ist absichtlich kontingentiert: Jede Generierung kostet einen
* Aufruf der Scriptalizer-API (Tageslimit). Der Zähler ist daher Teil der
* Bedienung und kein Beiwerk.
*
* Schriftstück und zugehöriger Umschlag gehören zusammen und werden gemeinsam
* gezeigt (Schriftstück oben, Umschlag unten). Die Pfeile blättern je Empfänger.
*/
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
/** Ein Empfänger-Dokument: Schriftstück + optional zugehöriger Umschlag. */
type Gruppe = { brief?: PreviewDoc; umschlag?: PreviewDoc };
type Props = {
titel: string;
format: string;
docs: PreviewDoc[];
gesamtDokumente: number;
verbleibend: number;
maximal: number;
laedt: boolean;
fehler: string | null;
onGenerate: () => void;
bereit: boolean;
};
/** Flache [brief, umschlag, brief, …]-Liste zu Empfänger-Gruppen bündeln. */
function gruppiere(docs: PreviewDoc[]): Gruppe[] {
const gruppen: Gruppe[] = [];
for (const d of docs) {
if (d.art === 'umschlag') {
const letzte = gruppen[gruppen.length - 1];
if (letzte && letzte.brief && !letzte.umschlag) letzte.umschlag = d;
else gruppen.push({ umschlag: d });
} else {
gruppen.push({ brief: d });
}
}
return gruppen;
}
export default function PreviewPanel({
titel, format, docs, gesamtDokumente, verbleibend, maximal, laedt, fehler, onGenerate, bereit,
}: Props) {
const [index, setIndex] = useState(0);
const [zoomSvg, setZoomSvg] = useState<string | null>(null);
// Die Zoom-Fläche wird auf den Inhaltsbereich begrenzt (unterhalb der
// Preiszeile, rechts der Seitenleiste), statt das ganze Fenster zu belegen.
const [zoomBox, setZoomBox] = useState<React.CSSProperties>({});
const gruppen = useMemo(() => gruppiere(docs), [docs]);
function messeBox(): React.CSSProperties {
if (typeof window === 'undefined' || window.innerWidth <= 900) return {};
const content = document.querySelector('.sk-content');
if (!content) return {};
const c = content.getBoundingClientRect();
const bar = document.querySelector('.sk-pricebar');
const top = Math.max(c.top, bar ? bar.getBoundingClientRect().bottom : c.top);
return {
top: `${top}px`, left: `${c.left}px`, width: `${c.width}px`,
height: `${Math.max(0, c.bottom - top)}px`, right: 'auto', bottom: 'auto',
};
}
function openZoom(svg: string) { setZoomBox(messeBox()); setZoomSvg(svg); }
/** Seitenverhältnis (Breite/Höhe) aus der viewBox – sperrt die Lightbox-Abmessungen. */
function seitenverhaeltnis(svg: string): string {
const m = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
return m ? `${m[1]} / ${m[2]}` : '210 / 297';
}
/** Musterbild der großen Vorschau als PNG herunterladen (SVG → Canvas). */
function bildSpeichern() {
if (!zoomSvg) return;
const m = zoomSvg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
const w = Math.round(m ? Number(m[1]) : 794);
const h = Math.round(m ? Number(m[2]) : 1123);
const scale = 2; // für ein scharfes Musterbild
// Gespeicherte Vorschau in Kugelschreiber-Blau (wie die Anzeige); die
// an die Produktion gehenden SVGs bleiben davon unberührt (schwarz).
const inkSvg = zoomSvg.replace(/stroke="#0{3,6}"/gi, 'stroke="#1a2a6c"');
const url = URL.createObjectURL(new Blob([inkSvg], { type: 'image/svg+xml;charset=utf-8' }));
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = w * scale;
canvas.height = h * scale;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob((png) => {
if (png) {
const a = document.createElement('a');
a.href = URL.createObjectURL(png);
a.download = 'skrift-musterbild.png';
a.click();
URL.revokeObjectURL(a.href);
}
URL.revokeObjectURL(url);
}, 'image/png');
} else {
URL.revokeObjectURL(url);
}
};
img.onerror = () => URL.revokeObjectURL(url);
img.src = url;
}
// Nach einer neuen Generierung wieder vorn beginnen.
useEffect(() => { setIndex(0); }, [docs]);
// Vergrößerte Ansicht: Escape schließt, Resize misst die Fläche neu.
useEffect(() => {
if (!zoomSvg) return;
const zu = (e: KeyboardEvent) => e.key === 'Escape' && setZoomSvg(null);
const neu = () => setZoomBox(messeBox());
window.addEventListener('keydown', zu);
window.addEventListener('resize', neu);
return () => { window.removeEventListener('keydown', zu); window.removeEventListener('resize', neu); };
}, [zoomSvg]);
const aktuell = gruppen[index] ?? null;
const anzahl = gruppen.length || gesamtDokumente;
/** Ein einzelnes Dokument (Schriftstück oder Umschlag) mit Beschriftung. */
const DocBox = ({ label, svg }: { label: string; svg: string }) => (
<figure
className="sk-preview-doc"
onClick={() => openZoom(svg)}
role="button"
tabIndex={0}
title="Zum Vergrößern klicken"
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openZoom(svg); }}
>
<figcaption className="sk-preview-doc-label">{label}</figcaption>
{/* Das SVG stammt aus unserer eigenen Generierung, nicht aus Nutzereingaben. */}
<div className="sk-preview-doc-sheet" dangerouslySetInnerHTML={{ __html: svg }} />
</figure>
);
return (
<aside className="sk-preview">
<div className="sk-preview-head">
<div>
<div className="sk-preview-title">Vorschau: {titel || '—'}</div>
<div className="sk-preview-sub">{format || '—'}</div>
</div>
</div>
{laedt ? (
<div className="sk-preview-sheet"><p className="sk-note">Vorschau wird erzeugt …</p></div>
) : aktuell ? (
<div className="sk-preview-docs">
{aktuell.brief && <DocBox label={`Schriftstück${format ? ` · ${format}` : ''}`} svg={aktuell.brief.svg} />}
{aktuell.umschlag && <DocBox label="Umschlag" svg={aktuell.umschlag.svg} />}
</div>
) : (
<div className="sk-preview-sheet">
<p className="sk-note" style={{ textAlign: 'center' }}>Noch keine Vorschau erzeugt.</p>
</div>
)}
{anzahl > 0 && (
<div className="sk-preview-pager">
<button className="sk-pager-btn" aria-label="Vorheriges Dokument"
disabled={index === 0 || gruppen.length <= 1}
onClick={() => setIndex((i) => Math.max(0, i - 1))}>‹</button>
<span className="sk-pager-label">
Dokument {gruppen.length ? index + 1 : 0} von {anzahl}
</span>
<button className="sk-pager-btn" aria-label="Nächstes Dokument"
disabled={index >= gruppen.length - 1 || gruppen.length <= 1}
onClick={() => setIndex((i) => Math.min(gruppen.length - 1, i + 1))}>›</button>
</div>
)}
{fehler && <p className="sk-error">{fehler}</p>}
<div className="sk-preview-quota">
Verbleibende Vorschau-Anfragen: {verbleibend}/{maximal}
</div>
<button className="sk-btn sk-btn--ghost sk-btn--block" onClick={onGenerate}
disabled={!bereit || laedt || verbleibend <= 0}
title={!bereit ? 'Bitte zuerst Text angeben' : undefined}>
{laedt ? 'Wird erzeugt …' : 'Vorschau generieren'}
</button>
{zoomSvg && (
<div className="sk-lightbox" style={zoomBox} onClick={() => setZoomSvg(null)} role="dialog" aria-modal="true"
aria-label="Vergrößerte Vorschau">
<div className="sk-lightbox-sheet" style={{ aspectRatio: seitenverhaeltnis(zoomSvg) }}
onClick={(e) => e.stopPropagation()}
dangerouslySetInnerHTML={{ __html: zoomSvg }} />
<div className="sk-lightbox-actions">
<button className="sk-lightbox-close" onClick={(e) => { e.stopPropagation(); bildSpeichern(); }}>
Bild speichern
</button>
<button className="sk-lightbox-close" onClick={() => setZoomSvg(null)} aria-label="Schließen">
Schließen
</button>
</div>
</div>
)}
</aside>
);
}

559
src/lib/api.ts Normal file
View File

@@ -0,0 +1,559 @@
/**
* Directus-Client.
*
* Bewusst ohne SDK: Wir brauchen vor allem unsere eigenen Endpunkte
* (/skrift-auth, /skrift-orders) plus einfache Item-Abfragen – ein schlanker
* fetch-Wrapper ist überschaubarer und hält uns von SDK-Versionswechseln fern.
*/
const BASE = (import.meta.env.VITE_DIRECTUS_URL ?? '').replace(/\/$/, '');
const STORAGE_KEY = 'skrift.session';
export type Session = {
access_token: string;
refresh_token: string;
/** Ablaufzeitpunkt als Unix-ms. */
expires_at: number;
};
export function loadSession(): Session | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as Session) : null;
} catch {
return null;
}
}
export function saveSession(s: Session | null) {
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
else localStorage.removeItem(STORAGE_KEY);
}
export class ApiError extends Error {
status: number;
/** Einzelne Validierungsmeldungen, falls der Endpunkt mehrere liefert. */
details: string[];
constructor(message: string, status: number, details: string[] = []) {
super(message);
this.status = status;
this.details = details;
}
}
async function parse(res: Response) {
const text = await res.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
/* keine JSON-Antwort */
}
if (!res.ok) {
// Directus liefert errors als Objekte ({message}), unsere eigenen
// Endpunkte als reine Strings – beides muss ankommen.
const roh = Array.isArray(data?.errors) ? data.errors : [];
const details: string[] = roh
.map((e: unknown) => (typeof e === 'string' ? e : (e as { message?: string })?.message))
.filter((m: unknown): m is string => typeof m === 'string' && m.length > 0);
const basis = details[0] ?? data?.error ?? data?.message ?? res.statusText;
// Manche eigenen Endpunkte hängen einen Diagnose-Grund an (data.detail).
const msg = typeof data?.detail === 'string' ? `${basis} (${data.detail})` : basis;
throw new ApiError(msg, res.status, details);
}
return data;
}
/** Erneuert die Session, wenn das Access-Token abgelaufen ist. */
async function ensureFreshToken(): Promise<Session | null> {
const session = loadSession();
if (!session) return null;
// 30 s Puffer, damit ein Request nicht mitten im Ablauf landet.
if (Date.now() < session.expires_at - 30_000) return session;
try {
const res = await fetch(`${BASE}/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: session.refresh_token, mode: 'json' }),
});
const data = await parse(res);
const next: Session = {
access_token: data.data?.access_token ?? data.access_token,
refresh_token: data.data?.refresh_token ?? data.refresh_token,
expires_at: Date.now() + (data.data?.expires ?? data.expires ?? 900_000),
};
saveSession(next);
return next;
} catch {
saveSession(null);
return null;
}
}
type RequestOptions = { method?: string; body?: unknown; auth?: boolean };
async function request(path: string, { method = 'GET', body, auth = true }: RequestOptions = {}) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (auth) {
const session = await ensureFreshToken();
if (session) headers.Authorization = `Bearer ${session.access_token}`;
}
const res = await fetch(BASE + path, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
return parse(res);
}
/* ── Auth (passwortlos, siehe Extension skrift-auth) ──────────────────────── */
export async function requestLoginCode(email: string) {
return request('/skrift-auth/request-code', { method: 'POST', body: { email }, auth: false });
}
export async function verifyLoginCode(email: string, code: string): Promise<Session> {
const data = await request('/skrift-auth/verify-code', {
method: 'POST',
body: { email, code },
auth: false,
});
const session: Session = {
access_token: data.access_token,
refresh_token: data.refresh_token,
expires_at: Date.now() + (data.expires ?? 900_000),
};
saveSession(session);
return session;
}
export function logout() {
saveSession(null);
}
/* ── Daten ────────────────────────────────────────────────────────────────── */
export type Order = {
id: number;
order_number: string;
status: string;
gross_total: number | null;
entries_count: number | null;
date_created: string | null;
product?: { name: string } | null;
};
/** Aufträge des angemeldeten Kunden – die Einschränkung erfolgt in Directus. */
export async function fetchMyOrders(): Promise<Order[]> {
const data = await request(
'/items/orders?fields=id,order_number,status,gross_total,entries_count,date_created,product.name' +
'&sort=-date_created&limit=100',
);
return data?.data ?? [];
}
export async function fetchMe() {
const data = await request('/users/me?fields=id,email,first_name,last_name');
return data?.data ?? null;
}
/* ── Stammdaten ───────────────────────────────────────────────────────────── */
export type CustomerAddress = {
label?: string;
street?: string;
zip?: string;
city?: string;
country?: string;
};
/** Feste Adresse ohne Bezeichnung (Rechnung bzw. Lieferung). */
export type Adresse2 = { street?: string; zip?: string; city?: string; country?: string };
export type Customer = {
/** Directus-User-ID (UUID) – Kunde ist das Login-Konto selbst. */
id: string;
email: string;
person_type: 'privat' | 'unternehmen' | null;
first_name: string | null;
last_name: string | null;
company: string | null;
phone: string | null;
/** Genau zwei feste Adressen ohne Namen. */
billing_address: Adresse2 | null;
shipping_address: Adresse2 | null;
/** Weitere Login-Mailadressen (nur Anzeige; Pflege durch den Betreiber). */
alt_emails: string[] | null;
/** Veraltet – frühere Mehrfach-Adressliste (nur für Alt-Daten). */
addresses?: CustomerAddress[] | null;
};
/**
* Profil des angemeldeten Kunden. Kunde = Login-Konto (directus_users),
* daher direkt über /users/me – kein separater „customers"-Datensatz mehr.
*/
export async function fetchMyCustomer(): Promise<Customer | null> {
const data = await request(
'/users/me?fields=id,email,person_type,first_name,last_name,company,phone,billing_address,shipping_address,alt_emails',
);
return data?.data ?? null;
}
/** person_type fehlt bewusst – die Einstufung ändert nur der Betreiber. */
export async function saveMyCustomer(c: Customer) {
return request('/users/me', {
method: 'PATCH',
body: {
first_name: c.first_name,
last_name: c.last_name,
company: c.company,
phone: c.phone,
billing_address: c.billing_address ?? null,
shipping_address: c.shipping_address ?? null,
},
});
}
/* ── Katalog (öffentlich lesbar – auch für Gäste) ─────────────────────────── */
export type Product = {
id: number;
key: string;
name: string;
description: string | null;
type: 'letter' | 'postcard' | 'sample' | 'contact';
pricing_mode: 'formel' | 'auf_anfrage';
/** Für wen das Produkt sichtbar ist (im Admin pflegbar). */
visibility: 'alle' | 'privat' | 'unternehmen';
/** Eingabeart im Konfigurator: freier Text oder Datei-Upload. */
input_mode: 'text' | 'datei';
};
export type Format = { id: number; key: string; name: string; product: number };
/**
* Produkt-Schlüssel, die für den angemeldeten Kunden individuell freigeschaltet
* sind (z. B. Unterschriftenservice). Nur mit Login sinnvoll; sonst leer.
*/
export async function fetchMyUnlockedProducts(): Promise<string[]> {
if (!loadSession()) return [];
try {
const data = await request('/items/customer_products?fields=product.key&limit=-1');
return (data?.data ?? [])
.map((r: { product?: { key?: string } }) => r.product?.key)
.filter((k: unknown): k is string => typeof k === 'string');
} catch {
return [];
}
}
export async function fetchProducts(): Promise<Product[]> {
// fields=* damit ein noch fehlendes Feld (z. B. visibility vor dem Bootstrap)
// den Aufruf nicht scheitern lässt.
const data = await request(
'/items/products?fields=*&filter[active][_eq]=true&sort=sort&limit=-1',
{ auth: false },
);
return (data?.data ?? []).map((p: Product) => ({ ...p, visibility: p.visibility ?? 'alle', input_mode: p.input_mode ?? 'text' }));
}
export async function fetchFormats(): Promise<Format[]> {
const data = await request(
'/items/formats?fields=id,key,name,product&filter[active][_eq]=true&limit=-1',
{ auth: false },
);
return data?.data ?? [];
}
/* ── Preis & Bestellung (Extension skrift-orders) ─────────────────────────── */
export type QuoteLine = { label: string; amount: number; per_unit?: boolean };
export type Quote = {
lines: QuoteLine[];
net_total: number;
/** Netto-Summe nur der pro-Stück-Positionen (ohne einmalige Kosten). */
unit_net_total: number;
vat_amount: number;
gross_total: number;
mwst_percent: number;
paypal_allowed: boolean;
};
export type QuoteInput = {
product: string;
format?: string;
quantity: number;
shipping_type: 'einzeln' | 'sammel';
needs_envelope: boolean;
envelope_labeling?: 'empfaenger' | 'freitext' | 'keine';
motif_mode?: string;
addons?: string[];
voucher_code?: string;
/** Optional: bekannte Kunden-E-Mail, damit hinterlegte Kundenrabatte greifen. */
email?: string;
/** Segment für den Mengen-Multiplikator (B2B/B2C). */
person_type?: 'privat' | 'unternehmen';
};
/** Preis serverseitig berechnen lassen – der Client rechnet nie selbst. */
export async function fetchQuote(input: QuoteInput): Promise<Quote> {
return request('/skrift-orders/quote', { method: 'POST', body: input, auth: false });
}
/* ── Motive ───────────────────────────────────────────────────────────────── */
export type Motif = {
id: number;
name: string;
image: string | null;
orientation: 'quer' | 'hochkant' | null;
};
export async function fetchMotifs(): Promise<Motif[]> {
const data = await request(
'/items/motifs?fields=id,name,image,orientation&filter[active][_eq]=true&sort=sort&limit=-1',
{ auth: false },
);
return data?.data ?? [];
}
/** Öffentliche URL einer Directus-Datei. */
export const assetUrl = (id: string) => `${BASE}/assets/${id}`;
/* ── Preis-Positionen (für Aufpreise an den Optionen) ─────────────────────── */
export type PriceItem = {
key: string;
label: string;
category: string;
price: number;
unit: 'einmalig' | 'pro_stueck' | 'prozent';
};
export async function fetchPriceItems(): Promise<PriceItem[]> {
const data = await request(
'/items/price_items?fields=key,label,category,price,unit&filter[active][_eq]=true&limit=-1',
{ auth: false },
);
return data?.data ?? [];
}
/* ── Vorschau ─────────────────────────────────────────────────────────────── */
export type PreviewDoc = { art: 'brief' | 'umschlag'; svg: string };
/** Ein Schriftstück, dessen Text nicht auf das Format passt (Zeilen-Überlauf). */
export type PreviewOverflow = { index: number; lineCount: number; lineLimit: number };
export type PreviewResult = {
docs: PreviewDoc[];
remaining: number;
limit: number;
/** true, wenn mindestens ein Schriftstück zu lang für das Format ist. */
has_overflow?: boolean;
overflow?: PreviewOverflow[];
/** Vorschau-Session – bei der Bestellung mitschicken, damit gezeigte
* Dokumente 1:1 übernommen werden. */
session?: string;
};
export async function fetchPreviewQuota(kennung: string): Promise<{ remaining: number; limit: number }> {
const p = kennung.includes('@') ? `email=${encodeURIComponent(kennung)}` : `client_id=${encodeURIComponent(kennung)}`;
return request(`/skrift-orders/preview/quota?${p}`, { auth: false });
}
export type PreviewInput = {
email?: string;
client_id?: string;
text: string;
font: string;
format?: string;
realistic?: boolean;
envelope_labeling?: string;
envelope_text?: string;
entries: unknown[];
};
export async function generatePreview(input: PreviewInput): Promise<PreviewResult> {
return request('/skrift-orders/preview', { method: 'POST', body: input, auth: false });
}
/**
* Reine Zeilen-/Überlauf-Prüfung (beim „Weiter" vom Text-Schritt) – verbraucht
* KEIN Vorschau-Kontingent und liefert keine SVGs.
*/
export async function validateText(
input: PreviewInput,
): Promise<{ has_overflow?: boolean; overflow?: PreviewOverflow[] }> {
return request('/skrift-orders/preview', { method: 'POST', body: { ...input, validate: true }, auth: false });
}
/* ── Bestellung absenden ──────────────────────────────────────────────────── */
export type OrderInput = {
email: string;
first_name?: string;
last_name?: string;
company?: string;
phone?: string;
person_type: 'privat' | 'unternehmen';
product: string;
format?: string;
font: string;
shipping_type: 'einzeln' | 'sammel';
needs_envelope: boolean;
envelope_labeling?: string;
motif_mode?: string;
text_template?: string;
text_briefing?: string;
/** Hochgeladenes Dokument (Datei-Produkte, z. B. zu unterschreibendes PDF). */
source_file?: string;
/** Alle hochgeladenen Dokumente (Datei-Produkte, mehrere möglich). */
source_files?: string[];
/** Rechnungs-/Lieferadresse als Snapshot (für Bestätigung & Rechnung). */
billing?: Record<string, string>;
shipping_address?: Record<string, string> | null;
/** Vorschau-Session (gezeigte Dokumente werden 1:1 übernommen). */
preview_session?: string;
addons?: string[];
voucher_code?: string;
payment_method: 'paypal' | 'rechnung';
source?: string;
entries: unknown[];
/** Motiv aus dem Katalog (ID) bzw. hochgeladene Datei (Directus-Datei-ID). */
motif?: number;
motif_upload?: string;
};
export type OrderResult = {
order_number: string;
net_total: number;
vat_amount: number;
gross_total: number;
payment_method: 'paypal' | 'rechnung';
paypal_allowed: boolean;
};
export async function createOrder(input: OrderInput): Promise<OrderResult> {
return request('/skrift-orders', { method: 'POST', body: input, auth: false });
}
/** Vermerkt eine erfolgreiche PayPal-Zahlung am Auftrag. */
export async function confirmPayment(order_number: string, payment_ref: string) {
return request('/skrift-orders/payment', {
method: 'POST',
body: { order_number, payment_ref },
auth: false,
});
}
/* ── Öffentliche Einstellungen (Preisformel, PayPal-Client-ID) ─────────────── */
export type PublicSettings = {
mwst_percent: number;
/** Erlaubte Zeichen als Regex-Klasse (z. B. "\\x20-\\x7EÄÖÜ…"). */
char_whitelist: string | null;
paypal_limit_net: number;
paypal_enabled: boolean;
paypal_mode: 'sandbox' | 'live';
paypal_client_id_sandbox: string | null;
paypal_client_id_live: string | null;
};
/**
* Bewusst `fields=*`: Wenn einzelne Felder (z. B. die PayPal-Spalten) noch
* nicht angelegt sind, würde eine explizite Feldliste den ganzen Aufruf
* scheitern lassen.
*/
export async function fetchSettings(): Promise<PublicSettings | null> {
const data = await request('/items/pricing_settings?fields=*', { auth: false });
return data?.data ?? null;
}
/** Die zum Modus passende öffentliche Client-ID. */
export const paypalClientId = (s: PublicSettings | null) =>
!s || !s.paypal_enabled
? null
: (s.paypal_mode === 'live' ? s.paypal_client_id_live : s.paypal_client_id_sandbox) || null;
/* ── Datei-Upload (Motiv) ─────────────────────────────────────────────────── */
/** Lädt eine Datei nach Directus und liefert deren ID. */
export async function uploadFile(datei: File): Promise<string> {
const form = new FormData();
form.append('file', datei);
const session = await ensureFreshToken();
const res = await fetch(`${BASE}/files`, {
method: 'POST',
headers: session ? { Authorization: `Bearer ${session.access_token}` } : undefined,
body: form,
});
const data = await parse(res);
return data?.data?.id;
}
/* ── Angebotsanfrage ──────────────────────────────────────────────────────── */
export type OfferRequest = {
name: string;
email: string;
phone?: string;
quantity?: number | null;
message: string;
/** Bei Anfrage aus einem Kontakt-Produkt: dessen Name. */
product?: string;
};
export async function sendOfferRequest(input: OfferRequest) {
return request('/skrift-orders/offer-request', { method: 'POST', body: input, auth: false });
}
/* ── Musterbestellung (kostenlos) ─────────────────────────────────────────── */
export type SampleRequest = {
product: string;
name: string;
email: string;
phone?: string;
street: string;
zip: string;
city: string;
country: string;
message?: string;
};
export async function sendSampleRequest(input: SampleRequest) {
return request('/skrift-orders/sample-request', { method: 'POST', body: input, auth: false });
}
/* ── Statusverlauf & Benachrichtigungen ───────────────────────────────────── */
export type StatusEvent = {
id: number;
status: string;
note: string | null;
timestamp: string;
order: { id: number; order_number: string } | null;
};
/** Statusverlauf – Directus liefert nur Einträge zu eigenen Aufträgen. */
export async function fetchStatusEvents(orderId?: number): Promise<StatusEvent[]> {
const filter = orderId ? `&filter[order][_eq]=${orderId}` : '';
const data = await request(
`/items/status_history?fields=id,status,note,timestamp,order.id,order.order_number` +
`&sort=-timestamp&limit=30${filter}`,
);
return data?.data ?? [];
}
const SEEN_KEY = 'skrift.notifications.seen';
export function getSeenAt(): number {
const raw = localStorage.getItem(SEEN_KEY);
return raw ? Number(raw) : 0;
}
export function markNotificationsSeen() {
localStorage.setItem(SEEN_KEY, String(Date.now()));
}

37
src/lib/dialogs.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { createContext, useContext, useState } from 'react';
/**
* Zentrale Dialog-Steuerung: Hilfe, Individuelles Angebot und Muster bestellen
* werden an einer Stelle (AppShell) gerendert, aber von überall geöffnet
* (Seitenleiste, Konfigurator, Kontakt-Produkte).
*/
export type DialogName = 'hilfe' | 'angebot' | 'muster' | null;
type Ctx = {
dialog: DialogName;
/** Optionaler Vorbelegungstext (z. B. gewähltes Kontakt-Produkt). */
vorbelegung: string;
open: (d: Exclude<DialogName, null>, vorbelegung?: string) => void;
close: () => void;
};
const DialogContext = createContext<Ctx>({ dialog: null, vorbelegung: '', open: () => {}, close: () => {} });
export function DialogsProvider({ children }: { children: React.ReactNode }) {
const [dialog, setDialog] = useState<DialogName>(null);
const [vorbelegung, setVorbelegung] = useState('');
return (
<DialogContext.Provider
value={{
dialog,
vorbelegung,
open: (d, v = '') => { setVorbelegung(v); setDialog(d); },
close: () => setDialog(null),
}}
>
{children}
</DialogContext.Provider>
);
}
export const useDialogs = () => useContext(DialogContext);

114
src/lib/excel.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* Excel-Import und -Vorlage für Empfängerdaten.
*
* Die Vorlage wird im Browser erzeugt – so bleibt sie automatisch synchron mit
* den erwarteten Spalten und muss nicht als Datei gepflegt werden.
*
* SheetJS wird bewusst erst bei Bedarf nachgeladen (dynamischer Import): Die
* Bibliothek ist größer als die restliche App und wird nur von wenigen Kunden
* gebraucht – sie gehört nicht ins Start-Bundle.
*/
export type ExcelEntry = {
salutation: string;
first_name: string;
last_name: string;
street: string;
house_no: string;
zip: string;
city: string;
country: string;
free_text: string;
placeholders: Record<string, string>;
};
/** Feste Spalten; alles Weitere wird als freier Platzhalter übernommen. */
const SPALTEN: Record<string, keyof ExcelEntry> = {
anrede: 'salutation',
vorname: 'first_name',
nachname: 'last_name',
name: 'last_name',
strasse: 'street',
straße: 'street',
nr: 'house_no',
hausnummer: 'house_no',
plz: 'zip',
ort: 'city',
stadt: 'city',
land: 'country',
};
const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, '');
export const VORLAGEN_SPALTEN = [
'Anrede', 'Vorname', 'Nachname', 'Strasse', 'Nr', 'PLZ', 'Ort', 'Land',
];
/** Spalten der Freitext-Variante: fünf frei beschreibbare Zeilen. */
export const FREITEXT_SPALTEN = ['Zeile 1', 'Zeile 2', 'Zeile 3', 'Zeile 4', 'Zeile 5'];
/**
* Erzeugt die Vorlagendatei und stößt den Download an.
* `nurZusatz` = nur die Platzhalter-Spalten (ohne Adressspalten) – für den
* Fall, dass nur Platzhalterwerte je Dokument gebraucht werden (Sammelversand
* ohne Kuvert), nicht aber Empfängeradressen.
*/
export async function ladeVorlageHerunter(zusatzSpalten: string[] = [], freitext = false, nurZusatz = false) {
const XLSX = await import('xlsx');
const basis = nurZusatz ? [] : (freitext ? FREITEXT_SPALTEN : VORLAGEN_SPALTEN);
const kopf = [...basis, ...zusatzSpalten];
const beispielBasis = nurZusatz ? [] : (freitext
? ['Erika Mustermann', 'Hauptstraße 1', '10115 Berlin', '', '']
: ['Frau', 'Erika', 'Mustermann', 'Hauptstraße', '1', '10115', 'Berlin', 'DE']);
const beispiel = [...beispielBasis, ...zusatzSpalten.map(() => 'Beispielwert')];
const blatt = XLSX.utils.aoa_to_sheet([kopf, beispiel]);
blatt['!cols'] = kopf.map(() => ({ wch: 16 }));
const mappe = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(mappe, blatt, 'Empfaenger');
XLSX.writeFile(mappe, 'skrift-empfaenger-vorlage.xlsx');
}
/** Liest eine hochgeladene Datei ein und wandelt sie in Empfängerzeilen. */
export async function leseEmpfaengerDatei(datei: File): Promise<ExcelEntry[]> {
const XLSX = await import('xlsx');
const puffer = await datei.arrayBuffer();
const mappe = XLSX.read(puffer, { type: 'array' });
const blatt = mappe.Sheets[mappe.SheetNames[0]];
if (!blatt) throw new Error('Die Datei enthält kein Tabellenblatt.');
const zeilen = XLSX.utils.sheet_to_json<Record<string, unknown>>(blatt, { defval: '' });
if (!zeilen.length) throw new Error('Die Datei enthält keine Datenzeilen.');
return zeilen.map((zeile) => {
const eintrag: ExcelEntry = {
salutation: '', first_name: '', last_name: '', street: '',
house_no: '', zip: '', city: '', country: 'DE',
free_text: '', placeholders: {},
};
// Freitext-Vorlage: „Zeile 1..5" werden zur mehrzeiligen Adresse.
const freitextZeilen: string[] = [];
for (const [spalte, wert] of Object.entries(zeile)) {
const text = String(wert ?? '').trim();
const zeilenTreffer = /^zeile\s*(\d+)$/i.exec(spalte.trim());
const feld = SPALTEN[norm(spalte)];
if (zeilenTreffer) {
freitextZeilen[Number(zeilenTreffer[1]) - 1] = text;
} else if (feld && feld !== 'placeholders') {
(eintrag[feld] as string) = text;
} else if (text) {
// Unbekannte Spalten werden zu Platzhaltern – so lassen sich
// beliebige eigene Felder im Text verwenden.
eintrag.placeholders[spalte.trim()] = text;
}
}
if (freitextZeilen.length) {
eintrag.free_text = freitextZeilen.map((z) => z ?? '').join('\n').replace(/\n+$/, '');
}
return eintrag;
});
}

86
src/lib/paypal.ts Normal file
View File

@@ -0,0 +1,86 @@
/**
* PayPal-Anbindung – clientseitig, wie im bisherigen Konfigurator.
*
* Es wird ausschließlich die öffentliche Client-ID verwendet; das Secret bleibt
* außen vor. Die Zahlung wird über `actions.order.create/capture` direkt gegen
* PayPal abgewickelt, die Referenz danach in Directus vermerkt.
*/
let geladen: Promise<void> | null = null;
declare global {
interface Window {
paypal?: {
Buttons: (config: Record<string, unknown>) => {
render: (el: HTMLElement) => Promise<void>;
};
};
}
}
/** Lädt das PayPal-SDK genau einmal nach. */
export function ladePayPal(clientId: string): Promise<void> {
if (window.paypal) return Promise.resolve();
if (geladen) return geladen;
geladen = new Promise<void>((resolve, reject) => {
const script = document.createElement('script');
script.src =
`https://www.paypal.com/sdk/js?client-id=${encodeURIComponent(clientId)}` +
'&currency=EUR&intent=capture&disable-funding=venmo,paylater,credit';
script.async = true;
script.onload = () => resolve();
script.onerror = () => {
geladen = null;
reject(new Error('PayPal konnte nicht geladen werden.'));
};
document.head.appendChild(script);
});
return geladen;
}
export type PayPalOptions = {
betrag: number;
beschreibung: string;
onApproved: (referenz: string) => void | Promise<void>;
onError: (meldung: string) => void;
};
/** Rendert die PayPal-Buttons in das übergebene Element. */
export async function rendereButtons(el: HTMLElement, o: PayPalOptions) {
if (!window.paypal) throw new Error('PayPal ist nicht geladen.');
el.innerHTML = '';
await window.paypal
.Buttons({
style: { shape: 'rect', layout: 'vertical', color: 'blue', label: 'paypal' },
createOrder: (_data: unknown, actions: any) =>
actions.order.create({
intent: 'CAPTURE',
purchase_units: [
{
amount: { currency_code: 'EUR', value: o.betrag.toFixed(2) },
description: o.beschreibung.slice(0, 127),
},
],
application_context: {
brand_name: 'Skrift',
locale: 'de-DE',
shipping_preference: 'NO_SHIPPING',
user_action: 'PAY_NOW',
},
}),
onApprove: (_data: unknown, actions: any) =>
actions.order.capture().then((details: any) => {
const capture = details?.purchase_units?.[0]?.payments?.captures?.[0];
if (capture?.status === 'COMPLETED') return o.onApproved(capture.id);
o.onError('Die Zahlung wurde nicht abgeschlossen.');
}),
onError: () => o.onError('Bei der Zahlung ist ein Fehler aufgetreten.'),
})
.render(el);
}

18
src/lib/status.ts Normal file
View File

@@ -0,0 +1,18 @@
/** Anzeigetexte für die Auftragsstatus aus Directus – an einer Stelle gepflegt. */
export const STATUS_LABEL: Record<string, string> = {
entwurf: 'Entwurf',
vorbereitet: 'Für Sie vorbereitet',
wartet_auf_zahlung: 'Wartet auf Zahlung',
bezahlt: 'Bezahlt',
material_erwartet: 'Material erwartet',
material_eingegangen: 'Material eingegangen',
in_queue: 'In der Warteschlange',
in_produktion: 'In Produktion',
gedruckt: 'Geschrieben',
versendet: 'Versendet',
abgeschlossen: 'Abgeschlossen',
storniert: 'Storniert',
fehler: 'Fehler',
};
export const statusText = (s: string) => STATUS_LABEL[s] ?? s;

46
src/lib/wizard.tsx Normal file
View File

@@ -0,0 +1,46 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
/**
* Der Konfigurator meldet seine Schritte hierher, damit die Seitenleiste den
* Fortschritt anzeigen kann – ohne dass Shell und Wizard voneinander wissen
* müssen. Umgekehrt darf die Seitenleiste über `goto` zu einem bereits
* erledigten Schritt zurückspringen; der Konfigurator registriert dafür seinen
* Handler (nur Rückwärtssprünge sind erlaubt).
*/
export type WizardState = { steps: string[]; current: number };
type Ctx = WizardState & {
publish: (s: WizardState) => void;
/** Vom Konfigurator aufgerufen: setzt den aktiven Sprung-Handler. */
registerGoto: (fn: (i: number) => void) => void;
/** Von der Seitenleiste aufgerufen: springe zu Schritt i. */
goto: (i: number) => void;
};
const WizardContext = createContext<Ctx>({
steps: [], current: 0, publish: () => {}, registerGoto: () => {}, goto: () => {},
});
export function WizardProvider({ children }: { children: React.ReactNode }) {
const [state, setState] = useState<WizardState>({ steps: [], current: 0 });
const gotoRef = useRef<(i: number) => void>(() => {});
const publish = useCallback((next: WizardState) => {
setState((prev) =>
prev.current === next.current && prev.steps.join('|') === next.steps.join('|')
? prev // unnötige Renders vermeiden
: next,
);
}, []);
const registerGoto = useCallback((fn: (i: number) => void) => { gotoRef.current = fn; }, []);
const goto = useCallback((i: number) => gotoRef.current(i), []);
const value = useMemo<Ctx>(
() => ({ ...state, publish, registerGoto, goto }),
[state, publish, registerGoto, goto],
);
return <WizardContext.Provider value={value}>{children}</WizardContext.Provider>;
}
export const useWizard = () => useContext(WizardContext);

14
src/main.tsx Normal file
View File

@@ -0,0 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/tokens.css';
import './styles/global.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

123
src/routes/Auftraege.tsx Normal file
View File

@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { ApiError, fetchMyOrders, fetchStatusEvents, type Order, type StatusEvent } from '../lib/api';
import { statusText } from '../lib/status';
import { IconChevronDown } from '../components/Icons';
const euro = (v: number | null) =>
v == null ? '—' : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
const datum = (v: string | null) =>
v ? new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(v)) : '';
const zeitpunkt = (v: string) =>
new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(v));
/** Statusverlauf eines Auftrags – wird erst bei Bedarf geladen. */
function Verlauf({ orderId }: { orderId: number }) {
const [events, setEvents] = useState<StatusEvent[] | null>(null);
useEffect(() => {
fetchStatusEvents(orderId)
.then(setEvents)
.catch(() => setEvents([]));
}, [orderId]);
if (events === null) return <p className="sk-note">Verlauf wird geladen …</p>;
if (events.length === 0) return <p className="sk-note">Noch kein Verlauf vorhanden.</p>;
return (
<ol className="sk-timeline">
{events.map((e) => (
<li key={e.id} className="sk-timeline-item">
<span className="sk-timeline-mark" />
<div>
<div className="sk-timeline-body">{statusText(e.status)}</div>
{e.note && <div className="sk-timeline-time">{e.note}</div>}
<div className="sk-timeline-time">{zeitpunkt(e.timestamp)}</div>
</div>
</li>
))}
</ol>
);
}
export default function Auftraege() {
const navigate = useNavigate();
const [orders, setOrders] = useState<Order[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [offen, setOffen] = useState<number | null>(null);
useEffect(() => {
fetchMyOrders()
.then(setOrders)
.catch((err) => {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
navigate('/login', { replace: true });
return;
}
setError(err instanceof ApiError ? err.message : 'Aufträge konnten nicht geladen werden.');
});
}, [navigate]);
return (
<div className="sk-view">
<h1>Aufträge</h1>
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
Alle Ihre Aufträge mit aktuellem Stand. Klicken Sie einen Auftrag an, um den Verlauf zu sehen.
</p>
{error && <p className="sk-error">{error}</p>}
{orders === null && !error && <p className="sk-note">Wird geladen …</p>}
{orders && orders.length === 0 && (
<div className="sk-empty">
<p style={{ marginBottom: 'var(--sk-space-4)' }}>Sie haben noch keine Aufträge.</p>
<Link to="/" className="sk-btn sk-btn--primary">
Ersten Auftrag anlegen
</Link>
</div>
)}
{orders && orders.length > 0 && (
<ul className="sk-order-list">
{orders.map((o) => (
<li key={o.id}>
<button
className="sk-order sk-order--clickable"
aria-expanded={offen === o.id}
onClick={() => setOffen(offen === o.id ? null : o.id)}
>
<span>
<span className="sk-order-no" style={{ display: 'block' }}>
{o.order_number}
</span>
<span className="sk-order-meta">
{[o.product?.name, o.entries_count ? `${o.entries_count} Stück` : null, datum(o.date_created)]
.filter(Boolean)
.join(' · ')}
</span>
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 'var(--sk-space-4)' }}>
<span className="sk-chip">{statusText(o.status)}</span>
<span className="sk-order-total">{euro(o.gross_total)}</span>
<IconChevronDown
size={18}
className={`sk-order-caret${offen === o.id ? ' sk-order-caret--open' : ''}`}
/>
</span>
</button>
{offen === o.id && (
<div className="sk-card" style={{ marginTop: 'var(--sk-space-2)' }}>
<h3 style={{ fontSize: 'var(--sk-size-sm)' }}>Verlauf</h3>
<Verlauf orderId={o.id} />
</div>
)}
</li>
))}
</ul>
)}
</div>
);
}

2062
src/routes/Configurator.tsx Normal file

File diff suppressed because it is too large Load Diff

121
src/routes/Login.tsx Normal file
View File

@@ -0,0 +1,121 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { requestLoginCode, verifyLoginCode, ApiError } from '../lib/api';
/**
* Passwortloser Login: E-Mail eingeben → Code per Mail → Code eingeben.
* Die Antwort auf "Code anfordern" ist bewusst immer positiv – der Server
* verrät nicht, ob es zu einer Adresse ein Konto gibt.
*/
export default function Login() {
const navigate = useNavigate();
const [step, setStep] = useState<'email' | 'code'>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submitEmail(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await requestLoginCode(email.trim());
setStep('code');
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Anfrage fehlgeschlagen.');
} finally {
setBusy(false);
}
}
async function submitCode(e: React.FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
await verifyLoginCode(email.trim(), code.trim());
navigate('/auftraege', { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Anmeldung fehlgeschlagen.');
} finally {
setBusy(false);
}
}
return (
<div className="sk-view" style={{ maxWidth: 'var(--sk-form-width)' }}>
<h1>Anmelden</h1>
{step === 'email' ? (
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitEmail}>
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
Sofern Sie bereits eine Bestellung bei uns aufgegeben haben, senden wir Ihnen einen
Anmeldecode an Ihre E-Mail-Adresse. Kein Passwort nötig.
</p>
<div className="sk-field">
<label className="sk-label" htmlFor="email">
E-Mail-Adresse
</label>
<input
id="email"
className="sk-input"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy}>
{busy ? 'Wird gesendet …' : 'Code anfordern'}
</button>
{error && <p className="sk-error">{error}</p>}
</form>
) : (
<form className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }} onSubmit={submitCode}>
<p className="sk-note" style={{ marginBottom: 'var(--sk-space-5)' }}>
Wir haben einen Code an <strong>{email}</strong> gesendet. Er ist 10 Minuten gültig.
</p>
<div className="sk-field">
<label className="sk-label" htmlFor="code">
Anmeldecode
</label>
<input
id="code"
className="sk-input sk-input--code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
pattern="\d{6}"
required
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
/>
</div>
<button className="sk-btn sk-btn--primary sk-btn--block" disabled={busy || code.length !== 6}>
{busy ? 'Wird geprüft …' : 'Anmelden'}
</button>
{error && <p className="sk-error">{error}</p>}
<button
type="button"
className="sk-btn sk-btn--ghost sk-btn--block"
style={{ marginTop: 'var(--sk-space-3)' }}
onClick={() => {
setStep('email');
setCode('');
setError(null);
}}
>
Andere Adresse verwenden
</button>
</form>
)}
</div>
);
}

8
src/routes/Muster.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { useNavigate } from 'react-router-dom';
import { Muster } from '../components/AppShell';
/** Musteranfrage als eigene Seite (Direktlink /muster) – kein Popup. */
export default function MusterSeite() {
const navigate = useNavigate();
return <Muster variant="page" onClose={() => navigate('/')} />;
}

137
src/routes/Stammdaten.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { fetchMyCustomer, saveMyCustomer, type Customer, type Adresse2 } from '../lib/api';
const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich'];
/** Stammdaten des angemeldeten Kunden – füllen später den Konfigurator vor. */
export default function Stammdaten() {
const [c, setC] = useState<Customer | null>(null);
const [status, setStatus] = useState<'laden' | 'bereit' | 'speichert' | 'gespeichert'>('laden');
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchMyCustomer()
.then((data) => {
setC(data);
setStatus('bereit');
})
.catch(() => {
setError('Stammdaten konnten nicht geladen werden.');
setStatus('bereit');
});
}, []);
async function speichern(e: React.FormEvent) {
e.preventDefault();
if (!c) return;
setStatus('speichert');
setError(null);
try {
await saveMyCustomer(c);
setStatus('gespeichert');
setTimeout(() => setStatus('bereit'), 2500);
} catch {
setError('Speichern fehlgeschlagen.');
setStatus('bereit');
}
}
// ── Zwei feste Adressen (Rechnung + Lieferung, ohne Bezeichnung) ──
const setAdrFeld = (slot: 'billing_address' | 'shipping_address', k: keyof Adresse2, v: string) =>
c && setC({ ...c, [slot]: { ...(c[slot] ?? {}), [k]: v } });
const adressBlock = (slot: 'billing_address' | 'shipping_address', titel: string) => {
const a: Adresse2 = c?.[slot] ?? {};
return (
<div className="sk-check--card" style={{ padding: 'var(--sk-space-4)', marginBottom: 'var(--sk-space-3)', border: '1px solid var(--sk-border)', borderRadius: 'var(--sk-radius, 10px)' }}>
<label className="sk-label" style={{ marginBottom: 'var(--sk-space-2)', display: 'block' }}>{titel}</label>
<input className="sk-input" style={{ marginBottom: 'var(--sk-space-2)' }} placeholder="Straße & Hausnummer"
value={a.street ?? ''} onChange={(e) => setAdrFeld(slot, 'street', e.target.value)} />
<div style={{ display: 'flex', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<input className="sk-input" placeholder="PLZ" value={a.zip ?? ''}
onChange={(e) => setAdrFeld(slot, 'zip', e.target.value.replace(/[^0-9]/g, ''))} style={{ flex: '0 0 30%' }} />
<input className="sk-input" placeholder="Ort" value={a.city ?? ''}
onChange={(e) => setAdrFeld(slot, 'city', e.target.value)} />
</div>
<select className="sk-input" value={a.country ?? 'Deutschland'} onChange={(e) => setAdrFeld(slot, 'country', e.target.value)}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
);
};
const feld = (k: keyof Customer, label: string, type = 'text') => (
<div className="sk-field">
<label className="sk-label" htmlFor={k}>
{label}
</label>
<input
id={k}
className="sk-input"
type={type}
value={(c?.[k] as string) ?? ''}
onChange={(e) => c && setC({ ...c, [k]: e.target.value })}
/>
</div>
);
return (
<div className="sk-view">
<h1>Stammdaten</h1>
<p className="sk-note" style={{ margin: 'var(--sk-space-2) 0 var(--sk-space-5)' }}>
Diese Angaben füllen wir bei neuen Aufträgen automatisch für Sie aus.
</p>
{status === 'laden' && <p className="sk-note">Wird geladen …</p>}
{error && <p className="sk-error">{error}</p>}
{c && (
<form className="sk-card" onSubmit={speichern}>
{/* Die Einstufung legt Skrift fest – sie bestimmt die Preisdarstellung. */}
<div className="sk-field">
<label className="sk-label">Sie bestellen als</label>
<input
className="sk-input"
value={c.person_type === 'unternehmen' ? 'Unternehmen' : 'Privatperson'}
disabled
/>
</div>
{feld('first_name', 'Vorname')}
{feld('last_name', 'Nachname')}
{c.person_type === 'unternehmen' && feld('company', 'Firma')}
{feld('phone', 'Telefon', 'tel')}
<div className="sk-field">
<label className="sk-label">E-Mail</label>
<input className="sk-input" value={c.email} disabled />
</div>
{/* Zwei feste Adressen – werden automatisch im Bestellvorgang eingesetzt. */}
<div className="sk-field" style={{ marginBottom: 0 }}>
{adressBlock('billing_address', 'Rechnungsadresse')}
{adressBlock('shipping_address', 'Lieferadresse')}
</div>
{/* Zusätzliche Login-Mailadressen (Pflege durch Skrift). */}
{c.alt_emails && c.alt_emails.length > 0 && (
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
<label className="sk-label">Weitere Login-Adressen</label>
<p className="sk-note">{c.alt_emails.join(', ')}</p>
</div>
)}
{status === 'gespeichert' && (
<p className="sk-note" role="status" style={{
marginTop: 'var(--sk-space-4)', color: 'var(--sk-success, #2e7d32)', fontWeight: 600,
}}>✓ Stammdaten gespeichert.</p>
)}
<button className="sk-btn sk-btn--primary" style={{ marginTop: 'var(--sk-space-4)' }} disabled={status === 'speichert'}>
{status === 'speichert' ? 'Wird gespeichert …' : status === 'gespeichert' ? 'Gespeichert' : 'Speichern'}
</button>
</form>
)}
</div>
);
}

1165
src/styles/global.css Normal file

File diff suppressed because it is too large Load Diff

86
src/styles/tokens.css Normal file
View File

@@ -0,0 +1,86 @@
/**
* Skrift – Design-Tokens
*
* Übernommen aus dem bestehenden System (skrift-configurator/configurator.css
* und skrift-job-manager/job-app.css), damit App, Konfigurator und
* Auftragsmanager identisch aussehen. Werte hier nicht „verbessern" – sie sind
* die gemeinsame Grundlage.
*/
:root {
/* ── Farben (1:1 aus configurator.css) ──────────────────── */
--sk-bg: #f7f7f8;
--sk-card: #ffffff;
--sk-text: #0f172a;
--sk-muted: rgba(15, 23, 42, 0.68);
--sk-border: rgba(15, 23, 42, 0.1);
--sk-border-strong: rgba(15, 23, 42, 0.16);
--sk-accent: #1e3a6a;
--sk-accent-hover: #1a3258;
--sk-success: #059669;
--sk-warning: #f59e0b;
--sk-error: #dc2626;
/* Navigationsflächen dieser App.
Vorschlag: tiefes Marken-Navy statt des grellen Royalblaus – ruhiger und
markenkonsistent (identisch mit --sk-accent). Alternative: #0f172a (Slate).*/
--sk-nav: #1e3a6a;
--sk-nav-text: #ffffff;
--sk-nav-text-dim: rgba(255, 255, 255, 0.82);
--sk-nav-text-faint: rgba(255, 255, 255, 0.68);
/* Aliasse für bestehende Klassennamen in dieser App */
--sk-primary: var(--sk-accent);
--sk-primary-hover: var(--sk-accent-hover);
--sk-text-muted: var(--sk-muted);
--sk-text-on-primary: #ffffff;
--sk-white: var(--sk-card);
--sk-bg-1: var(--sk-bg);
--sk-bg-2: rgba(15, 23, 42, 0.04);
--sk-bg-3: #c9b7a8;
--sk-danger: var(--sk-error);
/* ── Radius & Abstände ──────────────────────────────────── */
--sk-radius-sm: 6px;
--sk-radius: 8px;
--sk-radius-lg: 8px; /* im Bestandssystem gibt es nur einen Radius */
--sk-gap: 18px;
--sk-space-1: 4px;
--sk-space-2: 8px;
--sk-space-3: 12px;
--sk-space-4: 16px;
--sk-space-5: 18px;
--sk-space-6: 24px;
--sk-space-7: 32px;
--sk-space-8: 48px;
/* ── Typografie ─────────────────────────────────────────── */
/* Work Sans als Familie, Größen aus dem Bestandssystem:
Bedienelemente 14px, Hilfetexte 12px, Kartentitel 16px.
(Die früher genannten 18px galten dem Fließtext und ließen die
Oberfläche „herangezoomt" wirken.) */
--sk-font: 'Work Sans', system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif;
--sk-size-base: 14px;
--sk-size-xs: 12px;
--sk-size-sm: 13px;
--sk-size-md: 14px;
--sk-size-lg: 16px;
--sk-size-xl: 20px;
--sk-size-2xl: 24px;
--sk-leading: 1.5;
--sk-leading-tight: 1.3;
--sk-weight-normal: 400;
--sk-weight-medium: 500;
--sk-weight-semibold: 600;
--sk-weight-bold: 700;
--sk-weight-price: 750;
/* ── Layout ─────────────────────────────────────────────── */
--sk-app-width: 1400px;
--sk-sidebar-width: 248px;
--sk-read-width: 1200px; /* wie .sk-job-app */
--sk-form-width: 520px;
}

10
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** URL der Directus-Instanz (Daten, Auth, Bestellungen). */
readonly VITE_DIRECTUS_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}