diff --git a/src/routes/Configurator.tsx b/src/routes/Configurator.tsx index e473973..d015cea 100644 --- a/src/routes/Configurator.tsx +++ b/src/routes/Configurator.tsx @@ -306,6 +306,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const [motifMode, setMotifMode] = useState(entwurf.motifMode ?? 'katalog'); const [motifs, setMotifs] = useState([]); + const [motifeGeladen, setMotifeGeladen] = useState(false); const [motifId, setMotifId] = useState(entwurf.motifId ?? null); // Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden // Motiv-Optionen – daher abgeleitet, kein eigener Zustand mehr. @@ -359,7 +360,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri const [sendet, setSendet] = useState(false); const [bestellFehler, setBestellFehler] = useState([]); - const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; extern?: boolean } | null>(null); + const [bestellung, setBestellung] = useState<{ nummer: string; zahlung: string; bezahlt: boolean; betrag: number; extern?: boolean } | null>(null); const [settings, setSettings] = useState(null); const [preisKatalog, setPreisKatalog] = useState>({}); @@ -403,7 +404,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri // … 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([])); + 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])))) @@ -502,6 +503,17 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri [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. @@ -845,7 +857,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri .then(() => { if (abgebrochen || !paypalRef.current) return; return rendereButtons(paypalRef.current, { - betrag: quote?.gross_total ?? 0, + betrag: bestellung.betrag, beschreibung: `Skrift ${gewaehltesProdukt?.name ?? ''} – ${bestellung.nummer}`, onApproved: async (referenz) => { await confirmPayment(bestellung.nummer, referenz); @@ -857,7 +869,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri .catch((err) => setBestellFehler([err.message ?? 'PayPal konnte nicht geladen werden.'])); return () => { abgebrochen = true; }; - }, [bestellung, settings, quote?.gross_total, gewaehltesProdukt?.name]); + }, [bestellung, settings, gewaehltesProdukt?.name]); const mwst = quote?.mwst_percent ?? settings?.mwst_percent ?? 19; @@ -935,8 +947,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri 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); + const bestellbereit = agb && datenschutz && final && (!!quote || keinePreise) && mengeStimmt; async function weiter() { // Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele @@ -1128,6 +1148,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri 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) { @@ -1379,8 +1403,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri {/* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer genau eine Option gewählt (auch der Gestaltungsservice). */}
-