@@ -306,6 +306,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const [ motifMode , setMotifMode ] = useState < MotifMode > ( entwurf . motifMode ? ? 'katalog' ) ;
const [ motifMode , setMotifMode ] = useState < MotifMode > ( entwurf . motifMode ? ? 'katalog' ) ;
const [ motifs , setMotifs ] = useState < Motif [ ] > ( [ ] ) ;
const [ motifs , setMotifs ] = useState < Motif [ ] > ( [ ] ) ;
const [ motifeGeladen , setMotifeGeladen ] = useState ( false ) ;
const [ motifId , setMotifId ] = useState < number | null > ( entwurf . motifId ? ? null ) ;
const [ motifId , setMotifId ] = useState < number | null > ( entwurf . motifId ? ? null ) ;
// Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden
// Gestaltungsservice ist jetzt eine der sich gegenseitig ausschließenden
// Motiv-Optionen – daher abgeleitet, kein eigener Zustand mehr.
// 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 [ sendet , setSendet ] = useState ( false ) ;
const [ bestellFehler , setBestellFehler ] = useState < string [ ] > ( [ ] ) ;
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 [ settings , setSettings ] = useState < PublicSettings | null > ( null ) ;
const [ preisKatalog , setPreisKatalog ] = useState < Record < string , PriceItem > > ( { } ) ;
const [ preisKatalog , setPreisKatalog ] = useState < Record < string , PriceItem > > ( { } ) ;
@@ -403,7 +404,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
// … Motive, Einstellungen und Preise dagegen optional. Sie dürfen den
// … Motive, Einstellungen und Preise dagegen optional. Sie dürfen den
// Konfigurator nicht blockieren, wenn Felder fehlen (z. B. vor dem Bootstrap).
// 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 ) ) ;
fetchSettings ( ) . then ( setSettings ) . catch ( ( ) = > setSettings ( null ) ) ;
fetchPriceItems ( )
fetchPriceItems ( )
. then ( ( items ) = > setPreisKatalog ( Object . fromEntries ( items . map ( ( i ) = > [ i . key , i ] ) ) ) )
. then ( ( items ) = > setPreisKatalog ( Object . fromEntries ( items . map ( ( i ) = > [ i . key , i ] ) ) ) )
@@ -502,6 +503,17 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
[ motifs , benoetigteAusrichtung ] ,
[ motifs , benoetigteAusrichtung ] ,
) ;
) ;
const gewaehltesMotiv = sichtbareMotive . find ( ( m ) = > m . id === motifId ) ? ? null ;
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 –
// Produkt aus dem Direktlink (z. B. /briefe) vorwählen, sobald geladen –
// überschreibt auch ein evtl. zwischengespeichertes anderes Produkt.
// überschreibt auch ein evtl. zwischengespeichertes anderes Produkt.
@@ -572,23 +584,6 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
useEffect ( ( ) = > { setPreviewSession ( null ) ; } ,
useEffect ( ( ) = > { setPreviewSession ( null ) ; } ,
[ textTemplate , font , format , realistisch , labeling , needsEnvelope , umschlagText , entries ] ) ;
[ textTemplate , font , format , realistisch , labeling , needsEnvelope , umschlagText , entries ] ) ;
// Empfängerzeilen an die Menge angleichen (nur im Tabellenmodus). Gilt auch im
// Extern-Modus: die (vom Betreiber vorgegebene) Menge erzeugt die passende
// Anzahl Zeilen – egal ob mit oder ohne Kuvert/Empfänger.
useEffect ( ( ) = > {
// Bei vielen Empfängern (Tabelle unpraktisch) wird auf Excel verwiesen und
// nicht synchronisiert – AUSSER im Extern-Modus ohne Empfänger-Schritt, wo die
// Menge einfach N Blanko-Dokumente erzeugen muss (auch über der Tabellengrenze).
if ( nurUpload && ( ! externModus || empfaengerSchritt ) ) 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 ] ) ;
// Extern-Preset (vom Betreiber im Direktlink gesetzt) – der Kunde wählt nichts:
// 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
// ?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
// Die Menge ist maßgeblich (greift immer); nur ein Excel-Import passt sie an die
@@ -794,16 +789,43 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
const eigeneImText = genutztePlatzhalter . filter ( ( p ) = > ! standardSet . has ( p . toLowerCase ( ) ) ) ;
const eigeneImText = genutztePlatzhalter . filter ( ( p ) = > ! standardSet . has ( p . toLowerCase ( ) ) ) ;
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
/** Platzhalter im individuellen Umschlagtext – analog zur Text-Tabelle. */
const genutztePlatzhalterUmschlag = useMemo ( ( ) = > textPlatzhalter ( umschlagText ) , [ umschlagText ] ) ;
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
* Entry für den Versand ans Backend: löst ALLE im Text/Umschlag genutzten
* Platzhalter explizit auf (Standard-Platzhalter aus der Adresse via standardWert,
* Platzhalter explizit auf (Standard-Platzhalter aus der Adresse via standardWert,
* eigene aus e.placeholders). So wird je Empfänger sicher ersetzt – unabhängig
* eigene aus e.placeholders). So wird je Empfänger sicher ersetzt – unabhängig
* davon, wie das Backend die Adressfelder interpretiert.
* 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 mitPlatzhaltern = ( e : Entry ) : Entry = > {
const used = [ . . . new Set ( [ . . . genutztePlatzhalter , . . . genutztePlatzhalterUmschlag ] ) ] ;
const used = [ . . . new Set ( [ . . . genutztePlatzhalter , . . . genutztePlatzhalterUmschlag ] ) ] ;
return {
return {
. . . e ,
. . . e ,
free_text : adressModus === 'freitext' ? e . free_text : '' ,
placeholders : {
placeholders : {
. . . Object . fromEntries ( used . map ( ( p ) = > [ p , standardWert ( e , p ) ] ) ) ,
. . . Object . fromEntries ( used . map ( ( p ) = > [ p , standardWert ( e , p ) ] ) ) ,
. . . e . placeholders ,
. . . e . placeholders ,
@@ -839,7 +861,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
. then ( ( ) = > {
. then ( ( ) = > {
if ( abgebrochen || ! paypalRef . current ) return ;
if ( abgebrochen || ! paypalRef . current ) return ;
return rendereButtons ( paypalRef . current , {
return rendereButtons ( paypalRef . current , {
betrag : quote?.gross_total ? ? 0 ,
betrag : bestellung.betrag ,
beschreibung : ` Skrift ${ gewaehltesProdukt ? . name ? ? '' } – ${ bestellung . nummer } ` ,
beschreibung : ` Skrift ${ gewaehltesProdukt ? . name ? ? '' } – ${ bestellung . nummer } ` ,
onApproved : async ( referenz ) = > {
onApproved : async ( referenz ) = > {
await confirmPayment ( bestellung . nummer , referenz ) ;
await confirmPayment ( bestellung . nummer , referenz ) ;
@@ -851,7 +873,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
. catch ( ( err ) = > setBestellFehler ( [ err . message ? ? 'PayPal konnte nicht geladen werden.' ] ) ) ;
. catch ( ( err ) = > setBestellFehler ( [ err . message ? ? 'PayPal konnte nicht geladen werden.' ] ) ) ;
return ( ) = > { abgebrochen = true ; } ;
return ( ) = > { abgebrochen = true ; } ;
} , [ bestellung , settings , quote ? . gross_total , gewaehltesProdukt ? . name ] ) ;
} , [ bestellung , settings , gewaehltesProdukt ? . name ] ) ;
const mwst = quote ? . mwst_percent ? ? settings ? . mwst_percent ? ? 19 ;
const mwst = quote ? . mwst_percent ? ? settings ? . mwst_percent ? ? 19 ;
@@ -929,8 +951,16 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
if ( ! paypalMoeglich && zahlung === 'paypal' ) setZahlung ( 'rechnung' ) ;
if ( ! paypalMoeglich && zahlung === 'paypal' ) setZahlung ( 'rechnung' ) ;
} , [ paypalMoeglich , zahlung ] ) ;
} , [ 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.
// 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() {
async function weiter() {
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
// Beim Verlassen des Text-Schritts prüfen, ob das Schriftstück zu viele
@@ -999,7 +1029,7 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
realistic : realistisch ,
realistic : realistisch ,
envelope_labeling : needsEnvelope ? labeling : 'keine' ,
envelope_labeling : needsEnvelope ? labeling : 'keine' ,
envelope_text : umschlagText ,
envelope_text : umschlagText ,
entries : entries.slice ( 0 , 3 ) ,
entries : entries.slice ( 0 , 3 ) . map ( mitPlatzhaltern ) ,
} ) ;
} ) ;
setPreviewDocs ( ergebnis . docs ) ;
setPreviewDocs ( ergebnis . docs ) ;
setPreviewVerbleibend ( ergebnis . remaining ) ;
setPreviewVerbleibend ( ergebnis . remaining ) ;
@@ -1098,20 +1128,23 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
street : lieferung.street , zip : lieferung.zip , city : lieferung.city , country : lieferung.country ,
street : lieferung.street , zip : lieferung.zip , city : lieferung.city , country : lieferung.country ,
}
}
: null ,
: null ,
entries : entries.map ( ( e ) = > ( {
entries : entries.map ( ( e ) = > {
salutation : e.salutation ,
const n = mitPlatzhaltern ( e ) ; // free_text-Normalisierung + Platzhalter
first_name : e.first_name ,
return {
last_name : e.last_name ,
salutation : e.salutation ,
street : e.street ,
first_name : e.first_name ,
house_no : e.house_no ,
last_name : e.last_name ,
zip : e.zip ,
street : e.street ,
city : e.city ,
house_no : e.house_no ,
country : e.country ,
zip : e.zip ,
free_text : adressModus === 'freitext' ? e . free_text : '' ,
city : e.city ,
placeholders : mitPlatzhaltern ( e ) . placeholders ,
country : e.country ,
// Abweichender Text nur, wenn wirklich gesetzt (sonst greift der Standardtext).
free_text : n.free_text ,
text : ( e . text && e . text . trim ( ) ) ? e.text : undefined ,
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.
// Auftrag ist abgesendet – der zwischengespeicherte Entwurf wird verworfen.
try { localStorage . removeItem ( ENTWURF_KEY ) ; } catch { /* egal */ }
try { localStorage . removeItem ( ENTWURF_KEY ) ; } catch { /* egal */ }
@@ -1119,6 +1152,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
nummer : ergebnis.order_number ,
nummer : ergebnis.order_number ,
zahlung : ergebnis.payment_method ,
zahlung : ergebnis.payment_method ,
bezahlt : ergebnis.payment_method !== 'paypal' ,
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 ,
extern : externModus ,
} ) ;
} ) ;
} catch ( err ) {
} catch ( err ) {
@@ -1370,8 +1407,10 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
{ /* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer
{ /* Einheitliche Radio-Karten in fester Reihenfolge – es ist immer
genau eine Option gewählt (auch der Gestaltungsservice). */ }
genau eine Option gewählt (auch der Gestaltungsservice). */ }
< div className = "sk-options" style = { { marginTop : 'var(--sk-space-4)' } } >
< div className = "sk-options" style = { { marginTop : 'var(--sk-space-4)' } } >
< Option name = "motif" selected = { motifMode === 'katalog' } onSelect = { ( ) = > setMotifMode ( 'katalog' ) }
{ katalogOptionSichtbar && (
titl e= "M otiv aus unserer Auswahl" desc = "Wir zeigen Ihnen passende Motive" / >
< 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' ) }
< Option name = "motif" selected = { motifMode === 'ohne' } onSelect = { ( ) = > setMotifMode ( 'ohne' ) }
title = "Ohne Motiv" desc = "Blanko-Karte, nur beschrieben" / >
title = "Ohne Motiv" desc = "Blanko-Karte, nur beschrieben" / >
< Option name = "motif" selected = { motifMode === 'upload' } onSelect = { ( ) = > setMotifMode ( 'upload' ) }
< Option name = "motif" selected = { motifMode === 'upload' } onSelect = { ( ) = > setMotifMode ( 'upload' ) }
@@ -2056,6 +2095,22 @@ export default function Configurator({ vorwahlProdukt }: { vorwahlProdukt?: stri
< / span >
< / span >
< / label >
< / 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 && (
{ bestellFehler . length > 0 && (
< ul className = "sk-error" style = { { margin : 'var(--sk-space-3) 0 0' , paddingLeft : '1.1em' } } >
< ul className = "sk-error" style = { { margin : 'var(--sk-space-3) 0 0' , paddingLeft : '1.1em' } } >
{ bestellFehler . map ( ( f ) = > < li key = { f } > { f } < / li > ) }
{ bestellFehler . map ( ( f ) = > < li key = { f } > { f } < / li > ) }