Files
skrift-app/src/routes/Configurator.tsx
s4luorth dd9ab3736b
All checks were successful
Build & Deploy / deploy (push) Successful in 22s
Konfigurator: Sammelbestellung ohne Empfängerliste > 25 wieder bestellbar
Die neue Mengen-Sperre (entries.length === quantity) hätte identische
Sammelkarten ohne Empfängerliste oberhalb der Tabellengrenze blockiert:
dort wuchs entries nie auf die Menge, weil der Sync-Effekt pauschal bei
> 25 pausierte. Der Sync pausiert jetzt nur noch, wenn pro Zeile wirklich
Daten nötig sind (Empfänger-/Individualtext-Schritt oder eigene
Platzhalter im Brieftext). Ohne solche Daten erzeugt die Menge N
Blanko-Zeilen – auch > 25. Extern-Modus unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-21 11:57:59 +02:00

2176 lines
106 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, 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<string, string> = {
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<string, string>;
/** 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<Entwurf> {
try {
const roh = localStorage.getItem(ENTWURF_KEY);
return roh ? (JSON.parse(roh) as Partial<Entwurf>) : {};
} 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 (
<label className={`sk-option${selected ? ' sk-option--selected' : ''}`}>
<input type="radio" name={name} checked={selected} onChange={onSelect}
className={hideInput ? 'sk-option-input' : undefined} />
{icon && <span className="sk-option-icon">{icon}</span>}
<span className="sk-option-body">
<span className="sk-option-title">{title}</span>
{desc && <span className="sk-option-desc" style={{ display: 'block' }}>{desc}</span>}
</span>
{preis && preis.betrag > 0 && (
<span className="sk-option-price">
<span className="sk-option-price-amount">+ {euro(preis.betrag)}</span>
<span className="sk-option-price-unit">{preis.einheit}</span>
</span>
)}
</label>
);
}
/**
* 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 (
<div className="sk-field">
<label className={`sk-label${required ? ' sk-label--required' : ''}`} htmlFor={id}>
{label} {!required && <span className="sk-note" style={{ fontWeight: 400 }}>(optional)</span>}
</label>
<input
id={id}
className="sk-input"
type={type}
value={value}
required={required}
{...(numerisch || tel ? { inputMode: 'tel' as const } : {})}
onChange={(e) => onChange(saeubern(e.target.value))}
/>
{hint && <p className="sk-note" style={{ marginTop: '6px' }}>{hint}</p>}
</div>
);
}
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<Product[]>([]);
const [formats, setFormats] = useState<Format[]>([]);
/** Individuell freigeschaltete Produkt-Schlüssel (nur bei Login). */
const [freigeschaltet, setFreigeschaltet] = useState<string[]>([]);
const [ladeFehler, setLadeFehler] = useState<string | null>(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<PersonType | null>(entwurf.personType ?? null);
const [product, setProduct] = useState<string | null>(entwurf.product ?? null);
const [format, setFormat] = useState<string | null>(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<ShippingType>(entwurf.shippingType ?? 'sammel');
const [needsEnvelope, setNeedsEnvelope] = useState(entwurf.needsEnvelope ?? true);
const [labeling, setLabeling] = useState<Labeling>(entwurf.labeling ?? 'empfaenger');
const [motifMode, setMotifMode] = useState<MotifMode>(entwurf.motifMode ?? 'katalog');
const [motifs, setMotifs] = useState<Motif[]>([]);
const [motifeGeladen, setMotifeGeladen] = useState(false);
const [motifId, setMotifId] = useState<number | null>(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<Entry[]>(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<File | null>(null);
const [excelFehler, setExcelFehler] = useState<string | null>(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<Adresse>(entwurf.rechnung ?? leereAdresse());
const [lieferAbweichend, setLieferAbweichend] = useState(entwurf.lieferAbweichend ?? false);
const [lieferung, setLieferung] = useState<Adresse>(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<Quote | null>(null);
const [quoteFehler, setQuoteFehler] = useState<string | null>(null);
// Kundenangebote (customer_offers) für den eingeloggten Kunden + das Produkt.
const [offers, setOffers] = useState<CustomerOffer[]>([]);
const [offerId, setOfferId] = useState<number | null>(null);
const [previewDocs, setPreviewDocs] = useState<PreviewDoc[]>([]);
const [previewLaedt, setPreviewLaedt] = useState(false);
const [previewFehler, setPreviewFehler] = useState<string | null>(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<string | null>(null);
/** Läuft die Zeilen-Prüfung beim „Weiter"? */
const [pruefeLaedt, setPruefeLaedt] = useState(false);
const [sendet, setSendet] = useState(false);
const [bestellFehler, setBestellFehler] = useState<string[]>([]);
const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; betrag: number; extern?: boolean } | null>(null);
const [settings, setSettings] = useState<PublicSettings | null>(null);
const [preisKatalog, setPreisKatalog] = useState<Record<string, PriceItem>>({});
// 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<string | null>(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<File | null>(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<File[]>([]);
const paypalRef = useRef<HTMLDivElement | null>(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 (
<div className="sk-view">
<h1>{offenePayPalZahlung ? 'Fast geschafft' : 'Vielen Dank!'}</h1>
<section className="sk-card" style={{ marginTop: 'var(--sk-space-5)' }}>
<p>
Ihr Auftrag <strong>{bestellung.nummer}</strong> ist bei uns eingegangen.
</p>
{offenePayPalZahlung ? (
<>
<p className="sk-note" style={{ margin: 'var(--sk-space-3) 0 var(--sk-space-4)' }}>
Bitte schließen Sie die Zahlung über PayPal ab – danach starten wir die Produktion.
</p>
<div ref={paypalRef} />
{bestellFehler.length > 0 && <p className="sk-error">{bestellFehler[0]}</p>}
</>
) : (
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
{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.'}
</p>
)}
<p style={{ marginTop: 'var(--sk-space-3)' }} className="sk-note">
Den aktuellen Stand sehen Sie jederzeit unter „Aufträge" – melden Sie sich dafür mit Ihrer
E-Mail-Adresse an.
</p>
{/* Zurück zur Hauptseite – erst wenn die Bestellung abgeschlossen ist
(bei offener PayPal-Zahlung nicht, damit niemand vorher abspringt). */}
{!offenePayPalZahlung && (
<div style={{ marginTop: 'var(--sk-space-4)' }}>
<a className="sk-btn sk-btn--primary" href="https://skrift.de">
Zurück zu skrift.de
</a>
</div>
)}
</section>
</div>
);
}
return (
<>
{/* Hinweis auf verbotene Zeichen (kurz eingeblendet). */}
{zeichenHinweis && (
<div role="alert" style={{
position: 'fixed', left: '50%', bottom: 'var(--sk-space-5)', transform: 'translateX(-50%)',
zIndex: 200, maxWidth: '92vw',
border: '1px solid var(--sk-danger, #c62828)', background: 'var(--sk-danger, #c62828)', color: '#fff',
borderRadius: 'var(--sk-radius, 10px)', padding: 'var(--sk-space-3) var(--sk-space-4)',
fontSize: 'var(--sk-size-sm)', boxShadow: '0 10px 30px rgba(0,0,0,0.25)',
}}>{zeichenHinweis}</div>
)}
{/* Preiszeile erst ab dem zweiten Schritt (Produkt) und nur für
bepreisbare Produkte – Anfrage-, Extern- und Datei-Produkte ohne Preis. */}
{step > 0 && !kontaktSelektiert && !keinePreise && (
<div className="sk-pricebar">
<div className="sk-pricebar-inner">
<div className="sk-price-block">
<div className="sk-price-label">Ihr Preis</div>
<div className="sk-price-unit">
{stueckpreis == null ? '—' : `${quantityChosen ? '' : 'ab '}${euro(stueckpreis)}`}
</div>
<div className="sk-price-note">
pro Stück{gesamt != null && ` · ${mwstHinweis}`}
</div>
</div>
</div>
</div>
)}
<div className="sk-konf">
<div className="sk-konf-form">
{ladeFehler && <p className="sk-error">{ladeFehler}</p>}
{/* ── Personentyp ─────────────────────────────────────── */}
{aktuell === 'Sie sind' && (
<section className="sk-card">
<h2>Bestellen Sie privat oder geschäftlich?</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
<Option name="pt" selected={personType === 'privat'} onSelect={() => setPersonType('privat')}
title="Privatperson" icon={<IconUser size={20} />} hideInput />
<Option name="pt" selected={personType === 'unternehmen'} onSelect={() => setPersonType('unternehmen')}
title="Unternehmen" icon={<IconBuilding size={20} />} hideInput />
</div>
</section>
)}
{/* ── Produkt ─────────────────────────────────────────── */}
{aktuell === 'Produkt' && (
<section className="sk-card">
<h2>Was möchten Sie schreiben lassen?</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
{[...bestellbar, ...kontaktProdukte].map((p) => (
<Option key={p.key} name="product" selected={product === p.key}
onSelect={() => { setProduct(p.key); setFormat(null); }}
title={p.name} desc={p.description || PRODUKT_TEXT[p.key] || undefined} />
))}
</div>
</section>
)}
{/* ── Anfrage (Anfrageprodukte statt Konfigurator) ────── */}
{aktuell === 'Anfrage' && (
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Anfrage senden</h2>
<p>Dieses Produkt konfigurieren wir gemeinsam mit Ihnen – schildern Sie uns kurz Ihr Vorhaben.</p>
</div>
<div className="sk-card-body">
<OfferForm produkt={kontaktSelektiert?.name} />
</div>
</section>
)}
{/* ── Format (nur bei mehreren Formaten) ──────────────── */}
{aktuell === 'Format' && (
<section className="sk-card">
<h2>Welches Format?</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
{produktFormate.map((f) => (
<Option key={f.key} name="format" selected={format === f.key}
onSelect={() => setFormat(f.key)} title={f.name} />
))}
</div>
</section>
)}
{/* ── Menge & Versand ─────────────────────────────────── */}
{aktuell === 'Menge & Versand' && (
<>
{offers.length > 0 && (
<section className="sk-card">
<h2>Ihr Angebot</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
<Option name="offer" selected={offerId === null} onSelect={() => setOfferId(null)} title="Standard" />
{offers.map((o) => (
<Option key={o.id} name="offer" selected={offerId === o.id} onSelect={() => setOfferId(o.id)}
title={o.label + (o.name_suffix ? ` (${o.name_suffix})` : '')} />
))}
</div>
{(() => {
const o = offerId !== null ? offers.find((x) => x.id === offerId) : null;
if (!o) return null;
const sur = o.surcharges ?? [];
return (
<div style={{ marginTop: 'var(--sk-space-3)', fontSize: 13, color: 'var(--sk-muted, #666)', lineHeight: 1.5 }}>
{sur.length > 0 && (
<div>Aufschläge pro Stück: {sur.map((s) => `${s.label} +${Number(s.amount).toFixed(2)} €`).join(' · ')}</div>
)}
{o.note && <div style={{ marginTop: 4 }}>{o.note}</div>}
</div>
);
})()}
</section>
)}
<section className="sk-card">
<h2>Wie viele?</h2>
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)', marginBottom: 0 }}>
<label className="sk-label sk-label--required" htmlFor="menge">Anzahl</label>
<input id="menge" className="sk-input" type="number" min={1}
value={mengeText}
onChange={(e) => {
// 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));
}} />
</div>
</section>
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
<h2>Wie sollen die Schriftstücke versendet werden?</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
<Option name="ship" selected={shippingType === 'einzeln'} onSelect={() => waehleVersand('einzeln')}
title="Einzeln an die Empfänger"
desc="Wir versenden direkt – inklusive Kuvertierung und Beschriftung"
preis={aufpreis('porto_inland')} />
<Option name="ship" selected={shippingType === 'sammel'} onSelect={() => waehleVersand('sammel')}
title="Sammelversand an Sie" desc="Sie erhalten alles zur eigenen Verteilung"
preis={aufpreis(quantity <= 5 ? 'sammelversand_klein' : 'sammelversand')} />
</div>
{shippingType === 'einzeln' && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
Kuvert mit Empfängeradresse ist beim Einzelversand inklusive.
</p>
)}
</section>
{/* Kuvertfragen nur beim Sammelversand – beim Einzelversand
ist das Kuvert mit Empfängeradresse ohnehin zwingend. */}
{shippingType === 'sammel' && (
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
<h2>Benötigen Sie ein Kuvert?</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
<Option name="env" selected={needsEnvelope} onSelect={() => waehleKuvert(true)}
title="Ja, mit Kuvert" preis={aufpreis('kuvert')} />
<Option name="env" selected={!needsEnvelope} onSelect={() => waehleKuvert(false)}
title="Nein, ohne Kuvert" desc="Ihre Schriftstücke kommen ohne Umschlag zu Ihnen" />
</div>
{needsEnvelope && (
<>
<h3 style={{ marginTop: 'var(--sk-space-5)' }}>Womit soll das Kuvert beschriftet werden?</h3>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-3)' }}>
<Option name="lab" selected={labeling === 'empfaenger'} onSelect={() => setLabeling('empfaenger')}
title="Empfängerdaten" desc="Klassische Adressierung mit Name und Anschrift"
preis={aufpreis('beschriftung')} />
<Option name="lab" selected={labeling === 'freitext'} onSelect={() => setLabeling('freitext')}
title="Individueller Text" desc="Freier Text mit Platzhaltern"
preis={aufpreis('beschriftung')} />
<Option name="lab" selected={labeling === 'keine'} onSelect={() => setLabeling('keine')}
title="Keine Beschriftung" desc="Kuvert bleibt unbeschriftet" />
</div>
</>
)}
</section>
)}
</>
)}
{/* ── Motiv (nur Postkarte) ───────────────────────────── */}
{aktuell === 'Motiv' && (
<section className="sk-card">
<h2>Motiv der Postkarte</h2>
{/* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer
genau eine Option gewählt (auch der Gestaltungsservice). */}
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
{katalogOptionSichtbar && (
<Option name="motif" selected={motifMode === 'katalog'} onSelect={() => setMotifMode('katalog')}
title="Motiv aus unserer Auswahl" desc="Wir zeigen Ihnen passende Motive" />
)}
<Option name="motif" selected={motifMode === 'ohne'} onSelect={() => setMotifMode('ohne')}
title="Ohne Motiv" desc="Blanko-Karte, nur beschrieben" />
<Option name="motif" selected={motifMode === 'upload'} onSelect={() => setMotifMode('upload')}
title="Eigenes Motiv hochladen" desc="Ihre Bilddatei als Vorderseite"
preis={aufpreis('motiv_upload')} />
<Option name="motif" selected={motifMode === 'gestaltungsservice'} onSelect={() => setMotifMode('gestaltungsservice')}
title="Gestaltungsservice" desc="Wir gestalten die Karte für Sie."
preis={aufpreis('gestaltungsservice')} />
<Option name="motif" selected={motifMode === 'eigene_karten'} onSelect={() => setMotifMode('eigene_karten')}
title="Eigene Karten einsenden" desc="Sie schicken uns bereits gedruckte Karten – bis max. 240 g/m²" />
</div>
{motifMode === 'katalog' && (
<>
<h3 style={{ marginTop: 'var(--sk-space-5)' }}>Unsere Motive</h3>
{sichtbareMotive.length === 0 ? (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
Für dieses Format sind aktuell keine Motive hinterlegt – wählen Sie „Ohne Motiv"
oder laden Sie ein eigenes hoch.
</p>
) : (
<div className="sk-motifs">
{sichtbareMotive.map((m) => (
<button
key={m.id}
type="button"
className={`sk-motif${motifId === m.id ? ' sk-motif--selected' : ''}`}
onClick={() => setMotifId(m.id)}
>
{m.image ? (
<img src={assetUrl(m.image)} alt={m.name} />
) : (
<span className="sk-note">{m.name}</span>
)}
<span className="sk-motif-name">{m.name}</span>
</button>
))}
</div>
)}
{/* Gewähltes Motiv groß darunter zeigen. */}
{gewaehltesMotiv?.image && (
<figure className="sk-motif-large">
<img src={assetUrl(gewaehltesMotiv.image)} alt={gewaehltesMotiv.name} />
<figcaption className="sk-motif-large-name">{gewaehltesMotiv.name}</figcaption>
</figure>
)}
</>
)}
{motifMode === 'upload' && (
<div className="sk-field" style={{ marginTop: 'var(--sk-space-4)' }}>
<label className="sk-label" htmlFor="motivdatei">Motivdatei</label>
<input id="motivdatei" className="sk-input" type="file" accept="image/*,application/pdf"
onChange={(e) => setMotivDatei(e.target.files?.[0] ?? null)} />
{motivDatei && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
{motivDatei.name} – wird mit der Bestellung übertragen.
</p>
)}
</div>
)}
{motifMode === 'eigene_karten' && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-4)' }}>
Nach der Bestellung erhalten Sie die Versandanschrift. Die Produktion startet,
sobald Ihr Material bei uns eingegangen ist.
</p>
)}
</section>
)}
{/* ── Empfängerdaten ──────────────────────────────────── */}
{/* ── Individueller Umschlagtext (ersetzt die Adressen) ── */}
{aktuell === 'Individueller Text' && (
<>
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Individueller Text</h2>
<p>Dieser Text wird auf jeden Umschlag geschrieben – Platzhalter sind möglich.</p>
</div>
<div className="sk-card-body">
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="umschlagtext">Text für den Umschlag</label>
<textarea id="umschlagtext" className="sk-input" rows={4} maxLength={MAX_UMSCHLAG_TEXT}
value={umschlagText}
placeholder="z. B. Herzliche Grüße an [[Vorname]]"
onChange={(e) => setUmschlagText(saeubere(e.target.value))} />
<p className="sk-note" style={{ marginTop: 'var(--sk-space-2)' }}>
{umschlagText.length}/{MAX_UMSCHLAG_TEXT} Zeichen
</p>
</div>
</div>
</section>
{/* Platzhalter-Tabelle: erscheint, sobald der Umschlagtext Platzhalter
nutzt – je Empfänger ein Wert (analog zur Text-Tabelle). */}
{genutztePlatzhalterUmschlag.length > 0 && (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Platzhalter-Werte</h2>
<p>Füllen Sie die im Umschlagtext genutzten Platzhalter je Empfänger aus.</p>
</div>
<div className="sk-card-body">
<div className="sk-table-wrap">
<table className="sk-table">
<thead>
<tr>
<th>#</th>
{genutztePlatzhalterUmschlag.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i}>
<td className="sk-table-no">{i + 1}</td>
{genutztePlatzhalterUmschlag.map((p) => {
const standard = standardSet.has(p.toLowerCase());
return (
<td key={p}>
<input
className="sk-cell"
readOnly={standard}
title={standard ? 'Kommt aus den Adressdaten' : undefined}
style={standard ? { background: 'rgba(15,23,42,0.04)', color: 'var(--sk-muted)' } : undefined}
value={standard ? standardWert(e, p) : (e.placeholders[p] ?? '')}
onChange={standard ? undefined : (ev) => setPlatzhalter(i, p, ev.target.value)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
)}
</>
)}
{aktuell === 'Empfänger' && (
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Adressdaten</h2>
<p>
{eingabeArt === 'excel'
? 'Laden Sie eine Vorlage herunter, füllen Sie sie aus und laden Sie die Datei zurück hoch.'
: 'Jede Zeile wird ein Schriftstück. Die Briefnummer vergeben wir automatisch.'}
</p>
</div>
<div className="sk-card-body">
{/* Aufbau der Adresse */}
<div className="sk-options">
<Option name="admod" selected={adressModus === 'struktur'}
onSelect={() => setAdressModus('struktur')}
title="Standard-Adressliste"
desc="Felder: Vorname, Nachname, Straße, Hausnummer, PLZ, Ort, Land" />
<Option name="admod" selected={adressModus === 'freitext'}
onSelect={() => setAdressModus('freitext')}
title="Freitext-Adressliste"
desc={`${FREITEXT_ZEILEN} frei beschreibbare Zeilen pro Empfänger`} />
</div>
<hr className="sk-group-split" />
{/* Art der Erfassung. Die Tabellen-Option gibt es nur bis zur
Tabellengrenze – darüber ausschließlich Excel-Upload. */}
<div className="sk-options">
<Option name="eingabe" selected={eingabeArt === 'excel'}
onSelect={() => setEingabeArt('excel')}
title="Excel-Datei hochladen" />
{!nurUpload && (
<Option name="eingabe" selected={eingabeArt === 'tabelle'}
onSelect={() => setEingabeArt('tabelle')}
title="Direkt in Tabelle eingeben"
desc={`Bis ${TABELLEN_GRENZE} Empfänger – ohne Datei-Upload`} />
)}
</div>
{eingabeArt === 'excel' ? (
<div style={{ marginTop: 'var(--sk-space-4)' }}>
<button type="button" className="sk-btn sk-btn--ghost"
onClick={() => ladeVorlageHerunter(eigeneImText, adressModus === 'freitext')}>
{adressModus === 'freitext' ? 'Freitext-Vorlage' : 'Standard-Vorlage'} herunterladen
</button>
<label className="sk-drop" style={{ marginTop: 'var(--sk-space-4)' }}>
<span className="sk-drop-title">Ausgefüllte Datei hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.xlsx oder .csv</span>
<input type="file" accept=".xlsx,.xls,.csv"
onChange={(e) => excelEinlesen(e.target.files?.[0] ?? null)} />
</label>
{excelDatei && !excelFehler && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{excelDatei.name} – {entries.length} Empfänger gelesen.
</p>
)}
{excelFehler && <p className="sk-error">{excelFehler}</p>}
</div>
) : (
<div style={{ marginTop: 'var(--sk-space-4)' }}>
<div className="sk-table-wrap">
<table className="sk-table">
<thead>
<tr>
<th>#</th>
{adressModus === 'struktur' ? (
<>
<th>Vorname</th><th>Nachname</th><th>Straße</th>
<th>Nr.</th><th>PLZ</th><th>Stadt</th><th>Land</th>
</>
) : (
Array.from({ length: FREITEXT_ZEILEN }, (_, z) => <th key={z}>Zeile {z + 1}</th>)
)}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i}>
<td className="sk-table-no">{i + 1}</td>
{adressModus === 'struktur'
? (['first_name', 'last_name', 'street', 'house_no', 'zip', 'city', 'country'] as const)
.map((k) => (
<td key={k}>
<input className="sk-cell" value={e[k]}
onChange={(ev) => setEntry(i, k, ev.target.value)} />
</td>
))
: Array.from({ length: FREITEXT_ZEILEN }, (_, z) => (
<td key={z}>
<input className="sk-cell" value={freitextZeile(e, z)}
onChange={(ev) => setFreitextZeile(i, z, ev.target.value)} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{entries.length} Empfänger – wird automatisch übernommen.
</p>
</div>
)}
</div>
</section>
)}
{/* ── Text ────────────────────────────────────────────── */}
{aktuell === 'Text' && (
<>
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Schriftart für Schriftstück und Umschlag</h2>
<p>Die gewählte Schrift gilt für beides gleichermaßen.</p>
</div>
<div className="sk-card-body">
<div className="sk-field">
<select className="sk-input" value={font} aria-label="Schriftart"
onChange={(e) => setFont(e.target.value as typeof font)}>
<option value="tilda">Tilda – Druckschrift, leicht lesbar</option>
<option value="alva">Alva – verbundene Handschrift</option>
<option value="ellie">Ellie – lockere Handschrift</option>
</select>
</div>
<label className="sk-check" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={realistisch}
onChange={(e) => setRealistisch(e.target.checked)} />
<span>
<strong>Realistische Handschrift aktivieren</strong>
<span className="sk-option-desc" style={{ display: 'block' }}>
Buchstaben neigen sich leicht, die Grundlinie schwankt und Zeilen bekommen eine
natürliche Neigung – wie beim echten Schreiben.
</span>
</span>
</label>
</div>
</section>
{dateiModus ? (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Dokumente hochladen</h2>
<p>Laden Sie ein oder mehrere Dokumente hoch, die wir unterschreiben und versenden sollen (PDF, Word oder Excel).</p>
</div>
<div className="sk-card-body">
<label className="sk-drop">
<span className="sk-drop-title">Dateien hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.pdf, .doc, .docx, .xls, .xlsx – mehrere möglich</span>
<input type="file" multiple accept=".pdf,.doc,.docx,.xls,.xlsx"
onChange={(e) => setQuellDateien((prev) => [...prev, ...Array.from(e.target.files ?? [])])} />
</label>
{quellDateien.length > 0 && (
<ul className="sk-note" style={{ marginTop: 'var(--sk-space-3)', paddingLeft: '1.1em' }}>
{quellDateien.map((f, i) => (
<li key={i}>
{f.name}
<button type="button" className="sk-btn sk-btn--ghost" style={{ marginLeft: 'var(--sk-space-2)', padding: '2px 8px' }}
onClick={() => setQuellDateien((prev) => prev.filter((_, idx) => idx !== i))}>Entfernen</button>
</li>
))}
</ul>
)}
</div>
</section>
) : (
<>
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Möchten Sie den Inhalt selbst erstellen?</h2>
</div>
<div className="sk-card-body">
<div className="sk-options">
<Option name="ts" selected={!textservice} onSelect={() => setTextservice(false)}
title="Ich erstelle den Text selbst" desc="Sie geben den vollständigen Text vor" />
<Option name="ts" selected={textservice} onSelect={() => setTextservice(true)}
title="Textservice beauftragen"
desc="Wir erstellen den Text professionell für Sie"
preis={aufpreis('schreibservice')} />
</div>
</div>
</section>
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
{textservice ? (
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label" htmlFor="briefing">Worum soll es gehen?</label>
<textarea id="briefing" className="sk-input" rows={6} value={textBriefing}
onChange={(e) => setTextBriefing(e.target.value)} />
</div>
) : (
<div className="sk-field" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--sk-space-2)', marginBottom: 'var(--sk-space-2)' }}>
<label className="sk-label" htmlFor="text" style={{ marginBottom: 0 }}>
Inhalt des Schriftstücks <span className="sk-note">· max. {maxText} Zeichen</span>
</label>
{/* Pfeile: pro Schriftstück eigenen Text eingeben (Standard = #1). */}
{proDokText && (
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<button type="button" className="sk-pager-btn" aria-label="Vorheriges Schriftstück"
disabled={textDocIndex === 0}
onClick={() => setTextDocIndex((i) => Math.max(0, i - 1))}>‹</button>
<span className="sk-note" style={{ whiteSpace: 'nowrap' }}>
Schriftstück {textDocIndex + 1}/{textDocs}
</span>
<button type="button" className="sk-pager-btn" aria-label="Nächstes Schriftstück"
disabled={textDocIndex >= textDocs - 1}
onClick={() => setTextDocIndex((i) => Math.min(textDocs - 1, i + 1))}>›</button>
</div>
)}
</div>
<textarea id="text" className="sk-input" rows={10} value={aktDocText}
maxLength={maxText}
onChange={(e) => setDocText(e.target.value)}
onKeyDown={(e) => {
// Tab-Taste fügt einen echten Tabulator ein (statt den Fokus
// zu verschieben) – für z.B. das Datum am rechten Rand.
if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
e.preventDefault();
if (aktDocText.length >= maxText) return;
const ta = e.currentTarget;
const s = ta.selectionStart ?? aktDocText.length;
const en = ta.selectionEnd ?? s;
setDocText(aktDocText.slice(0, s) + '\t' + aktDocText.slice(en));
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
}}
onBlur={() => {
// Leerzeilen am Ende automatisch entfernen (zählen sonst als
// Zeilen mit und können das Zeilenlimit fälschlich sprengen).
const beschnitten = aktDocText.replace(/(?:\r?\n[ \t]*)+$/, '');
if (beschnitten !== aktDocText) setDocText(beschnitten);
}} />
<p className={textZuLang ? 'sk-error' : 'sk-note'} style={{ marginTop: 'var(--sk-space-2)' }}>
{aktDocText.length}/{maxText} Zeichen
{textZuLang && ' – Text ist zu lang für das gewählte Format.'}
</p>
{proDokText && textDocIndex > 0 && (
<p className="sk-error" style={{ marginTop: 'var(--sk-space-2)' }}>
Dieser Text wird nur für Schriftstück {textDocIndex + 1} verwendet (Standard = Text aus #1).
</p>
)}
<p className="sk-note" style={{ marginTop: '8px' }}>
{standardPlatzhalter.length > 0 ? (
<>
Verfügbare Platzhalter:{' '}
{standardPlatzhalter.map((p, i) => (
<span key={p}>{i > 0 && ', '}<code>[[{p}]]</code></span>
))}
. Wir ersetzen sie je Empfänger. Eigene Platzhalter wie{' '}
<code>[[Gutscheincode]]</code> sind möglich.
</>
) : (
<>
Nutzen Sie eigene Platzhalter wie <code>[[Gutscheincode]]</code> – die Werte
tragen Sie unten je Empfänger ein.
</>
)}
</p>
</div>
)}
</section>
{/* Platzhalter-Tabelle: zeigt genau die im Text verwendeten Platzhalter.
Standard-Platzhalter (aus der Adresse) sind gesperrt, eigene editierbar. */}
{!textservice && genutztePlatzhalter.length > 0 && (
<section className="sk-card sk-card--head" style={{ marginTop: 'var(--sk-space-3)' }}>
<div className="sk-card-head">
<h2>Platzhalter-Werte</h2>
<p>
Grau hinterlegte Platzhalter kommen aus den Adressdaten. Eigene füllen Sie hier
je Empfänger aus.
</p>
</div>
<div className="sk-card-body">
{platzhalterNurImText && eigeneImText.length > 0 && (
<div className="sk-options" style={{ marginBottom: 'var(--sk-space-4)' }}>
<Option name="ph-eingabe" selected={eingabeArt === 'excel'}
onSelect={() => setEingabeArt('excel')} title="Excel-Datei hochladen" />
<Option name="ph-eingabe" selected={eingabeArt === 'tabelle'}
onSelect={() => !nurUpload && setEingabeArt('tabelle')}
title="Direkt in Tabelle eingeben"
desc={nurUpload
? `Ab ${TABELLEN_GRENZE + 1} Dokumenten nicht möglich – bitte Datei hochladen`
: `Bis ${TABELLEN_GRENZE} Dokumente – ohne Datei-Upload`} />
</div>
)}
{eingabeArt === 'excel' ? (
platzhalterNurImText ? (
<div>
<button type="button" className="sk-btn sk-btn--ghost"
onClick={() => ladeVorlageHerunter(eigeneImText, false, true)}>
Vorlage herunterladen
</button>
<label className="sk-drop" style={{ marginTop: 'var(--sk-space-4)' }}>
<span className="sk-drop-title">Ausgefüllte Datei hier ablegen oder klicken</span>
<span className="sk-drop-sub" style={{ display: 'block' }}>.xlsx oder .csv</span>
<input type="file" accept=".xlsx,.xls,.csv"
onChange={(e) => excelEinlesen(e.target.files?.[0] ?? null)} />
</label>
{excelDatei && !excelFehler && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
{excelDatei.name} – {entries.length} Zeile(n) gelesen.
</p>
)}
{excelFehler && <p className="sk-error">{excelFehler}</p>}
</div>
) : (
<p className="sk-note">
Die Werte kommen aus Ihrer Excel-Datei.
{eigeneImText.length > 0 && (
<> Ergänzen Sie eigene Platzhalter ({eigeneImText.map((p) => `[[${p}]]`).join(', ')})
dort als zusätzliche Spalten – laden Sie die Vorlage im Schritt „Empfänger" neu,
falls nötig.</>
)}
</p>
)
) : (
<div className="sk-table-wrap">
<table className="sk-table">
<thead>
<tr>
<th>#</th>
{genutztePlatzhalter.map((p) => <th key={p}>{p}</th>)}
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr key={i}>
<td className="sk-table-no">{i + 1}</td>
{genutztePlatzhalter.map((p) => {
const standard = standardSet.has(p.toLowerCase());
return (
<td key={p}>
<input
className="sk-cell"
readOnly={standard}
title={standard ? 'Kommt aus den Adressdaten' : undefined}
style={standard ? { background: 'rgba(15,23,42,0.04)', color: 'var(--sk-muted)' } : undefined}
value={standard ? standardWert(e, p) : (e.placeholders[p] ?? '')}
onChange={standard ? undefined : (ev) => setPlatzhalter(i, p, ev.target.value)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
)}
</>
)}
</>
)}
{/* ── Rechnungs- und Lieferdaten ──────────────────────── */}
{aktuell === 'Rechnungsdaten' && (
<>
{/* Reihenfolge und Pflichtfelder 1:1 aus dem bestehenden Konfigurator */}
<section className="sk-card sk-card--head">
<div className="sk-card-head">
<h2>Rechnungsdaten</h2>
<p>An diese Adresse stellen wir die Rechnung aus.</p>
</div>
<div className="sk-card-body">
<Feld id="r-vn" label="Vorname" value={rechnung.first_name} required
onChange={(v) => setRechnung({ ...rechnung, first_name: v })} />
<Feld id="r-nn" label="Nachname" value={rechnung.last_name} required
onChange={(v) => setRechnung({ ...rechnung, last_name: v })} />
{personType === 'unternehmen' && (
<Feld id="r-fa" label="Unternehmensname" value={rechnung.company} required
onChange={(v) => setRechnung({ ...rechnung, company: v })} />
)}
<Feld id="r-mail" label="E-Mail-Adresse" type="email" value={rechnung.email} required
onChange={(v) => setRechnung({ ...rechnung, email: v })}
hint="An diese Adresse senden wir Bestätigung und Statusmeldungen." />
<Feld id="r-tel" label="Telefonnummer" type="tel" value={rechnung.phone} tel
onChange={(v) => setRechnung({ ...rechnung, phone: v })} />
<Feld id="r-str" label="Straße und Hausnummer" value={rechnung.street} required
onChange={(v) => setRechnung({ ...rechnung, street: v })} />
<Feld id="r-plz" label="PLZ" value={rechnung.zip} required numerisch
onChange={(v) => setRechnung({ ...rechnung, zip: v })} />
<Feld id="r-ort" label="Stadt" value={rechnung.city} required
onChange={(v) => setRechnung({ ...rechnung, city: v })} />
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label sk-label--required" htmlFor="r-land">Land</label>
<select id="r-land" className="sk-input" value={rechnung.country || 'Deutschland'}
onChange={(e) => setRechnung({ ...rechnung, country: e.target.value })}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
</section>
{/* Abweichende Lieferadresse gibt es nur beim Sammelversand –
beim Einzelversand geht alles direkt an die Empfänger. */}
{shippingType === 'sammel' && (
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
<label className="sk-check">
<input type="checkbox" checked={lieferAbweichend}
onChange={(e) => setLieferAbweichend(e.target.checked)} />
<span><strong>Abweichende Lieferadresse</strong></span>
</label>
{lieferAbweichend && (
<div style={{ marginTop: '18px' }}>
<Feld id="l-vn" label="Vorname" value={lieferung.first_name} required
onChange={(v) => setLieferung({ ...lieferung, first_name: v })} />
<Feld id="l-nn" label="Nachname" value={lieferung.last_name} required
onChange={(v) => setLieferung({ ...lieferung, last_name: v })} />
<Feld id="l-str" label="Straße und Hausnummer" value={lieferung.street} required
onChange={(v) => setLieferung({ ...lieferung, street: v })} />
<Feld id="l-plz" label="PLZ" value={lieferung.zip} required numerisch
onChange={(v) => setLieferung({ ...lieferung, zip: v })} />
<Feld id="l-ort" label="Stadt" value={lieferung.city} required
onChange={(v) => setLieferung({ ...lieferung, city: v })} />
<div className="sk-field" style={{ marginBottom: 0 }}>
<label className="sk-label sk-label--required" htmlFor="l-land">Land</label>
<select id="l-land" className="sk-input" value={lieferung.country || 'Deutschland'}
onChange={(e) => setLieferung({ ...lieferung, country: e.target.value })}>
{LAENDER.map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
)}
</section>
)}
</>
)}
{/* ── Übersicht ───────────────────────────────────────── */}
{aktuell === 'Übersicht' && (
<>
{keinePreise ? (
<section className="sk-card">
<p className="sk-note" style={{ margin: 0 }}>
{externModus
? 'Externe Bestellung – sie wird ohne Preis und ohne Zahlung direkt zur Produktion eingereiht (Abrechnung erfolgt über die Plattform).'
: 'Der Preis wird individuell berechnet – Sie erhalten die Rechnung anschließend per E-Mail.'}
</p>
</section>
) : (
<>
<section className="sk-card">
<h2>Kostenübersicht</h2>
{quote ? (
<>
<ul className="sk-summary">
{quote.lines.map((l, i) => (
<li key={i}>
<span>{l.label}</span>
<span>{euro(l.amount)}</span>
</li>
))}
<li className="sk-summary-sum">
<span>{personType === 'unternehmen' ? 'Summe netto' : 'Summe'}</span>
<span>{euro(gesamt ?? 0)}</span>
</li>
<li className="sk-summary-note">
<span>{mwstHinweis}</span>
<span>{euro(quote.vat_amount)}</span>
</li>
</ul>
</>
) : (
<p className="sk-note">Preis wird berechnet …</p>
)}
{quoteFehler && <p className="sk-error">{quoteFehler}</p>}
<div className="sk-field" style={{ marginTop: 'var(--sk-space-5)', marginBottom: 0 }}>
<label className="sk-label" htmlFor="gutschein">Gutscheincode <span className="sk-note">(optional)</span></label>
<input id="gutschein" className="sk-input" value={voucher}
onChange={(e) => setVoucher(e.target.value)} />
</div>
</section>
<section className="sk-card" style={{ marginTop: 'var(--sk-space-3)' }}>
<h2>Zahlungsart</h2>
<div className="sk-options" style={{ marginTop: 'var(--sk-space-4)' }}>
{/* PayPal nur zeigen, wenn erlaubt (unter dem Netto-Limit). */}
{paypalMoeglich && (
<Option name="pay" selected={zahlung === 'paypal'} onSelect={() => setZahlung('paypal')}
title="PayPal" desc="Sofort bezahlen" />
)}
<Option name="pay" selected={zahlung === 'rechnung'} onSelect={() => setZahlung('rechnung')}
title="Rechnung" desc="Zahlung nach Erhalt der Rechnung, Produktionsstart nach Zahlungseingang" />
</div>
{!paypalMoeglich && quote && (
<p className="sk-note" style={{ marginTop: 'var(--sk-space-3)' }}>
Ab 200 € netto wickeln wir ausschließlich per Rechnung ab.
</p>
)}
</section>
</>
)}
<section style={{ marginTop: 'var(--sk-space-3)' }}>
<label className="sk-check sk-check--card">
<input type="checkbox" checked={agb} onChange={(e) => setAgb(e.target.checked)} />
<span>
Ich habe die{' '}
<a href="https://skrift.de/agb" target="_blank" rel="noreferrer">
Allgemeinen Geschäftsbedingungen
</a>{' '}
gelesen und akzeptiere deren Geltung. Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1
BGB nicht, da Skrift ausschließlich Waren anbietet, die nicht vorgefertigt sind und für
deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Kunden maßgeblich
ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind.
</span>
</label>
<label className="sk-check sk-check--card">
<input type="checkbox" checked={datenschutz} onChange={(e) => setDatenschutz(e.target.checked)} />
<span>
Ja, ich habe die{' '}
<a href="https://skrift.de/datenschutz" target="_blank" rel="noreferrer">
Datenschutzerklärung
</a>{' '}
zur Kenntnis genommen und bin damit einverstanden, dass die von mir angegebenen Daten
elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur streng
zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt.
</span>
</label>
<label className="sk-check sk-check--card">
<input type="checkbox" checked={final} onChange={(e) => setFinal(e.target.checked)} />
<span>
Druckdaten und Schriftauswahl sind final
<span className="sk-option-desc" style={{ display: 'block' }}>
Ich bestätige, dass Schriftart, Brieftext und alle Empfängerdaten endgültig sind. Der
Auftrag wird unmittelbar in den Druck gegeben und kann danach nicht mehr geändert werden.
</span>
</span>
</label>
{!mengeStimmt && (
<div role="alert" style={{
border: '1px solid var(--sk-danger, #c62828)',
background: 'rgba(198,40,40,0.08)',
color: 'var(--sk-danger, #c62828)',
borderRadius: 'var(--sk-radius, 10px)',
padding: 'var(--sk-space-3) var(--sk-space-4)',
margin: 'var(--sk-space-4) 0 0',
fontSize: 'var(--sk-size-sm)',
}}>
Ihre hinterlegten Empfänger/Dokumente ({entries.length}) stimmen nicht mit der
gewählten Menge ({quantity}) überein. Bitte gleichen Sie die Menge oder Ihre
Empfängerliste an, bevor Sie kostenpflichtig bestellen.
</div>
)}
{bestellFehler.length > 0 && (
<ul className="sk-error" style={{ margin: 'var(--sk-space-3) 0 0', paddingLeft: '1.1em' }}>
{bestellFehler.map((f) => <li key={f}>{f}</li>)}
</ul>
)}
<button className="sk-btn sk-btn--primary sk-btn--block" style={{ marginTop: 'var(--sk-space-5)' }}
disabled={!bestellbereit || sendet} onClick={bestellen}>
{sendet ? 'Wird gesendet …' : externModus ? 'Bestellung absenden' : keinePreise ? 'Kostenpflichtig bestellen' : 'Zahlungspflichtig bestellen'}
</button>
</section>
</>
)}
{/* Zeilen-Überlauf als deutlicher roter Kasten direkt vor „Weiter". */}
{aktuell === 'Text' && textUeberlauf && previewFehler && (
<div role="alert" style={{
border: '1px solid var(--sk-danger, #c62828)',
background: 'rgba(198,40,40,0.08)',
color: 'var(--sk-danger, #c62828)',
borderRadius: 'var(--sk-radius, 10px)',
padding: 'var(--sk-space-3) var(--sk-space-4)',
margin: 'var(--sk-space-4) 0 0',
fontSize: 'var(--sk-size-sm)',
}}>{previewFehler}</div>
)}
<div className="sk-wizard-nav">
<button className="sk-btn sk-btn--ghost" onClick={() => setStep((v) => Math.max(0, v - 1))}
disabled={step === 0}>
Zurück
</button>
{/* Im letzten Schritt gibt es kein „Weiter" – dort steht der
Bestell- bzw. Absende-Button in der Karte selbst. */}
{step < maxStep && (
<button className="sk-btn sk-btn--primary" onClick={weiter} disabled={!weiterMoeglich || pruefeLaedt}>
{pruefeLaedt ? 'Prüfe …' : 'Weiter'}
</button>
)}
</div>
</div>
{/* Vorschau nur beim Text-Schritt – nicht bei Datei-Produkten (dort wird
das hochgeladene Dokument verwendet, keine Handschrift generiert). */}
{aktuell === 'Text' && !dateiModus && (
<PreviewPanel
titel={gewaehltesProdukt?.name ?? ''}
format={produktFormate.find((f) => f.key === format)?.name ?? ''}
docs={previewDocs}
gesamtDokumente={quantity}
verbleibend={previewVerbleibend}
maximal={previewLimit}
laedt={previewLaedt}
fehler={previewFehler}
onGenerate={generiereVorschau}
bereit={!!product && !textservice && !!textTemplate && !textZuLang}
/>
)}
</div>
</>
);
}