All checks were successful
Build & Deploy / deploy (push) Successful in 22s
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>
2176 lines
106 KiB
TypeScript
2176 lines
106 KiB
TypeScript
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>
|
||
</>
|
||
);
|
||
}
|