// 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); }