1031 lines
30 KiB
JavaScript
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);
|
|
}
|