import { useEffect, useMemo, useRef, useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { ApiError, assetUrl, createOrder, fetchFormats, fetchMotifs, fetchMyCustomer, fetchMyUnlockedProducts, fetchPreviewQuota, fetchPriceItems, fetchProducts, fetchQuote, fetchMyOffers, fetchSettings, generatePreview, validateText, loadSession, paypalClientId, uploadFile, confirmPayment, type Format, type Motif, type PreviewDoc, type PriceItem, type Product, type PublicSettings, type Quote, type CustomerOffer, } from '../lib/api'; import { ladePayPal, rendereButtons } from '../lib/paypal'; import { ladeVorlageHerunter, leseEmpfaengerDatei } from '../lib/excel'; import { useWizard } from '../lib/wizard'; import PreviewPanel from '../components/PreviewPanel'; import OfferForm from '../components/OfferForm'; import { IconBuilding, IconUser } from '../components/Icons'; /** Kurzbeschreibungen je Produkt (Fallback, falls im Admin keine gesetzt ist). */ const PRODUKT_TEXT: Record = { briefe: 'Persönliche Briefe in echter Handschrift – für Grüße, Anschreiben und Mailings.', postkarten: 'Handgeschriebene Postkarten mit Ihrem Motiv – persönlich und aufmerksamkeitsstark.', unterschriftenservice: 'Lassen Sie Ihre Schreiben automatisiert unterschreiben und versenden.', follow_ups: 'Automatisierte handschriftliche Nachfass-Mailings für mehr Rücklauf.', }; /** Standard-Platzhalter der strukturierten Adresse (werden automatisch befüllt). * Ohne „Anrede" – dafür gibt es kein Empfängerfeld; als [[Anrede]] ist es ein * frei befüllbarer eigener Platzhalter. */ const STANDARD_STRUKTUR = ['Vorname', 'Nachname', 'Strasse', 'PLZ', 'Ort', 'Land']; /** Standard-Platzhalter der Freitext-Adresse. */ const STANDARD_FREITEXT = ['Zeile1', 'Zeile2', 'Zeile3', 'Zeile4', 'Zeile5']; /** Alle im Text verwendeten Platzhalter, in Reihenfolge des ersten Auftretens. */ function textPlatzhalter(text: string): string[] { const treffer = text.match(/\[\[([^\]]+)\]\]/g) ?? []; return [...new Set(treffer.map((t) => t.slice(2, -2).trim()).filter(Boolean))]; } /** Wert eines Standard-Platzhalters aus einer Empfängerzeile. */ function standardWert(e: Entry, name: string): string { switch (name.toLowerCase()) { case 'anrede': return e.salutation; case 'vorname': return e.first_name; case 'nachname': case 'name': return e.last_name; case 'strasse': case 'straße': return [e.street, e.house_no].filter(Boolean).join(' '); case 'plz': return e.zip; case 'ort': case 'stadt': return e.city; case 'land': return e.country; default: { const m = /^zeile\s*(\d+)$/i.exec(name); return m ? (e.free_text.split('\n')[Number(m[1]) - 1] ?? '') : ''; } } } /** Stabile anonyme Kennung, damit auch Gäste ein Vorschau-Kontingent haben. */ function clientId(): string { const KEY = 'skrift.client'; let id = localStorage.getItem(KEY); if (!id) { id = `c-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; localStorage.setItem(KEY, id); } return id; } /** * Konfigurator – ein Formular für Briefe und Postkarten. * * Reihenfolge: Personentyp → Produkt (inkl. Format) → Menge & Versand → * Motiv (nur Postkarte) → Empfängerdaten → Text → Rechnungsdaten → Übersicht. * Die Vorschau ist kein Schritt, sondern läuft rechts mit. */ type PersonType = 'privat' | 'unternehmen'; type ShippingType = 'einzeln' | 'sammel'; type Labeling = 'empfaenger' | 'freitext' | 'keine'; type MotifMode = 'katalog' | 'ohne' | 'upload' | 'gestaltungsservice' | 'eigene_karten'; type Entry = { salutation: string; first_name: string; last_name: string; street: string; house_no: string; zip: string; city: string; country: string; /** Alternative zur strukturierten Adresse. */ free_text: string; placeholders: Record; /** Abweichender Text NUR für dieses Schriftstück (leer/undefined = Text aus #1). */ text?: string; }; type Adresse = { first_name: string; last_name: string; company: string; email: string; phone: string; street: string; house_no: string; zip: string; city: string; country: string; }; const leereEntry = (): Entry => ({ salutation: '', first_name: '', last_name: '', street: '', house_no: '', zip: '', city: '', country: 'DE', free_text: '', placeholders: {}, }); const leereAdresse = (): Adresse => ({ first_name: '', last_name: '', company: '', email: '', phone: '', street: '', house_no: '', zip: '', city: '', country: 'Deutschland', }); /** Ab dieser Menge ist die Tabelle unpraktikabel – dann nur noch Excel-Upload. */ const TABELLEN_GRENZE = 25; /** Zeichengrenzen wie im bisherigen Konfigurator. */ const MAX_TEXT = { a4: 2500, a6: 500 }; /** Überlauf-Meldung ohne verwirrende „Nr." – es gibt fürs Schreiben ein Schriftstück. */ function overflowMeldung(overflow?: { index: number; lineCount: number; lineLimit: number }[]) { const schlimmste = (overflow ?? []).reduce<{ lineCount: number; lineLimit: number } | null>( (m, o) => (o.lineCount > (m?.lineCount ?? 0) ? o : m), null); const detail = schlimmste ? ` (${schlimmste.lineCount} von ${schlimmste.lineLimit} Zeilen)` : ''; return `Der Text ist zu lang für das gewählte Format${detail}. Bitte kürzen Sie den Text.`; } const MAX_UMSCHLAG_TEXT = 160; /** Freitext-Adressen: feste Zeilenzahl je Empfänger. */ const FREITEXT_ZEILEN = 5; /** Referenzmenge für den „ab"-Stückpreis, solange keine Menge gewählt wurde. */ const REFERENZ_MENGE = 500; const VORSCHAU_LIMIT = 10; /** * Entwurf – alle vom Nutzer eingegebenen Werte (Felder, Tabellen, Auswahl) * werden lokal zwischengespeichert, bis der Auftrag abgesendet ist. So gehen * Text und Empfängerlisten bei Neuladen oder Seitenwechsel nicht verloren. * Dateien (Motiv-/Excel-Upload) sind nicht serialisierbar und bleiben außen vor; * die aus der Excel-Datei gelesenen Zeilen stecken jedoch in `entries`. */ const ENTWURF_KEY = 'skrift.entwurf'; type Entwurf = { step: number; personType: PersonType | null; product: string | null; format: string | null; quantity: number; quantityChosen: boolean; shippingType: ShippingType; needsEnvelope: boolean; labeling: Labeling; motifMode: MotifMode; motifId: number | null; entries: Entry[]; adressModus: 'struktur' | 'freitext'; eingabeArt: 'tabelle' | 'excel'; umschlagText: string; realistisch: boolean; font: 'tilda' | 'alva' | 'ellie'; textservice: boolean; textBriefing: string; textTemplate: string; rechnung: Adresse; lieferAbweichend: boolean; lieferung: Adresse; voucher: string; zahlung: 'paypal' | 'rechnung'; }; function ladeEntwurf(): Partial { try { const roh = localStorage.getItem(ENTWURF_KEY); return roh ? (JSON.parse(roh) as Partial) : {}; } catch { return {}; } } const euro = (v: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v); /** Aufpreis-Anzeige rechts an einer Option: Betrag + Einheit darunter. */ type Aufpreis = { betrag: number; einheit: string } | null; function Option({ selected, onSelect, title, desc, preis, name, icon, hideInput, }: { selected: boolean; onSelect: () => void; title: string; desc?: string; preis?: Aufpreis; name: string; icon?: React.ReactNode; /** Nur Schritt 1 verbirgt das Radio-Symbol und zeigt stattdessen ein Icon. */ hideInput?: boolean; }) { return ( ); } /** * Formularfeld in der Machart des bestehenden Konfigurators: Pflichtfelder * tragen einen roten Stern, optionale bleiben unmarkiert – statt jedes * optionale Feld mit „(optional)" zuzupflastern. */ function Feld({ id, label, value, onChange, type = 'text', required, hint, numerisch, tel, }: { id: string; label: string; value: string; onChange: (v: string) => void; type?: string; required?: boolean; hint?: string; numerisch?: boolean; tel?: boolean; }) { const saeubern = (v: string) => { if (numerisch) return v.replace(/[^0-9]/g, ''); if (tel) return v.replace(/[^0-9+ ]/g, ''); // nur Ziffern, Plus und Leerzeichen return v; }; return (
onChange(saeubern(e.target.value))} /> {hint &&

{hint}

}
); } const istEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e.trim()); /** Länderauswahl wie im bestehenden Konfigurator. */ const LAENDER = ['Deutschland', 'Österreich', 'Schweiz', 'Luxemburg', 'Belgien', 'Niederlande', 'Frankreich']; export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: string }) { const { publish, registerGoto } = useWizard(); const [searchParams] = useSearchParams(); // Externer/Etsy-Modus (?extern=1): Bestellung ohne Preise & Zahlung, // nur Empfänger → Text → Übersicht (siehe schritte weiter unten). const externModus = searchParams.get('extern') === '1'; // Zwischengespeicherten Entwurf einmalig laden (siehe ENTWURF_KEY). const entwurf = useMemo(ladeEntwurf, []); const [products, setProducts] = useState([]); const [formats, setFormats] = useState([]); /** Individuell freigeschaltete Produkt-Schlüssel (nur bei Login). */ const [freigeschaltet, setFreigeschaltet] = useState([]); const [ladeFehler, setLadeFehler] = useState(null); // Über einen Direktlink (vorgewähltes Produkt) immer auf Schritt 1 starten – // nicht auf einen zwischengespeicherten späteren Schritt springen. const [step, setStep] = useState(vorwahlProdukt ? 0 : (entwurf.step ?? 0)); const [personType, setPersonType] = useState(entwurf.personType ?? null); const [product, setProduct] = useState(entwurf.product ?? null); const [format, setFormat] = useState(entwurf.format ?? null); const [quantity, setQuantity] = useState(entwurf.quantity ?? 100); const [quantityChosen, setQuantityChosen] = useState(entwurf.quantityChosen ?? false); /** Rohtext des Mengenfelds – darf zwischendurch leer sein; geklemmt wird beim Verlassen. */ const [mengeText, setMengeText] = useState(String(entwurf.quantity ?? 100)); const [shippingType, setShippingType] = useState(entwurf.shippingType ?? 'sammel'); const [needsEnvelope, setNeedsEnvelope] = useState(entwurf.needsEnvelope ?? true); const [labeling, setLabeling] = useState(entwurf.labeling ?? 'empfaenger'); const [motifMode, setMotifMode] = useState(entwurf.motifMode ?? 'katalog'); const [motifs, setMotifs] = useState([]); const [motifeGeladen, setMotifeGeladen] = useState(false); const [motifId, setMotifId] = useState(entwurf.motifId ?? null); // Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden // Motiv-Optionen – daher abgeleitet, kein eigener Zustand mehr. const designservice = motifMode === 'gestaltungsservice'; const [entries, setEntries] = useState(entwurf.entries ?? [leereEntry()]); /** Wie die Adresse aufgebaut ist. */ const [adressModus, setAdressModus] = useState<'struktur' | 'freitext'>(entwurf.adressModus ?? 'struktur'); /** Wie die Daten erfasst werden – ab 26 Empfängern nur noch Excel. */ const [eingabeArt, setEingabeArt] = useState<'tabelle' | 'excel'>(entwurf.eingabeArt ?? 'tabelle'); const [excelDatei, setExcelDatei] = useState(null); const [excelFehler, setExcelFehler] = useState(null); const [umschlagText, setUmschlagText] = useState(entwurf.umschlagText ?? ''); const [realistisch, setRealistisch] = useState(entwurf.realistisch ?? true); const [font, setFont] = useState<'tilda' | 'alva' | 'ellie'>(entwurf.font ?? 'tilda'); const [textservice, setTextservice] = useState(entwurf.textservice ?? false); const [textBriefing, setTextBriefing] = useState(entwurf.textBriefing ?? ''); const [textTemplate, setTextTemplate] = useState(entwurf.textTemplate ?? ''); /** Aktuell im Textfeld angezeigtes Schriftstück (0 = Standardtext, der für alle gilt). */ const [textDocIndex, setTextDocIndex] = useState(0); const [rechnung, setRechnung] = useState(entwurf.rechnung ?? leereAdresse()); const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false); const [lieferung, setLieferung] = useState(entwurf.lieferung ?? leereAdresse()); const [voucher, setVoucher] = useState(entwurf.voucher ?? ''); const [zahlung, setZahlung] = useState<'paypal' | 'rechnung'>(entwurf.zahlung ?? 'paypal'); const [agb, setAgb] = useState(false); const [datenschutz, setDatenschutz] = useState(false); const [final, setFinal] = useState(false); const [quote, setQuote] = useState(null); const [quoteFehler, setQuoteFehler] = useState(null); // Kundenangebote (customer_offers) für den eingeloggten Kunden + das Produkt. const [offers, setOffers] = useState([]); const [offerId, setOfferId] = useState(null); const [previewDocs, setPreviewDocs] = useState([]); const [previewLaedt, setPreviewLaedt] = useState(false); const [previewFehler, setPreviewFehler] = useState(null); const [previewVerbleibend, setPreviewVerbleibend] = useState(VORSCHAU_LIMIT); const [previewLimit, setPreviewLimit] = useState(VORSCHAU_LIMIT); /** Zeilen-Überlauf laut Backend-Vorschau: Text passt nicht auf das Format. */ const [textUeberlauf, setTextUeberlauf] = useState(false); /** Vorschau-Session: nur gültig, solange sich die Vorschau-Eingaben nicht * geändert haben – dann werden die gezeigten Dokumente 1:1 übernommen. */ const [previewSession, setPreviewSession] = useState(null); /** Läuft die Zeilen-Prüfung beim „Weiter"? */ const [pruefeLaedt, setPruefeLaedt] = useState(false); const [sendet, setSendet] = useState(false); const [bestellFehler, setBestellFehler] = useState([]); const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; betrag: number; extern?: boolean } | null>(null); const [settings, setSettings] = useState(null); const [preisKatalog, setPreisKatalog] = useState>({}); // Verbotene Zeichen: dieselbe Whitelist wie im Backend. Wir verhindern die // Eingabe direkt und zeigen kurz einen Hinweis, welches Zeichen verboten ist. const [zeichenHinweis, setZeichenHinweis] = useState(null); const verbotenRe = useMemo(() => { const wl = settings?.char_whitelist; if (!wl) return null; // Zeilenumbrüche sind im mehrzeiligen Text erlaubt; einzeilige Felder // entfernen \n/\r ohnehin separat vor saeubere(). Tabs (\t) sind erlaubt, // damit man z.B. das Datum an den rechten Rand rücken kann. try { return new RegExp(`[^${wl}\\n\\r\\t]`, 'g'); } catch { return null; } }, [settings?.char_whitelist]); /** Entfernt verbotene Zeichen und meldet sie; gibt den bereinigten Wert zurück. */ const saeubere = (v: string) => { if (!verbotenRe) return v; const treffer = v.match(verbotenRe); if (!treffer || !treffer.length) return v; const einmalig = [...new Set(treffer)].map((c) => (c === ' ' ? '␣' : c)).join(' '); setZeichenHinweis(`Zeichen ${einmalig} ist nicht erlaubt und wurde entfernt.`); return v.replace(verbotenRe, ''); }; // Hinweis nach kurzer Zeit automatisch ausblenden. useEffect(() => { if (!zeichenHinweis) return; const t = setTimeout(() => setZeichenHinweis(null), 4000); return () => clearTimeout(t); }, [zeichenHinweis]); const [motivDatei, setMotivDatei] = useState(null); /** Hochgeladenes Dokument bei Datei-Produkten (z. B. zu unterschreibendes PDF). */ /** Hochgeladene Dokumente bei Datei-Produkten (mehrere PDF/Word/Excel möglich). */ const [quellDateien, setQuellDateien] = useState([]); const paypalRef = useRef(null); useEffect(() => { // Katalogdaten sind zwingend … Promise.all([fetchProducts(), fetchFormats()]) .then(([p, f]) => { setProducts(p); setFormats(f); }) .catch(() => setLadeFehler('Produkte konnten nicht geladen werden.')); // … Motive, Einstellungen und Preise dagegen optional. Sie dürfen den // Konfigurator nicht blockieren, wenn Felder fehlen (z. B. vor dem Bootstrap). fetchMotifs().then(setMotifs).catch(() => setMotifs([])).finally(() => setMotifeGeladen(true)); fetchSettings().then(setSettings).catch(() => setSettings(null)); fetchPriceItems() .then((items) => setPreisKatalog(Object.fromEntries(items.map((i) => [i.key, i])))) .catch(() => setPreisKatalog({})); }, []); // Kontingent serverseitig erfragen (Gäste über eine anonyme Kennung). useEffect(() => { const kennung = rechnung.email.trim() || clientId(); fetchPreviewQuota(kennung) .then((q) => { setPreviewVerbleibend(q.remaining); setPreviewLimit(q.limit); }) .catch(() => { /* Kontingent ist nicht kritisch */ }); }, [rechnung.email]); // Angemeldete Kunden: individuell freigeschaltete Produkte laden. useEffect(() => { if (!loadSession()) return; fetchMyUnlockedProducts().then(setFreigeschaltet).catch(() => setFreigeschaltet([])); }, []); // Angemeldete Kunden: Einstufung + die zwei festen Adressen automatisch // übernehmen (Rechnung → Rechnungsdaten, Lieferung → abweichende Lieferadresse). // Kein Adress-Dropdown mehr; alles wird direkt eingesetzt. useEffect(() => { if (!loadSession()) return; fetchMyCustomer() .then((c) => { if (!c) return; if (c.person_type) setPersonType(c.person_type); const b = c.billing_address ?? {}; setRechnung((r) => ({ ...r, first_name: c.first_name ?? r.first_name, last_name: c.last_name ?? r.last_name, company: c.company ?? r.company, email: c.email ?? r.email, phone: c.phone ?? r.phone, street: b.street ?? r.street, zip: b.zip ?? r.zip, city: b.city ?? r.city, country: b.country ?? r.country, })); // Lieferadresse vorbefüllen, aber „abweichende Lieferadresse" bleibt per // Default AUS – der Kunde aktiviert sie bei Bedarf selbst. const s = c.shipping_address ?? {}; if (s.street || s.zip || s.city) { setLieferung((l) => ({ ...l, street: s.street ?? l.street, zip: s.zip ?? l.zip, city: s.city ?? l.city, country: s.country ?? l.country, })); } }) .catch(() => { /* Vorausfüllen ist optional */ }); }, []); // Sichtbarkeit je Personentyp (im Admin über products.visibility pflegbar). // Ein per Direktlink vorgewähltes Produkt bleibt immer sichtbar – sonst würde // die Anfrage abbrechen, sobald z. B. „privat" bei einem Unternehmens-Produkt gewählt wird. const sichtbar = (p: Product) => p.key === vorwahlProdukt || p.visibility === 'alle' || !personType || p.visibility === personType; // Konfigurierbar: Briefe/Postkarten – plus individuell freigeschaltete // Kontaktprodukte (z. B. Unterschriftenservice) für diesen Kunden. const bestellbar = useMemo( () => products.filter((p) => (p.type === 'letter' || p.type === 'postcard' || (p.type === 'contact' && freigeschaltet.includes(p.key))) && sichtbar(p)), [products, personType, freigeschaltet], ); // Anfrageprodukte: Kontaktprodukte, die NICHT freigeschaltet sind. const kontaktProdukte = useMemo( () => products.filter((p) => p.type === 'contact' && !freigeschaltet.includes(p.key) && sichtbar(p)), [products, personType, freigeschaltet], ); const gewaehltesProdukt = bestellbar.find((p) => p.key === product) ?? null; const produktFormate = gewaehltesProdukt ? formats.filter((f) => f.product === gewaehltesProdukt.id) : []; const istPostkarte = gewaehltesProdukt?.type === 'postcard'; // Datei-Produkt (z. B. Unterschriftenservice): im Text-Schritt lädt der Kunde // ein Dokument hoch, statt Text zu tippen. const dateiModus = gewaehltesProdukt?.input_mode === 'datei'; // Kein Preis im Konfigurator: Extern-Modus ODER Datei-Produkt (z. B. // Unterschriftenservice – dort wird individuell abgerechnet). const keinePreise = externModus || dateiModus; // Eigener Format-Schritt nur, wenn es überhaupt eine Wahl gibt. const brauchtFormatSchritt = produktFormate.length > 1; // Motive passend zum Postkartenformat (quer/hochkant). Motive ohne gesetzte // Ausrichtung erscheinen immer. const benoetigteAusrichtung: 'quer' | 'hochkant' | null = format?.includes('quer') ? 'quer' : format?.includes('hoch') ? 'hochkant' : null; const sichtbareMotive = useMemo( () => motifs.filter((m) => !benoetigteAusrichtung || !m.orientation || m.orientation === benoetigteAusrichtung), [motifs, benoetigteAusrichtung], ); const gewaehltesMotiv = sichtbareMotive.find((m) => m.id === motifId) ?? null; // Katalog-Auswahl nur anbieten, wenn (nach dem Laden) auch Motive hinterlegt // sind. Vor dem Laden bleibt die Option sichtbar (Standard), damit sie nicht // aufblitzt und wieder verschwindet. const katalogVerfuegbar = sichtbareMotive.length > 0; const katalogOptionSichtbar = !motifeGeladen || katalogVerfuegbar; // Ist „Motiv aus unserer Auswahl" gewählt, aber es gibt (nach dem Laden) keine // Motive für dieses Format, automatisch auf „Ohne Motiv" wechseln. useEffect(() => { if (motifeGeladen && !katalogVerfuegbar && motifMode === 'katalog') setMotifMode('ohne'); }, [motifeGeladen, katalogVerfuegbar, motifMode]); // Produkt aus dem Direktlink (z. B. /briefe) vorwählen, sobald geladen – // überschreibt auch ein evtl. zwischengespeichertes anderes Produkt. useEffect(() => { if (vorwahlProdukt && product !== vorwahlProdukt && products.some((p) => p.key === vorwahlProdukt)) { setProduct(vorwahlProdukt); } }, [vorwahlProdukt, product, products]); // Genau ein Format → still setzen (kein Format-Schritt nötig). Bei mehreren // Formaten (z. B. Postkarte hoch/quer) NICHT automatisch vorbelegen – der Kunde // wählt im Format-Schritt (auch im Extern-Modus). useEffect(() => { if (produktFormate.length === 1 && format !== produktFormate[0].key) { setFormat(produktFormate[0].key); } }, [produktFormate, format]); const nurUpload = quantity > TABELLEN_GRENZE; /** * Feldlogik aus dem bisherigen Konfigurator übernommen: * Empfängeradressen braucht es nur, wenn der Umschlag mit Empfängerdaten * beschriftet wird ODER einzeln an die Empfänger versendet wird. * Bei „Individueller Text" tritt an ihre Stelle ein Umschlagtext. */ const brauchtAdressen = shippingType === 'einzeln' || (needsEnvelope && labeling === 'empfaenger'); const umschlagFreitext = needsEnvelope && labeling === 'freitext'; // Empfänger-Schritt (früh definiert, da mehrere Effekte darauf zugreifen): // „Individueller Text" bei Freitext-Kuvert, „Empfänger" bei Adressbedarf, sonst keiner. const empfaengerSchritt = umschlagFreitext ? 'Individueller Text' : brauchtAdressen ? 'Empfänger' : null; // Ab 26 Empfängern ist die Tabelle keine Option mehr. useEffect(() => { if (nurUpload && eingabeArt === 'tabelle') setEingabeArt('excel'); }, [nurUpload, eingabeArt]); /** Zeichengrenze richtet sich nach dem Format – wie bisher. */ const maxText = format?.startsWith('a4') ? MAX_TEXT.a4 : MAX_TEXT.a6; // ── Text je Schriftstück (#5) ────────────────────────────────────────────── // Pfeile im Textfeld: pro Dokument ein eigener Text möglich (Standard = #1). // Nur im Tabellenmodus (≤ 25) und bei echtem Text (kein Textservice/Datei). const proDokText = !textservice && !dateiModus && !nurUpload && entries.length > 1; const textDocs = Math.min(entries.length, TABELLEN_GRENZE); // Angezeigter Text: #1 = Standardtext (textTemplate); ab #2 der Override bzw. // – solange nicht geändert – der Standardtext. const aktDocText = textDocIndex === 0 ? textTemplate : (entries[textDocIndex]?.text ?? textTemplate); const setDocText = (v: string) => { const clean = saeubere(v); if (textDocIndex === 0) { setTextTemplate(clean); return; } setEntries((prev) => prev.map((e, i) => (i === textDocIndex ? { ...e, text: clean } : e))); setPreviewSession(null); }; const textZuLang = aktDocText.length > maxText; // Ändert sich Text, Format oder Schrift, ist ein früher erkannter Überlauf // hinfällig – erst die nächste Vorschau entscheidet erneut. useEffect(() => { setTextUeberlauf(false); }, [textTemplate, format, font]); // Dokument-Index nicht über die Anzahl hinaus stehen lassen. useEffect(() => { if (textDocIndex >= textDocs) setTextDocIndex(0); }, [textDocIndex, textDocs]); // Ändert sich irgendetwas, das die gerenderten Dokumente beeinflusst, wird die // Vorschau-Session ungültig – sonst würde bei der Bestellung veralteter Inhalt // 1:1 übernommen. Nur eine unveränderte Vorschau wird wiederverwendet. useEffect(() => { setPreviewSession(null); }, [textTemplate, font, format, realistisch, labeling, needsEnvelope, umschlagText, entries]); // Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts: // ?extern=1&versand=sammel|einzeln&kuvert=0|1&beschriftung=empfaenger|freitext|keine&menge=N // Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die // tatsächlich gelesene Empfängerzahl an. useEffect(() => { if (!externModus) return; const v = searchParams.get('versand'); if (v === 'einzeln' || v === 'sammel') setShippingType(v); const k = searchParams.get('kuvert'); if (k === '1' || k === '0') setNeedsEnvelope(k === '1'); const b = searchParams.get('beschriftung'); if (b === 'empfaenger' || b === 'freitext' || b === 'keine') setLabeling(b); const fmt = searchParams.get('format'); if (fmt) setFormat(fmt); const m = parseInt(searchParams.get('menge') || '', 10); if (Number.isFinite(m) && m > 0) { setQuantity(m); setQuantityChosen(true); setMengeText(String(m)); } // Nur einmal beim Betreten des Extern-Modus anwenden. // eslint-disable-next-line react-hooks/exhaustive-deps }, [externModus]); // Abweichende Lieferadresse gibt es nur beim Sammelversand – sonst zurücksetzen. useEffect(() => { if (shippingType !== 'sammel') setLieferAbweichend(false); }, [shippingType]); /** * Der Empfängerschritt entfällt vollständig, wenn weder Adressen noch ein * Umschlagtext gebraucht werden. Bei „Individueller Text" heißt er anders, * weil dort keine Adressen, sondern der Umschlagtext erfasst wird. * (empfaengerSchritt ist oben – bei brauchtAdressen/umschlagFreitext – definiert.) */ // Ist das aktuell gewählte Produkt ein „auf Anfrage"-Kontaktprodukt? const kontaktSelektiert = kontaktProdukte.find((p) => p.key === product) ?? null; // Produkt kommt aus dem Direktlink; Extern-Modus ist oben definiert. const angemeldet = !!loadSession(); // Kundenangebote laden, sobald Produkt gewählt + eingeloggt (sonst keine). useEffect(() => { setOfferId(null); if (!product || !angemeldet) { setOffers([]); return; } let ab = false; fetchMyOffers(product).then((o) => { if (!ab) setOffers(o); }).catch(() => { if (!ab) setOffers([]); }); return () => { ab = true; }; }, [product, angemeldet]); // „Sie sind" entfällt, wenn die Einstufung schon feststeht (eingeloggt mit // person_type) oder im Extern-Modus. const zeigeSieSind = !externModus && !(angemeldet && !!personType); // Anfrageprodukte (Unterschriftenservice, Follow-ups) haben keinen Konfigurator – // nach „Weiter" folgt direkt das Anfrageformular als eigene Seite. Freigeschaltete // Kunden landen NICHT hier (dann ist das Produkt bestellbar, s. bestellbar/kontaktProdukte). const schritte = externModus // Extern: der Kunde füllt NUR Empfänger + Text; Menge/Versand/Kuvert/Format // legt der Betreiber über die Direktlink-Parameter fest (s. Extern-Preset). // Keine Preise, keine Rechnungsdaten, keine Zahlung. ? [ // Format wählbar, wenn es eine echte Wahl gibt (z. B. Postkarte hoch/quer) // und nicht per ?format=… vorgegeben wurde. ...(brauchtFormatSchritt && !searchParams.get('format') ? ['Format'] : []), ...(empfaengerSchritt ? [empfaengerSchritt] : []), 'Text', 'Rechnungsdaten', 'Übersicht', ] : kontaktSelektiert // Direktlink auf ein Kontaktprodukt → direkt zum Anfrageformular (ohne „Sie sind"). ? (vorwahlProdukt ? ['Anfrage'] : ['Sie sind', 'Produkt', 'Anfrage']) : [ ...(zeigeSieSind ? ['Sie sind'] : []), ...(vorwahlProdukt ? [] : ['Produkt']), ...(brauchtFormatSchritt ? ['Format'] : []), 'Menge & Versand', ...(istPostkarte ? ['Motiv'] : []), ...(empfaengerSchritt ? [empfaengerSchritt] : []), 'Text', 'Rechnungsdaten', 'Übersicht', ]; const maxStep = schritte.length - 1; const name = (i: number) => schritte[i]; useEffect(() => { publish({ steps: schritte, current: step }); }, [publish, step, schritte.join('|')]); // Rücksprung aus der Seitenleiste: nur zu bereits erledigten Schritten. useEffect(() => { registerGoto((i) => setStep((s) => (i >= 0 && i < s ? i : s))); }, [registerGoto]); // Nach dem Laden kann ein gespeicherter Schritt über die (nun kürzere) // Schrittfolge hinausragen – dann auf den letzten gültigen begrenzen. useEffect(() => { if (step > maxStep) setStep(maxStep); }, [step, maxStep]); // Entwurf lokal sichern, bis der Auftrag abgesendet ist. Danach nicht mehr // schreiben (sonst würde der eben geleerte Cache neu angelegt). useEffect(() => { if (bestellung) return; const daten: Entwurf = { step, personType, product, format, quantity, quantityChosen, shippingType, needsEnvelope, labeling, motifMode, motifId, entries, adressModus, eingabeArt, umschlagText, realistisch, font, textservice, textBriefing, textTemplate, rechnung, lieferAbweichend, lieferung, voucher, zahlung, }; try { localStorage.setItem(ENTWURF_KEY, JSON.stringify(daten)); } catch { /* Speicher voll o. Ä. */ } }, [bestellung, step, personType, product, format, quantity, quantityChosen, shippingType, needsEnvelope, labeling, motifMode, motifId, entries, adressModus, eingabeArt, umschlagText, realistisch, font, textservice, textBriefing, textTemplate, rechnung, lieferAbweichend, lieferung, voucher, zahlung]); const preisMenge = quantityChosen ? quantity : REFERENZ_MENGE; useEffect(() => { // Kontaktprodukte, Extern-Modus und Datei-Produkte zeigen keinen Preis. if (!product || kontaktSelektiert || keinePreise) { setQuote(null); return; } let abgebrochen = false; setQuoteFehler(null); fetchQuote({ product, person_type: personType ?? 'privat', format: format ?? undefined, quantity: preisMenge, shipping_type: shippingType, needs_envelope: needsEnvelope, envelope_labeling: labeling, motif_mode: istPostkarte ? motifMode : undefined, addons: [ ...(textservice ? ['schreibservice'] : []), ...(designservice ? ['gestaltungsservice'] : []), ], voucher_code: voucher.trim() || undefined, // Bekannte E-Mail mitgeben, damit Kundenrabatte in den Live-Preis einfließen. email: istEmail(rechnung.email) ? rechnung.email.trim() : undefined, offer: offerId ?? undefined, }) .then((q) => !abgebrochen && setQuote(q)) .catch((err) => { if (abgebrochen) return; setQuote(null); setQuoteFehler(err?.message ?? 'Preis konnte nicht berechnet werden.'); }); return () => { abgebrochen = true; }; }, [product, kontaktSelektiert, format, preisMenge, shippingType, needsEnvelope, labeling, motifMode, istPostkarte, textservice, designservice, voucher, rechnung.email, personType, offerId]); /** * Einzelversand: Kuvert mit Empfängeradresse ist zwingend (wir adressieren * und verschicken selbst) – dafür gibt es keine Auswahl. Die Kuvert-/ * Beschriftungsfragen erscheinen daher nur beim Sammelversand. */ function waehleVersand(v: ShippingType) { setShippingType(v); if (v === 'einzeln') { setNeedsEnvelope(true); setLabeling('empfaenger'); } } function waehleKuvert(v: boolean) { setNeedsEnvelope(v); if (!v) { setShippingType('sammel'); setLabeling('keine'); } else if (labeling === 'keine') setLabeling('empfaenger'); } function setEntry(i: number, k: keyof Entry, v: string) { v = saeubere(v); setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, [k]: v } : e))); } /** Freitext-Adresse als feste Zeilen – die Tabelle zeigt eine Spalte je Zeile. */ const freitextZeile = (e: Entry, z: number) => (e.free_text.split('\n')[z] ?? ''); function setFreitextZeile(i: number, z: number, v: string) { setEntries((prev) => prev.map((e, idx) => { if (idx !== i) return e; const zeilen = e.free_text.split('\n'); while (zeilen.length < FREITEXT_ZEILEN) zeilen.push(''); zeilen[z] = saeubere(v.replace(/[\r\n]/g, '')); return { ...e, free_text: zeilen.slice(0, FREITEXT_ZEILEN).join('\n').replace(/\n+$/, '') }; }), ); } function setPlatzhalter(i: number, name: string, v: string) { v = saeubere(v); setEntries((prev) => prev.map((e, idx) => (idx === i ? { ...e, placeholders: { ...e.placeholders, [name]: v } } : e)), ); } /** * Verfügbare Standard-Platzhalter – hängen vom Adressmodus ab * (strukturiert: Vorname/Nachname/…, Freitext: Zeile1…5). Ohne Adressbedarf * gibt es keine, dann sind alle Platzhalter frei definierbar. */ const standardPlatzhalter = !brauchtAdressen ? [] : adressModus === 'freitext' ? STANDARD_FREITEXT : STANDARD_STRUKTUR; const standardSet = useMemo( () => new Set(standardPlatzhalter.map((p) => p.toLowerCase())), [standardPlatzhalter.join('|')], ); /** Alle Platzhalter, die tatsächlich im Text stehen. */ const genutztePlatzhalter = useMemo(() => textPlatzhalter(textTemplate), [textTemplate]); /** Davon die eigenen (nicht aus der Adresse ableitbaren) – nur diese sind editierbar. */ const eigeneImText = genutztePlatzhalter.filter((p) => !standardSet.has(p.toLowerCase())); /** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */ const genutztePlatzhalterUmschlag = useMemo(() => textPlatzhalter(umschlagText), [umschlagText]); // Müssen pro Dokument eigene Daten erfasst werden? Das ist der Fall bei einem // Empfänger-/Individualtext-Schritt oder bei eigenen Platzhaltern im Brieftext // (jede Zeile trägt andere Werte → oberhalb der Tabellengrenze via Excel). const zeilenDatenNoetig = !!empfaengerSchritt || eigeneImText.length > 0; // Empfängerzeilen an die Menge angleichen. Unterhalb der Tabellengrenze immer; // darüber nur, wenn KEINE Pflicht-Zeilendaten nötig sind (z. B. identische // Sammelkarten ohne Empfängerliste) – dann erzeugt die Menge N Blanko-Zeilen. // Werden pro Zeile Daten gebraucht, bleibt es beim Excel-Upload (nicht syncen). // Im Extern-Modus wie bisher: syncen außer bei Empfänger-/Individualtext-Schritt. useEffect(() => { if (nurUpload && (externModus ? empfaengerSchritt : zeilenDatenNoetig)) return; setEntries((prev) => { if (prev.length === quantity) return prev; if (prev.length < quantity) { return [...prev, ...Array.from({ length: quantity - prev.length }, leereEntry)]; } return prev.slice(0, quantity); }); }, [quantity, nurUpload, externModus, empfaengerSchritt, zeilenDatenNoetig]); /** * Entry für den Versand ans Backend: löst ALLE im Text/Umschlag genutzten * Platzhalter explizit auf (Standard-Platzhalter aus der Adresse via standardWert, * eigene aus e.placeholders). So wird je Empfänger sicher ersetzt – unabhängig * davon, wie das Backend die Adressfelder interpretiert. * * Zusätzlich wird der Freitext nur bei Adressmodus „freitext" mitgeschickt. * Bei strukturierter Adresse muss free_text leer sein, sonst bevorzugt das * Backend (addressText) den Freitext und beschriftet den Umschlag falsch – * auch wenn im Freitextfeld nur Reste von einem früheren Moduswechsel stehen. */ const mitPlatzhaltern = (e: Entry): Entry => { const used = [...new Set([...genutztePlatzhalter, ...genutztePlatzhalterUmschlag])]; return { ...e, free_text: adressModus === 'freitext' ? e.free_text : '', placeholders: { ...Object.fromEntries(used.map((p) => [p, standardWert(e, p)])), ...e.placeholders, }, }; }; /** * Werden Platzhalterwerte NICHT bereits in einem Empfänger-Schritt erfasst * (kein Adress-Schritt, kein Umschlag-Freitext)? Dann muss der Text-Schritt * selbst Tabelle UND Excel-Upload für die Platzhalterwerte anbieten – auch * beim Sammelversand ohne Kuvert. */ const platzhalterNurImText = !brauchtAdressen; // PayPal-Buttons erscheinen erst nach der Bestellung – dann steht der // endgültige Betrag fest und kann nicht mehr manipuliert werden. useEffect(() => { if (!bestellung || bestellung.zahlung !== 'paypal' || bestellung.bezahlt) return; const clientId = paypalClientId(settings); if (!clientId) { setBestellFehler([ 'Die Online-Zahlung lässt sich gerade nicht laden. Bitte laden Sie die Seite neu – ' + 'andernfalls senden wir Ihnen die Rechnung per E-Mail.', ]); return; } const ziel = paypalRef.current; if (!ziel) return; let abgebrochen = false; ladePayPal(clientId) .then(() => { if (abgebrochen || !paypalRef.current) return; return rendereButtons(paypalRef.current, { betrag: bestellung.betrag, beschreibung: `Skrift ${gewaehltesProdukt?.name ?? ''} – ${bestellung.nummer}`, onApproved: async (referenz) => { await confirmPayment(bestellung.nummer, referenz); setBestellung({ ...bestellung, bezahlt: true }); }, onError: (m) => setBestellFehler([m]), }); }) .catch((err) => setBestellFehler([err.message ?? 'PayPal konnte nicht geladen werden.'])); return () => { abgebrochen = true; }; }, [bestellung, settings, gewaehltesProdukt?.name]); const mwst = quote?.mwst_percent ?? settings?.mwst_percent ?? 19; /** * Aufpreis einer Option für die Anzeige an der Checkbox. * Privatkunde → inkl. MwSt (Bruttobetrag), Unternehmen → zzgl. (Nettobetrag). * Der MwSt-Hinweis wird bewusst nicht angezeigt. */ const aufpreis = (key: string): Aufpreis => { // Ohne Preise (Datei-Produkt/Extern) an keiner Option Aufpreise zeigen. if (keinePreise) return null; const it = preisKatalog[key]; if (!it || !it.price) return null; // Nur Sammelversand wird für alle identisch (netto) angezeigt. Einzelversand // (Porto) und alles andere: Privat=brutto, Unternehmen=netto. const ohneMwStAnzeige = key === 'sammelversand' || key === 'sammelversand_klein'; const betrag = ohneMwStAnzeige || personType === 'unternehmen' ? it.price : it.price * (1 + mwst / 100); const einheit = it.unit === 'pro_stueck' ? 'pro Stück' : 'einmalig'; return { betrag, einheit }; }; const mwstHinweis = personType === 'unternehmen' ? `zzgl. ${mwst} % MwSt.` : `inkl. ${mwst} % MwSt.`; const gesamt = quote == null ? null : personType === 'unternehmen' ? quote.net_total : quote.gross_total; // Preis pro Stück OHNE einmalige Kosten: nur die pro-Stück-Positionen (Netto), // für Privatkunden brutto ausgewiesen. const stueckpreis = quote == null ? null : (personType === 'unternehmen' ? quote.unit_net_total : quote.unit_net_total * (1 + mwst / 100)) / preisMenge; const aktuell = name(step); const weiterMoeglich = (() => { switch (aktuell) { case 'Sie sind': return !!personType; case 'Produkt': return !!product; case 'Format': return !!format; case 'Menge & Versand': return quantity >= 1; case 'Motiv': return true; case 'Individueller Text': return umschlagText.trim().length > 0; case 'Empfänger': { if (eingabeArt === 'excel') return !!excelDatei && entries.length > 0; return adressModus === 'freitext' ? entries.every((e) => e.free_text.trim().length > 0) : entries.every((e) => e.last_name.trim() && e.zip.trim() && e.city.trim()); } case 'Text': if (dateiModus) return quellDateien.length > 0; if (textservice) return textBriefing.trim().length > 0; return textTemplate.trim().length > 0 && !textZuLang && !textUeberlauf; case 'Rechnungsdaten': // Pflichtfelder wie im bestehenden Konfigurator (Telefon optional). // Bei abweichender Lieferadresse sind deren Kernfelder ebenfalls Pflicht. return ( !!rechnung.first_name.trim() && !!rechnung.last_name.trim() && istEmail(rechnung.email) && !!rechnung.street.trim() && !!rechnung.zip.trim() && !!rechnung.city.trim() && (personType !== 'unternehmen' || !!rechnung.company.trim()) && (!lieferAbweichend || ( !!lieferung.first_name.trim() && !!lieferung.last_name.trim() && !!lieferung.street.trim() && !!lieferung.zip.trim() && !!lieferung.city.trim() )) ); default: return false; } })(); // PayPal nur anbieten, wenn serverseitig erlaubt (unter dem Netto-Limit) UND // eine öffentliche Client-ID hinterlegt ist. Sonst gäbe es die Option gar // nicht – und der Kunde landet nicht mit einer nicht-ladbaren Zahlung fest. const paypalMoeglich = !!quote && quote.paypal_allowed && !!paypalClientId(settings); // Ist PayPal nicht möglich, aber gewählt → automatisch auf Rechnung. useEffect(() => { if (!paypalMoeglich && zahlung === 'paypal') setZahlung('rechnung'); }, [paypalMoeglich, zahlung]); // Sicherheitsnetz gegen Fehlbelastung: Die produzierte Menge (entries_count, // serverseitig = entries.length) MUSS der berechneten/bezahlten Menge // entsprechen. Weicht sie ab (z. B. Excel mit weniger Zeilen als gewählte // Menge, oder nachträglich geänderte Menge), wird die Bestellung blockiert – // sonst zahlt der Kunde für mehr Karten, als tatsächlich angelegt werden. // Extern-/Datei-/Preislose Produkte haben ein anderes Mengenmodell → ausgenommen. const mengeStimmt = externModus || dateiModus || keinePreise || entries.length === quantity; // Ohne Preis (Extern-Modus/Datei-Produkt) gibt es keinen quote – dort reicht die Zustimmung. const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise) && mengeStimmt; async function weiter() { // Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele // Zeilen für das Format hat (formatabhängig, wie im alten Konfigurator). // Nur wenn selbst getippter Text vorliegt und noch keine gültige Vorschau // existiert (die hätte den Überlauf bereits gemeldet). Verbraucht kein // Vorschau-Kontingent. if (aktuell === 'Text' && !dateiModus && !textservice && !previewSession && textTemplate.trim()) { setPruefeLaedt(true); setPreviewFehler(null); try { const r = await validateText({ ...(rechnung.email.trim() ? { email: rechnung.email.trim() } : { client_id: clientId() }), text: textTemplate, font, format: format ?? undefined, realistic: realistisch, envelope_labeling: needsEnvelope ? labeling : 'keine', envelope_text: umschlagText, entries: entries.slice(0, 3).map(mitPlatzhaltern), }); if (r.has_overflow) { setTextUeberlauf(true); setPreviewFehler(overflowMeldung(r.overflow)); setPruefeLaedt(false); return; // nicht weiter } // Text passt → evtl. alte Überlauf-Meldung zurücknehmen. setTextUeberlauf(false); } catch { // Prüfung fehlgeschlagen (z. B. Backend nicht erreichbar) → nicht blockieren; // die Zeichengrenze greift ohnehin clientseitig. } setPruefeLaedt(false); } setStep((v) => Math.min(v + 1, maxStep)); } /** Übernimmt eine hochgeladene Excel-Datei als Empfängerliste. */ async function excelEinlesen(datei: File | null) { setExcelDatei(datei); setExcelFehler(null); if (!datei) return; try { const gelesen = await leseEmpfaengerDatei(datei); setEntries(gelesen.map((g) => ({ ...leereEntry(), ...g }))); setQuantity(gelesen.length); setMengeText(String(gelesen.length)); setQuantityChosen(true); } catch (err) { setExcelFehler(err instanceof Error ? err.message : 'Datei konnte nicht gelesen werden.'); setEntries([]); } } async function generiereVorschau() { setPreviewFehler(null); setPreviewLaedt(true); try { const email = rechnung.email.trim(); const ergebnis = await generatePreview({ ...(email ? { email } : { client_id: clientId() }), text: textTemplate, font, format: format ?? undefined, realistic: realistisch, envelope_labeling: needsEnvelope ? labeling : 'keine', envelope_text: umschlagText, entries: entries.slice(0, 3).map(mitPlatzhaltern), }); setPreviewDocs(ergebnis.docs); setPreviewVerbleibend(ergebnis.remaining); setPreviewLimit(ergebnis.limit); setPreviewSession(ergebnis.session ?? null); // Zeilen-Prüfung: Backend meldet, wenn der Text nicht auf das Format passt. const ueberlauf = !!ergebnis.has_overflow; setTextUeberlauf(ueberlauf); if (ueberlauf) { setPreviewFehler(overflowMeldung(ergebnis.overflow)); } else if (!ergebnis.docs.length) { setPreviewFehler('Es wurde kein Dokument zurückgegeben.'); } } catch (err) { setPreviewFehler(err instanceof ApiError ? err.message : 'Vorschau fehlgeschlagen.'); } finally { setPreviewLaedt(false); } } async function bestellen() { setBestellFehler([]); setSendet(true); try { // Eigenes Motiv zuerst hochladen – die Datei-ID reist mit der Bestellung. let motivDateiId: string | null = null; if (istPostkarte && motifMode === 'upload' && motivDatei) { try { motivDateiId = await uploadFile(motivDatei); } catch { setBestellFehler(['Das Motiv konnte nicht hochgeladen werden.']); setSendet(false); return; } } // Datei-Produkte (z. B. Unterschriftenservice): ein oder mehrere // Dokumente (PDF/Word/Excel) hochladen. let quellDateiIds: string[] = []; if (dateiModus && quellDateien.length) { try { quellDateiIds = await Promise.all(quellDateien.map((f) => uploadFile(f))); } catch { setBestellFehler(['Ein Dokument konnte nicht hochgeladen werden.']); setSendet(false); return; } } // Extern-Modus (Etsy o. Ä.): keine Rechnungsdaten erfasst → Platzhalter, // Bestellung ohne Zahlung, als „extern" markiert. const bestellEmail = externModus && !rechnung.email.trim() ? 'extern@skrift.de' : rechnung.email.trim(); const bestellVorname = externModus && !rechnung.first_name.trim() ? 'Externe' : rechnung.first_name; const bestellNachname = externModus && !rechnung.last_name.trim() ? 'Bestellung' : rechnung.last_name; const ergebnis = await createOrder({ motif: istPostkarte && motifMode === 'katalog' ? motifId ?? undefined : undefined, motif_upload: motivDateiId ?? undefined, source_file: quellDateiIds[0] ?? undefined, source_files: quellDateiIds.length ? quellDateiIds : undefined, email: bestellEmail, first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company, phone: rechnung.phone, person_type: personType ?? 'privat', product: product!, format: format ?? undefined, font, shipping_type: shippingType, needs_envelope: needsEnvelope, envelope_labeling: labeling, motif_mode: istPostkarte ? motifMode : undefined, text_template: (textservice || dateiModus) ? '' : textTemplate, text_briefing: textservice ? textBriefing : undefined, addons: [ ...(textservice ? ['schreibservice'] : []), ...(designservice ? ['gestaltungsservice'] : []), ], voucher_code: voucher.trim() || undefined, offer: offerId ?? undefined, payment_method: keinePreise ? 'rechnung' : zahlung, source: externModus ? 'extern' : (loadSession() ? 'portal' : 'configurator'), // Gezeigte Vorschau 1:1 übernehmen (nur wenn unverändert seit der Vorschau). preview_session: previewSession ?? undefined, // Adress-Snapshot für Bestätigung/Rechnung. billing: { first_name: bestellVorname, last_name: bestellNachname, company: rechnung.company, email: bestellEmail, phone: rechnung.phone, street: rechnung.street, zip: rechnung.zip, city: rechnung.city, country: rechnung.country, }, shipping_address: lieferAbweichend ? { first_name: lieferung.first_name, last_name: lieferung.last_name, company: lieferung.company, street: lieferung.street, zip: lieferung.zip, city: lieferung.city, country: lieferung.country, } : null, entries: entries.map((e) => { const n = mitPlatzhaltern(e); // free_text-Normalisierung + Platzhalter return { salutation: e.salutation, first_name: e.first_name, last_name: e.last_name, street: e.street, house_no: e.house_no, zip: e.zip, city: e.city, country: e.country, free_text: n.free_text, placeholders: n.placeholders, // Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext). text: (e.text && e.text.trim()) ? e.text : undefined, }; }), }); // Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen. try { localStorage.removeItem(ENTWURF_KEY); } catch { /* egal */ } setBestellung({ nummer: ergebnis.order_number, zahlung: ergebnis.payment_method, bezahlt: ergebnis.payment_method !== 'paypal', // Serverseitig bestätigter Betrag – NUR dieser wird per PayPal belastet, // nie der Client-Quote (der könnte von der tatsächlich angelegten // Auftragsmenge abweichen und den Kunden falsch belasten). betrag: ergebnis.gross_total, extern: externModus, }); } catch (err) { if (err instanceof ApiError) setBestellFehler(err.details.length ? err.details : [err.message]); else setBestellFehler(['Bestellung konnte nicht abgesendet werden.']); } finally { setSendet(false); } } // Nach erfolgreicher Bestellung ersetzt die Bestätigung das Formular. if (bestellung) { const offenePayPalZahlung = bestellung.zahlung === 'paypal' && !bestellung.bezahlt; return (

{offenePayPalZahlung ? 'Fast geschafft' : 'Vielen Dank!'}

Ihr Auftrag {bestellung.nummer} ist bei uns eingegangen.

{offenePayPalZahlung ? ( <>

Bitte schließen Sie die Zahlung über PayPal ab – danach starten wir die Produktion.

{bestellFehler.length > 0 &&

{bestellFehler[0]}

} ) : (

{bestellung.extern ? 'Die Bestellung wird direkt in die Produktion gegeben – die Abrechnung läuft über die Plattform.' : bestellung.zahlung === 'rechnung' ? 'Die Rechnung erhalten Sie per E-Mail. Die Produktion startet nach Zahlungseingang.' : 'Ihre Zahlung ist eingegangen. Wir haben Ihnen eine Bestätigung per E-Mail geschickt.'}

)}

Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer E-Mail-Adresse an.

{/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist (bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */} {!offenePayPalZahlung && ( )}
); } return ( <> {/* Hinweis auf verbotene Zeichen (kurz eingeblendet). */} {zeichenHinweis && (
{zeichenHinweis}
)} {/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */} {step > 0 && !kontaktSelektiert && !keinePreise && (
Ihr Preis
{stueckpreis == null ? '—' : `${quantityChosen ? '' : 'ab '}${euro(stueckpreis)}`}
pro Stück{gesamt != null && ` · ${mwstHinweis}`}
)}
{ladeFehler &&

{ladeFehler}

} {/* ── Personentyp ─────────────────────────────────────── */} {aktuell === 'Sie sind' && (

Bestellen Sie privat oder geschäftlich?

)} {/* ── Produkt ─────────────────────────────────────────── */} {aktuell === 'Produkt' && (

Was möchten Sie schreiben lassen?

{[...bestellbar, ...kontaktProdukte].map((p) => (
)} {/* ── Anfrage (Anfrageprodukte statt Konfigurator) ────── */} {aktuell === 'Anfrage' && (

Anfrage senden

Dieses Produkt konfigurieren wir gemeinsam mit Ihnen – schildern Sie uns kurz Ihr Vorhaben.

)} {/* ── Format (nur bei mehreren Formaten) ──────────────── */} {aktuell === 'Format' && (

Welches Format?

{produktFormate.map((f) => (
)} {/* ── Menge & Versand ─────────────────────────────────── */} {aktuell === 'Menge & Versand' && ( <> {offers.length > 0 && (

Ihr Angebot

{(() => { const o = offerId !== null ? offers.find((x) => x.id === offerId) : null; if (!o) return null; const sur = o.surcharges ?? []; return (
{sur.length > 0 && (
Aufschläge pro Stück: {sur.map((s) => `${s.label} +${Number(s.amount).toFixed(2)} €`).join(' · ')}
)} {o.note &&
{o.note}
}
); })()}
)}

Wie viele?

{ // Feld darf leer sein, während getippt wird. setMengeText(e.target.value); const n = parseInt(e.target.value, 10); if (Number.isFinite(n) && n >= 1) { setQuantity(n); setQuantityChosen(true); } }} onBlur={() => { const n = parseInt(mengeText, 10); const gueltig = Number.isFinite(n) && n >= 1 ? n : 1; setQuantity(gueltig); setMengeText(String(gueltig)); }} />

Wie sollen die Schriftstücke versendet werden?

{shippingType === 'einzeln' && (

Kuvert mit Empfängeradresse ist beim Einzelversand inklusive.

)}
{/* Kuvertfragen nur beim Sammelversand – beim Einzelversand ist das Kuvert mit Empfängeradresse ohnehin zwingend. */} {shippingType === 'sammel' && (

Benötigen Sie ein Kuvert?

{needsEnvelope && ( <>

Womit soll das Kuvert beschriftet werden?

)}
)} )} {/* ── Motiv (nur Postkarte) ───────────────────────────── */} {aktuell === 'Motiv' && (

Motiv der Postkarte

{/* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer genau eine Option gewählt (auch der Gestaltungsservice). */}
{katalogOptionSichtbar && (
{motifMode === 'katalog' && ( <>

Unsere Motive

{sichtbareMotive.length === 0 ? (

Für dieses Format sind aktuell keine Motive hinterlegt – wählen Sie „Ohne Motiv" oder laden Sie ein eigenes hoch.

) : (
{sichtbareMotive.map((m) => ( ))}
)} {/* Gewähltes Motiv groß darunter zeigen. */} {gewaehltesMotiv?.image && (
{gewaehltesMotiv.name}
{gewaehltesMotiv.name}
)} )} {motifMode === 'upload' && (
setMotivDatei(e.target.files?.[0] ?? null)} /> {motivDatei && (

{motivDatei.name} – wird mit der Bestellung übertragen.

)}
)} {motifMode === 'eigene_karten' && (

Nach der Bestellung erhalten Sie die Versandanschrift. Die Produktion startet, sobald Ihr Material bei uns eingegangen ist.

)}
)} {/* ── Empfängerdaten ──────────────────────────────────── */} {/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */} {aktuell === 'Individueller Text' && ( <>

Individueller Text

Dieser Text wird auf jeden Umschlag geschrieben – Platzhalter sind möglich.