Files
2026-02-07 13:04:04 +01:00

1031 lines
30 KiB
JavaScript

// Skrift Konfigurator State - Überarbeitet
// Optimierter Frageprozess mit bedingter Logik
// Import Pricing Logic
import { calculateStandardQuote } from "./configurator-pricing.js";
// Basis-Produktdefinitionen (werden mit Backend-Settings überschrieben)
const PRODUCT_BASE_CONFIG = {
businessbriefe: {
key: "businessbriefe",
formats: ["a4"],
category: "business",
supportsMotif: false,
},
"business-postkarten": {
key: "business-postkarten",
formats: ["a6p", "a6l"],
category: "business",
supportsMotif: true,
},
"follow-ups": {
key: "follow-ups",
formats: ["a4", "a6p", "a6l"],
category: "business",
supportsMotif: true,
isFollowUp: true,
},
einladungen: {
key: "einladungen",
formats: ["a4", "a6p", "a6l"],
category: "private",
supportsMotif: true,
},
"private-briefe": {
key: "private-briefe",
formats: ["a4"],
category: "private",
supportsMotif: false,
},
};
// Produktdefinitionen mit Backend-Settings mergen
function getProductDefinitions() {
const settings = window.SkriftConfigurator?.settings?.products || {};
const products = {};
for (const [key, baseConfig] of Object.entries(PRODUCT_BASE_CONFIG)) {
const backendSettings = settings[key] || {};
products[key] = {
...baseConfig,
label: backendSettings.label || key,
description: backendSettings.description || 'Professionelle handgeschriebene Korrespondenz',
basePrice: parseFloat(backendSettings.base_price) || 2.50,
};
}
return products;
}
const PRODUCT_BY_PARAM = getProductDefinitions();
export const STEPS = {
PRODUCT: 0, // Kundentyp + Produktauswahl zusammen
QUANTITY: 1, // Mengenabfrage
ENVELOPE: 2, // Versand + Umschlag zusammen
CONTENT: 3,
CUSTOMER_DATA: 4,
REVIEW: 5,
};
export function deriveContextFromUrl(search) {
const q = new URLSearchParams(search);
const keys = Array.from(q.keys());
// Produkt ermitteln (erster Key der ein Produkt ist, oder 'product' Parameter)
let product = null;
let urlParam = null;
// Prüfe ob ein Produkt-Schlüssel direkt als Parameter vorhanden ist
for (const key of keys) {
if (PRODUCT_BY_PARAM[key]) {
product = PRODUCT_BY_PARAM[key];
urlParam = key;
break;
}
}
// Quantity aus URL
const quantityParam = q.get('quantity');
const quantity = quantityParam ? parseInt(quantityParam, 10) : null;
// Format aus URL (a4, a6h = A6 Hochformat, a6q = A6 Querformat)
const formatParam = q.get('format')?.toLowerCase();
let format = null;
if (formatParam === 'a4') format = 'a4';
else if (formatParam === 'a6h') format = 'a6p'; // Hochformat
else if (formatParam === 'a6q') format = 'a6l'; // Querformat
// noPrice Parameter (Preise ausblenden)
const noPrice = q.has('noPrice') || q.has('noprice');
// noLimits Parameter (keine Mindestmengen)
const noLimits = q.has('noLimits') || q.has('nolimits');
return {
urlParam: urlParam || null,
product,
quantity: quantity && !isNaN(quantity) && quantity > 0 ? quantity : null,
format,
noPrice,
noLimits,
};
}
export function normalizePlaceholderName(raw) {
return String(raw || "")
.toLowerCase()
.replace(/\s+/g, "");
}
export function extractPlaceholders(text) {
const out = new Set();
const re = /\[\[([^\]]+)\]\]/g;
let m;
while ((m = re.exec(String(text || "")))) {
const name = normalizePlaceholderName(m[1]);
if (name) out.add(name);
}
return Array.from(out);
}
export function getEnvelopeTypeByFormat(format) {
if (format === "a4") return "dinlang";
if (format === "a6p" || format === "a6l") return "c6";
return null;
}
export function createInitialState(ctx) {
const hasPreselectedProduct = !!ctx?.product?.key;
// Wenn Produkt vorgewählt, Produktauswahl überspringen
let initialStep = STEPS.PRODUCT;
let initialCustomerType = null;
if (hasPreselectedProduct) {
initialStep = STEPS.QUANTITY;
initialCustomerType =
ctx.product.category === "business" ? "business" : "private";
}
// URL-Parameter für Menge und Format
const urlQuantity = ctx?.quantity || null;
const urlFormat = ctx?.format || null;
// Wenn Produkt + Quantity + Format aus URL: Direkt zu Umschlag-Step springen
if (hasPreselectedProduct && urlQuantity && urlFormat) {
// Prüfen ob Format vom Produkt unterstützt wird
const supportedFormats = ctx.product.formats || [];
if (supportedFormats.includes(urlFormat)) {
initialStep = STEPS.ENVELOPE;
}
}
// Standardmenge auf beste Preismenge (normalQuantity) setzen, außer URL-Parameter
const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {};
const isB2B = initialCustomerType === "business";
const defaultQuantity = urlQuantity || (isB2B
? (dynamicPricing.business_normal_quantity || 200)
: (dynamicPricing.private_normal_quantity || 50));
// Format aus URL oder null
const initialFormat = urlFormat || null;
// noPrice Mode aus URL
const noPrice = ctx?.noPrice || false;
// noLimits Mode aus URL (keine Mindestmengen)
const noLimits = ctx?.noLimits || false;
return {
step: initialStep,
history: [],
ctx,
noPrice, // Preise ausblenden wenn true
noLimits, // Keine Mindestmengen wenn true
quote: {
currency: "EUR",
subtotalNet: 0,
vatRate: 0.19,
vatAmount: 0,
totalGross: 0,
lines: [],
},
answers: {
customerType: initialCustomerType,
quantity: defaultQuantity,
format: initialFormat,
// Follow-ups Spezifisch
followupYearlyVolume: null, // Neu: 5-49, 50-199, etc.
followupCreateMode: null, // 'auto' | 'manual'
followupSourceSystem: "",
followupTriggerDescription: "", // Neu: Was löst Follow-up aus
followupCheckCycle: "monthly",
// Versand
shippingMode: null, // 'direct' | 'bulk'
// Umschlag
envelope: null,
envelopeLabeled: null,
envelopeMode: null, // 'recipientData' | 'customText' | 'none'
envelopeCustomText: "",
// Inhalt
contentCreateMode: null, // 'self' | 'textservice'
letterText: "",
// Motiv
motifNeed: null,
motifSource: null, // 'upload' | 'printed' | 'design'
motifFileName: "",
motifFileMeta: null,
// Services
serviceText: false,
serviceDesign: false,
serviceApi: false,
},
recipientRows: [],
// Adressmodus: 'classic' (Name, Anschrift) oder 'free' (5 freie Zeilen)
addressMode: 'classic',
// Freie Adresszeilen (separat gespeichert, damit beim Wechsel nichts verloren geht)
freeAddressRows: [],
placeholders: {
envelope: [],
letter: [],
},
placeholderValues: {},
order: {
billing: {
firstName: "",
lastName: "",
company: "",
email: "",
phone: "",
street: "",
houseNumber: "",
zip: "",
city: "",
country: "Deutschland",
},
shippingDifferent: false,
shipping: {
firstName: "",
lastName: "",
company: "",
street: "",
houseNumber: "",
zip: "",
city: "",
country: "Deutschland",
},
acceptedAgb: false,
acceptedPrivacy: false,
},
};
}
// Helper Functions
export function isFollowups(state) {
return state?.ctx?.product?.isFollowUp === true;
}
export function isInvitation(state) {
return state?.ctx?.product?.key === "einladungen";
}
export function isPostcardLike(state) {
const k = state?.ctx?.product?.key;
return (
k === "business-postkarten" ||
(k === "follow-ups" &&
(state.answers.format === "a6p" || state.answers.format === "a6l"))
);
}
export function supportsMotif(state) {
const product = state?.ctx?.product;
if (!product?.supportsMotif) return false;
// Postkarten: immer Motiv möglich (nur A6 verfügbar)
if (product.key === "business-postkarten") return true;
// Einladungen und Follow-ups: nur bei A6 Format
if (product.key === "einladungen" || product.key === "follow-ups") {
return state.answers.format === "a6p" || state.answers.format === "a6l";
}
return false;
}
export function productSupportsFormat(state, format) {
const formats = state?.ctx?.product?.formats || [];
return formats.includes(format);
}
export function calcEffectiveEnvelopeType(state) {
return getEnvelopeTypeByFormat(state.answers.format);
}
export function getAvailableProductsForCustomerType(customerType) {
return Object.values(PRODUCT_BY_PARAM).filter(
(p) => p.category === customerType
);
}
export function syncPlaceholders(state) {
const pEnv =
state.answers.envelopeMode === "customText"
? extractPlaceholders(state.answers.envelopeCustomText)
: [];
const pLetter = extractPlaceholders(state.answers.letterText);
const builtIn =
state.answers.envelopeMode === "recipientData" ? ["vorname", "name"] : [];
return {
...state,
placeholders: {
envelope: pEnv,
letter: Array.from(new Set([...pLetter, ...builtIn])),
},
};
}
export function requiredRowCount(state) {
const q = Number(state.answers.quantity);
return Number.isFinite(q) && q > 0 ? q : 0;
}
export function ensurePlaceholderArrays(state) {
const rows = requiredRowCount(state);
const all = new Set([
...state.placeholders.envelope,
...state.placeholders.letter,
]);
const nextValues = { ...state.placeholderValues };
for (const name of all) {
if (!Array.isArray(nextValues[name])) nextValues[name] = [];
if (nextValues[name].length < rows) {
nextValues[name] = [
...nextValues[name],
...new Array(rows - nextValues[name].length).fill(""),
];
} else if (nextValues[name].length > rows) {
nextValues[name] = nextValues[name].slice(0, rows);
}
}
return { ...state, placeholderValues: nextValues };
}
export function validateStep(state) {
const s = state.step;
if (s === STEPS.PRODUCT) {
// Kundentyp und Produkt müssen gewählt sein
if (!state.answers.customerType) return false;
return !!state?.ctx?.product?.key;
}
if (s === STEPS.QUANTITY) {
// Bei Follow-ups keine Mengenabfrage, nur Volumen
if (!isFollowups(state)) {
const qty = Number(state.answers.quantity);
if (!Number.isFinite(qty) || qty <= 0) return false;
}
// Format Auswahl validieren
const formats = state?.ctx?.product?.formats || [];
if (formats.length > 0 && !state.answers.format) return false;
// Follow-ups spezifische Validierung
if (isFollowups(state)) {
if (!state.answers.followupYearlyVolume) return false;
if (!state.answers.followupCreateMode) return false;
if (state.answers.followupCreateMode === "auto") {
if (!state.answers.followupSourceSystem?.trim()) return false;
if (!state.answers.followupTriggerDescription?.trim()) return false;
}
}
return true;
}
if (s === STEPS.ENVELOPE) {
// Versandart muss gewählt sein
if (!state.answers.shippingMode) return false;
// Bei Versand durch Skrift ist Umschlag automatisch
if (state.answers.shippingMode === "direct") {
// Automatisch gesetzt, keine Validierung nötig für envelope
if (!state.answers.envelopeMode) return false;
// Bei Follow-ups: keine Empfängerdaten-Validierung (kommt aus CRM)
// Bei regulären Produkten: Empfängerdaten müssen vorhanden sein
if (!isFollowups(state)) {
// Empfängerdaten validieren für reguläre Produkte
if (state.answers.envelopeMode === "recipientData") {
const addressMode = state.addressMode || 'classic';
if (addressMode === 'free') {
// Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein
const rows = state.freeAddressRows || [];
if (rows.length !== requiredRowCount(state)) return false;
for (const r of rows) {
if (!r) return false;
// Mindestens Zeile 1 muss ausgefüllt sein
if (!String(r.line1 || "").trim()) return false;
}
} else {
// Klassische Adresse
const rows = state.recipientRows || [];
if (rows.length !== requiredRowCount(state)) return false;
for (const r of rows) {
if (!r) return false;
const required = [
"firstName",
"lastName",
"street",
"houseNumber",
"zip",
"city",
"country",
];
for (const k of required) {
if (!String(r[k] || "").trim()) return false;
}
}
}
}
}
} else {
// Bulk versand
if (state.answers.envelope === null) return false;
if (state.answers.envelope === true) {
// Beschriftungsmodus muss gewählt sein
if (!state.answers.envelopeMode) return false;
// Empfängerdaten validieren für Bulk-Versand (nur wenn Umschlag gewählt)
if (state.answers.envelopeMode === "recipientData") {
const addressMode = state.addressMode || 'classic';
if (addressMode === 'free') {
// Freie Adresse: mindestens Zeile 1 muss ausgefüllt sein
const rows = state.freeAddressRows || [];
if (rows.length !== requiredRowCount(state)) return false;
for (const r of rows) {
if (!r) return false;
if (!String(r.line1 || "").trim()) return false;
}
} else {
// Klassische Adresse
const rows = state.recipientRows || [];
if (rows.length !== requiredRowCount(state)) return false;
for (const r of rows) {
if (!r) return false;
const required = [
"firstName",
"lastName",
"street",
"houseNumber",
"zip",
"city",
"country",
];
for (const k of required) {
if (!String(r[k] || "").trim()) return false;
}
}
}
}
// Custom Text validieren (nur wenn Umschlag gewählt)
if (state.answers.envelopeMode === "customText") {
if (!state.answers.envelopeCustomText?.trim()) return false;
if (state.placeholders.envelope.length > 0) {
for (const ph of state.placeholders.envelope) {
const arr = state.placeholderValues[ph] || [];
if (arr.length !== requiredRowCount(state)) return false;
if (arr.some((v) => !String(v || "").trim())) return false;
}
}
}
}
// Wenn envelope === false, keine weitere Validierung nötig
}
return true;
}
if (s === STEPS.CONTENT) {
if (!state.answers.contentCreateMode) return false;
// Wenn self, muss Text vorhanden sein
if (state.answers.contentCreateMode === "self") {
if (!state.answers.letterText?.trim()) return false;
// Platzhalter validieren
const usedLetter = extractPlaceholders(state.answers.letterText);
// Platzhalter validieren
// "vorname", "name", "ort" können immer aus recipientRows kommen (bei shippingMode=direct oder envelopeMode=recipientData)
const recipientPlaceholders = new Set(["vorname", "name", "ort"]);
const hasRecipientData = state.answers.shippingMode === "direct" || state.answers.envelopeMode === "recipientData";
// Platzhalter aus dem Umschlag
const envSet = new Set(state.placeholders.envelope || []);
// Platzhalter die validiert werden müssen (nicht aus Umschlag, nicht aus recipientRows)
const needed = usedLetter.filter((p) => {
if (envSet.has(p)) return false; // aus Umschlag
if (hasRecipientData && recipientPlaceholders.has(p)) return false; // aus recipientRows
return true;
});
// Nur die übrigen Platzhalter validieren
for (const ph of needed) {
const arr = state.placeholderValues[ph] || [];
if (arr.length !== requiredRowCount(state)) return false;
if (arr.some((v) => !String(v || "").trim())) return false;
}
}
// Motiv Validierung
if (supportsMotif(state)) {
if (state.answers.motifNeed === null) return false;
if (state.answers.motifNeed === true) {
if (!state.answers.motifSource) return false;
// Nur bei Upload muss eine Datei vorhanden sein
// Bei "printed" (bedruckte Karten) und "design" (Designservice) nicht
if (state.answers.motifSource === "upload") {
if (!state.answers.motifFileName) return false;
}
}
}
return true;
}
if (s === STEPS.CUSTOMER_DATA) {
const b = state.order.billing;
// houseNumber entfernt - ist jetzt Teil von street
const req = [
"firstName",
"lastName",
"email",
"phone",
"street",
"zip",
"city",
"country",
];
for (const k of req) {
if (!String(b[k] || "").trim()) return false;
}
// E-Mail Format validieren
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(b.email)) return false;
if (state.answers.customerType === "business") {
if (!String(b.company || "").trim()) return false;
}
if (state.order.shippingDifferent) {
const sh = state.order.shipping;
// Shipping braucht kein E-Mail/Telefon - nur Adressfelder
const shippingReq = ["firstName", "lastName", "street", "zip", "city", "country"];
for (const k of shippingReq) {
if (!String(sh[k] || "").trim()) return false;
}
}
return true;
}
if (s === STEPS.REVIEW) {
if (!state.order.acceptedAgb) return false;
if (!state.order.acceptedPrivacy) return false;
return true;
}
return false;
}
// Preisrelevante Felder die eine Neuberechnung auslösen
const PRICE_RELEVANT_FIELDS = new Set([
'quantity',
'format',
'shippingMode',
'envelope',
'envelopeMode',
'motifSource',
'motifNeed',
'contentCreateMode',
'followupYearlyVolume',
'followupCreateMode',
'customerType',
]);
/**
* Prüft ob ein Patch preisrelevante Änderungen enthält
*/
function hasPriceRelevantChanges(patch) {
if (!patch) return false;
return Object.keys(patch).some(key => PRICE_RELEVANT_FIELDS.has(key));
}
/**
* Zählt Inland/Ausland-Empfänger für Vergleich
*/
function countCountryDistribution(rows, addressMode) {
let domestic = 0;
let international = 0;
if (!Array.isArray(rows)) return { domestic, international };
for (const row of rows) {
if (!row) continue;
let country = '';
if (addressMode === 'free') {
country = row.line5 || '';
} else {
country = row.country || '';
}
const countryLower = country.toLowerCase().trim();
const isDomestic = !countryLower ||
countryLower === 'deutschland' ||
countryLower === 'germany' ||
countryLower === 'de' ||
countryLower === 'ger';
if (isDomestic) {
domestic++;
} else {
international++;
}
}
return { domestic, international };
}
/**
* Berechnet Quote neu basierend auf aktuellem State
* Wird nur aufgerufen wenn preisrelevante Änderungen vorliegen
*/
function recalculateQuote(state, forceRecalculate = false) {
// Nur für Nicht-Follow-ups Quote berechnen
if (!isFollowups(state)) {
try {
const quote = calculateStandardQuote(state);
return { ...state, quote };
} catch (e) {
console.error("Error calculating quote:", e);
}
}
return state;
}
export function reducer(state, action) {
switch (action.type) {
case "HYDRATE_ALL": {
// Alle gespeicherten Daten in EINEM Durchgang wiederherstellen
const p = action.payload;
let next = { ...state };
// Produkt wiederherstellen
if (p.productKey) {
const products = listProducts();
const savedProduct = products.find((prod) => prod.key === p.productKey);
if (savedProduct) {
next.ctx = { ...next.ctx, product: savedProduct };
}
}
// Answers wiederherstellen (URL-Parameter haben Vorrang)
const answerPatch = { ...p.answers };
if (p.urlQuantity !== null) delete answerPatch.quantity;
if (p.urlFormat !== null) delete answerPatch.format;
next.answers = { ...next.answers, ...answerPatch };
// Empfängerdaten
if (Array.isArray(p.recipientRows)) {
next.recipientRows = p.recipientRows;
}
if (p.addressMode) {
next.addressMode = p.addressMode;
}
if (Array.isArray(p.freeAddressRows)) {
next.freeAddressRows = p.freeAddressRows;
}
if (p.placeholderValues && typeof p.placeholderValues === "object") {
next.placeholderValues = p.placeholderValues;
}
// Order
if (p.order && typeof p.order === "object") {
next.order = {
...next.order,
billing: p.order.billing || next.order?.billing,
shipping: p.order.shipping || next.order?.shipping,
shippingDifferent: !!p.order.shippingDifferent,
acceptedAgb: false,
acceptedPrivacy: false,
};
}
// Step wiederherstellen (nur wenn nicht im PRODUCT Step)
if (typeof p.step === "number" && p.step >= 0 && p.currentStep !== STEPS.PRODUCT) {
next.step = p.step;
next.history = [];
for (let i = 0; i < p.step; i++) {
next.history.push(i);
}
}
// Quote einmal am Ende berechnen
next = recalculateQuote(next);
return next;
}
case "SET_CUSTOMER_TYPE": {
// Bleibt im PRODUCT Step, zeigt jetzt Produktauswahl an
return {
...state,
answers: { ...state.answers, customerType: action.customerType },
};
}
case "SET_PRODUCT": {
let next = {
...state,
ctx: { ...state.ctx, product: action.product },
};
// Format zurücksetzen wenn nicht unterstützt
if (
next.answers.format &&
!productSupportsFormat(next, next.answers.format)
) {
next.answers = { ...next.answers, format: null };
}
// Follow-ups: Automatisch Direktversand setzen
if (action.product?.key === "follow-ups") {
next.answers = { ...next.answers, shippingMode: "direct" };
}
return next;
}
case "RESTORE_PRODUCT": {
// Wie SET_PRODUCT, aber ohne Step zu ändern (für localStorage-Hydration)
let next = {
...state,
ctx: { ...state.ctx, product: action.product },
};
// Format zurücksetzen wenn nicht unterstützt
if (
next.answers.format &&
!productSupportsFormat(next, next.answers.format)
) {
next.answers = { ...next.answers, format: null };
}
// Follow-ups: Automatisch Direktversand setzen
if (action.product?.key === "follow-ups") {
next.answers = { ...next.answers, shippingMode: "direct" };
}
return next;
}
case "SET_STEP": {
return { ...state, step: action.step };
}
case "ANSWER": {
let next = { ...state, answers: { ...state.answers, ...action.patch } };
// WICHTIG: Preview Cache löschen wenn envelopeMode geändert wird
if (action.patch && "envelopeMode" in action.patch) {
console.log('[State] envelopeMode changed, clearing envelope previews');
if (window.envelopePreviewManager) {
window.envelopePreviewManager.currentBatchPreviews = [];
window.envelopePreviewManager.currentDocIndex = 0;
}
}
// KEIN automatisches Preview-Löschen mehr für andere Änderungen
// Benutzer muss auf "Vorschau generieren" klicken um neu zu laden
// (verwendet dann 1 Request)
// Auto-Logik für Versand -> Umschlag
if (action.patch && "shippingMode" in action.patch) {
if (action.patch.shippingMode === "direct") {
next.answers = {
...next.answers,
envelope: true,
envelopeMode: "recipientData",
};
// Bei Einzelversand: Leere Länder-Felder auf "Deutschland" setzen (Default)
if (Array.isArray(next.recipientRows)) {
next.recipientRows = next.recipientRows.map(row => ({
...row,
// Nur setzen wenn Feld leer ist
country: row.country && row.country.trim() !== "" ? row.country : "Deutschland"
}));
}
}
}
// Auto-Logik für Kundentyp-Wechsel: Standardmenge anpassen NUR wenn noch nicht gesetzt
if (action.patch && "customerType" in action.patch) {
// Nur setzen wenn Menge noch nicht vom Benutzer geändert wurde
// (d.h. wenn sie noch dem alten Default entspricht oder nicht gesetzt ist)
const currentQty = next.answers.quantity;
const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {};
// Alte Default-Werte berechnen
const wasB2B = state.answers.customerType === "business";
const oldDefaultQty = wasB2B
? (dynamicPricing.business_normal_quantity || 200)
: (dynamicPricing.private_normal_quantity || 50);
// Nur überschreiben wenn Menge noch dem alten Default entspricht oder nicht gesetzt ist
if (!currentQty || currentQty === oldDefaultQty || currentQty === 1) {
const isB2B = action.patch.customerType === "business";
const newDefaultQuantity = isB2B
? (dynamicPricing.business_normal_quantity || 200)
: (dynamicPricing.private_normal_quantity || 50);
next.answers = { ...next.answers, quantity: newDefaultQuantity };
}
}
// Auto-Logik für textservice
if (action.patch && "contentCreateMode" in action.patch) {
if (action.patch.contentCreateMode === "textservice") {
next.answers = { ...next.answers, serviceText: true };
} else {
next.answers = { ...next.answers, serviceText: false };
}
}
// Auto-Logik für Motiv + Designservice
if (action.patch && "motifSource" in action.patch) {
if (action.patch.motifSource === "design") {
next.answers = { ...next.answers, serviceDesign: true };
}
}
// Auto-Logik für motifNeed: Wenn kein Motiv gewählt, motifSource zurücksetzen
if (action.patch && "motifNeed" in action.patch) {
if (action.patch.motifNeed === false) {
next.answers = { ...next.answers, motifSource: null, serviceDesign: false };
}
}
// Auto-Logik für Format-Wechsel: Bei A4 nur "printed" erlaubt
if (action.patch && "format" in action.patch) {
if (action.patch.format === "a4") {
// Wenn upload oder design ausgewählt war, auf printed umstellen
if (next.answers.motifSource === "upload" || next.answers.motifSource === "design") {
next.answers = { ...next.answers, motifSource: "printed" };
}
}
}
next = syncPlaceholders(next);
next = ensurePlaceholderArrays(next);
// Quote nur neu berechnen wenn preisrelevante Änderungen vorliegen
if (hasPriceRelevantChanges(action.patch)) {
next = recalculateQuote(next);
}
return next;
}
case "SET_RECIPIENT_ROWS": {
// Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat
let next = { ...state, recipientRows: action.rows };
if (state.answers?.shippingMode === "direct") {
const oldDist = countCountryDistribution(state.recipientRows, state.addressMode);
const newDist = countCountryDistribution(action.rows, state.addressMode);
if (oldDist.domestic !== newDist.domestic || oldDist.international !== newDist.international) {
next = recalculateQuote(next);
}
}
return next;
}
case "SET_ADDRESS_MODE": {
// Quote neu berechnen da Adressmodus die Länder-Erkennung beeinflusst
let next = { ...state, addressMode: action.mode };
if (state.answers?.shippingMode === "direct") {
next = recalculateQuote(next);
}
return next;
}
case "SET_FREE_ADDRESS_ROWS": {
// Quote nur neu berechnen wenn sich Inland/Ausland-Verteilung geändert hat
let next = { ...state, freeAddressRows: action.rows };
if (state.answers?.shippingMode === "direct") {
const oldDist = countCountryDistribution(state.freeAddressRows, 'free');
const newDist = countCountryDistribution(action.rows, 'free');
if (oldDist.domestic !== newDist.domestic || oldDist.international !== newDist.international) {
next = recalculateQuote(next);
}
}
return next;
}
case "SET_PLACEHOLDER_VALUE": {
const name = normalizePlaceholderName(action.name);
const row = Number(action.row);
const value = String(action.value ?? "");
const nextValues = { ...state.placeholderValues };
const arr = Array.isArray(nextValues[name]) ? [...nextValues[name]] : [];
const rows = requiredRowCount(state);
while (arr.length < rows) arr.push("");
if (row >= 0 && row < rows) arr[row] = value;
nextValues[name] = arr;
return { ...state, placeholderValues: nextValues };
}
case "SET_PLACEHOLDER_VALUES": {
// Komplettes Platzhalter-Objekt setzen (analog zu SET_RECIPIENT_ROWS)
return { ...state, placeholderValues: action.values || {} };
}
case "SET_ORDER": {
const nextState = { ...state, order: { ...state.order, ...action.patch } };
// Quote neu berechnen wenn Gutschein geändert wurde
return recalculateQuote(nextState);
}
case "SET_ORDER_BILLING": {
return {
...state,
order: {
...state.order,
billing: { ...state.order.billing, ...action.patch },
},
};
}
case "SET_ORDER_SHIPPING": {
return {
...state,
order: {
...state.order,
shipping: { ...state.order.shipping, ...action.patch },
},
};
}
case "NAV_NEXT": {
const ok = validateStep(state);
if (!ok) return state;
const nextStep = Math.min(STEPS.REVIEW, state.step + 1);
// Nach oben scrollen
window.scrollTo({ top: 0, behavior: 'smooth' });
return {
...state,
history: [...state.history, state.step],
step: nextStep,
};
}
case "NAV_PREV": {
const hist = state.history || [];
if (hist.length === 0) return state;
const prev = hist[hist.length - 1];
// Nach oben scrollen
window.scrollTo({ top: 0, behavior: 'smooth' });
return { ...state, step: prev, history: hist.slice(0, -1) };
}
default:
return state;
}
}
export function listProducts() {
return Object.values(PRODUCT_BY_PARAM);
}