@@ -44,8 +44,10 @@ const PRODUKT_TEXT: Record<string, string> = {
follow_ups : 'Automatisierte handschriftliche Nachfass-Mailings für mehr Rücklauf.' ,
} ;
/** Standard-Platzhalter der strukturierten Adresse (werden automatisch befüllt). */
const STANDARD_STRUKTUR = [ 'Anrede' , 'Vorname' , 'Nachname' , 'Strasse' , 'PLZ' , 'Ort' , 'Land' ] ;
/** 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' ] ;
@@ -304,6 +306,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
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.
@@ -357,7 +360,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [ sendet , setSendet ] = useState ( false ) ;
const [ bestellFehler , setBestellFehler ] = useState < string [ ] > ( [ ] ) ;
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 < PublicSettings | null > ( null ) ;
const [ preisKatalog , setPreisKatalog ] = useState < Record < string , PriceItem > > ( { } ) ;
@@ -401,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 ] ) ) ) )
@@ -500,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.
@@ -792,6 +806,28 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const eigeneImText = genutztePlatzhalter . filter ( ( p ) = > ! standardSet . has ( p . toLowerCase ( ) ) ) ;
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
const genutztePlatzhalterUmschlag = useMemo ( ( ) = > textPlatzhalter ( umschlagText ) , [ umschlagText ] ) ;
/**
* 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
@@ -821,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 ) ;
@@ -833,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 ;
@@ -911,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
@@ -932,7 +976,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
realistic : realistisch ,
envelope_labeling : needsEnvelope ? labeling : 'keine' ,
envelope_text : umschlagText ,
entries : entries.slice ( 0 , 3 ) ,
entries : entries.slice ( 0 , 3 ) . map ( mitPlatzhaltern ) ,
} ) ;
if ( r . has_overflow ) {
setTextUeberlauf ( true ) ;
@@ -981,7 +1025,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
realistic : realistisch ,
envelope_labeling : needsEnvelope ? labeling : 'keine' ,
envelope_text : umschlagText ,
entries : entries.slice ( 0 , 3 ) ,
entries : entries.slice ( 0 , 3 ) . map ( mitPlatzhaltern ) ,
} ) ;
setPreviewDocs ( ergebnis . docs ) ;
setPreviewVerbleibend ( ergebnis . remaining ) ;
@@ -1080,20 +1124,23 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
street : lieferung.street , zip : lieferung.zip , city : lieferung.city , country : lieferung.country ,
}
: null ,
entries : entries.map ( ( e ) = > ( {
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 : adressModus === 'freitext' ? e . free_text : '' ,
placeholders : e.placeholders ,
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
text : ( e . text && e . text . trim ( ) ) ? e.text : undefined ,
} ) ) ,
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 */ }
@@ -1101,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 ) {
@@ -1352,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). */ }
< div className = "sk-options" style = { { marginTop : 'var(--sk-space-4)' } } >
< Option name = "motif" selected = { motifMode === 'katalog' } onSelect = { ( ) = > setMotifMode ( 'katalog' ) }
titl e= "M otiv aus unserer Auswahl" desc = "Wir zeigen Ihnen passende Motive" / >
{ katalogOptionSichtbar && (
< Option nam e= "m otif" 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' ) }
@@ -2038,6 +2091,22 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
< / 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 > ) }