diff --git a/Docker Backend/src/api/controllers/order-controller.js b/Docker Backend/src/api/controllers/order-controller.js index 5501eaf..671422a 100644 --- a/Docker Backend/src/api/controllers/order-controller.js +++ b/Docker Backend/src/api/controllers/order-controller.js @@ -23,9 +23,6 @@ async function finalizeOrder(req, res, next) { } console.log(`[Order] Finalizing order: ${orderNumber} from session: ${sessionId}`); - if (envelopes && envelopes.length > 0) { - console.log(`[Order] Envelope data provided: ${envelopes.length} envelopes`); - } // Check if session cache exists const sessionDir = path.join(config.paths.previews, sessionId); @@ -97,6 +94,7 @@ async function finalizeOrder(req, res, next) { } // Create order metadata + const meta = req.body.metadata || {}; const orderMetadata = { orderNumber, sessionId, @@ -105,7 +103,10 @@ async function finalizeOrder(req, res, next) { envelopeCount: copiedEnvelopes.length, letters: copiedLetters, envelopes: copiedEnvelopes, - other: copiedOther + other: copiedOther, + letterFormat: meta.letterFormat || null, + envelopeFormat: meta.envelopeFormat || null, + envelopeBeschriftung: meta.envelopeBeschriftung || null, }; const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); @@ -317,6 +318,7 @@ async function generateOrder(req, res, next) { } // Step 6: Create order metadata + const meta = req.body.metadata || {}; const orderMetadata = { orderNumber, generatedAt: new Date().toISOString(), @@ -326,7 +328,10 @@ async function generateOrder(req, res, next) { envelopes: generatedEnvelopes, fonts: Object.keys(lettersByFont), hasPlaceholders, - csvFiles + csvFiles, + letterFormat: meta.letterFormat || null, + envelopeFormat: meta.envelopeFormat || null, + envelopeBeschriftung: meta.envelopeBeschriftung || null, }; const orderMetadataPath = path.join(outputDir, 'order-metadata.json'); diff --git a/Docker Backend/src/lib/page-layout.js b/Docker Backend/src/lib/page-layout.js index fe36106..ca19801 100644 --- a/Docker Backend/src/lib/page-layout.js +++ b/Docker Backend/src/lib/page-layout.js @@ -5,7 +5,7 @@ const FONT_SIZE_PX = 26; const LINE_LIMITS = { a4: 27, a6p: 14, - a6l: 9, + a6l: 11, }; // Layout Konfiguration pro Format + Orientation @@ -19,15 +19,16 @@ const FORMAT_LAYOUTS = { // A6 Hochformat a6p: { - marginTopMm: 12, - marginLeftMm: 10, + marginTopMm: 8, + marginLeftMm: 8, lineHeightFactor: 1.3, }, // A6 Querformat (landscape) a6l: { - marginTopMm: 10, + marginTopMm: 8, marginLeftMm: 8, + marginRightMm: 14, lineHeightFactor: 1.2, }, @@ -77,6 +78,7 @@ function getLayoutForFormat(format) { return { marginTopPx: mmToPx(layoutConfig.marginTopMm), marginLeftPx: mmToPx(layoutConfig.marginLeftMm), + marginRightPx: layoutConfig.marginRightMm ? mmToPx(layoutConfig.marginRightMm) : null, lineHeightPx: FONT_SIZE_PX * layoutConfig.lineHeightFactor, }; } diff --git a/Docker Backend/src/lib/svg-generator.js b/Docker Backend/src/lib/svg-generator.js index eeb653d..66f7d6b 100644 --- a/Docker Backend/src/lib/svg-generator.js +++ b/Docker Backend/src/lib/svg-generator.js @@ -37,10 +37,10 @@ function generateLetterSVG(text, format, options = {}) { const fontConfig = SVG_FONT_CONFIG[fontKey]; const { widthPx, heightPx, widthMm, heightMm } = getPageDimensions(format); - const { marginTopPx, marginLeftPx, lineHeightPx } = getLayoutForFormat(format); + const { marginTopPx, marginLeftPx, marginRightPx, lineHeightPx } = getLayoutForFormat(format); // Automatischer Zeilenumbruch - const maxWidthPx = widthPx - 2 * marginLeftPx; + const maxWidthPx = widthPx - marginLeftPx - (marginRightPx ?? marginLeftPx); const lines = wrapTextToLinesByWidth({ text: String(text || ''), diff --git a/skrift-configurator/assets/js/configurator-app.js b/skrift-configurator/assets/js/configurator-app.js index 6ef3ee4..205514f 100644 --- a/skrift-configurator/assets/js/configurator-app.js +++ b/skrift-configurator/assets/js/configurator-app.js @@ -4,28 +4,35 @@ import { deriveContextFromUrl, reducer, STEPS, -} from "./configurator-state.js?ver=0.3.0"; -import { render, showValidationOverlay, hideValidationOverlay, showOverflowWarning, showValidationError, flushAllTables } from "./configurator-ui.js?ver=0.3.0"; -import './configurator-api.js'; // Backend API initialisieren -import PreviewManager from './configurator-preview-manager.js'; // Preview Management +} from "./configurator-state.js?ver=0.3.1"; +import { + render, + showValidationOverlay, + hideValidationOverlay, + showOverflowWarning, + showValidationError, + flushAllTables, +} from "./configurator-ui.js?ver=0.3.1"; +import "./configurator-api.js"; // Backend API initialisieren +import PreviewManager from "./configurator-preview-manager.js"; // Preview Management (function boot() { const root = document.querySelector('[data-skrift-konfigurator="1"]'); if (!root) return; // Guard: Verhindere doppelte Initialisierung - if (root.dataset.skriftInitialized === '1') { - console.warn('[Konfigurator] Bereits initialisiert, überspringe.'); + if (root.dataset.skriftInitialized === "1") { + console.warn("[Konfigurator] Bereits initialisiert, überspringe."); return; } - root.dataset.skriftInitialized = '1'; + root.dataset.skriftInitialized = "1"; // Cleanup bei Navigation (SPA) oder Seiten-Unload const cleanupHandlers = []; const cleanup = () => { flushAllTables(); // Tabellen-Daten speichern vor Cleanup/Seiten-Unload - cleanupHandlers.forEach(handler => handler()); + cleanupHandlers.forEach((handler) => handler()); cleanupHandlers.length = 0; if (window.envelopePreviewManager) { window.envelopePreviewManager.destroy(); @@ -36,8 +43,10 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag }; // Cleanup bei Seiten-Unload - window.addEventListener('beforeunload', cleanup); - cleanupHandlers.push(() => window.removeEventListener('beforeunload', cleanup)); + window.addEventListener("beforeunload", cleanup); + cleanupHandlers.push(() => + window.removeEventListener("beforeunload", cleanup), + ); const ctx = deriveContextFromUrl(window.location.search); let state = createInitialState(ctx); @@ -70,7 +79,7 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag // Scroll-Position NACH dem Render wiederherstellen // Nur bei Actions die NICHT den Step wechseln (Navigation) - const navigationActions = ['NAV_NEXT', 'NAV_PREV', 'SET_STEP']; + const navigationActions = ["NAV_NEXT", "NAV_PREV", "SET_STEP"]; if (!navigationActions.includes(action.type)) { // requestAnimationFrame stellt sicher, dass das DOM komplett gerendert ist requestAnimationFrame(() => { @@ -89,6 +98,21 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag const nextClickHandler = async () => { flushAllTables(); // Tabellen-Daten speichern vor Navigation + // WICHTIG: Globalen State in den Store synchronisieren + // Nötig weil Paste direkt in window.currentGlobalState schreibt (Performance) + const globalState = window.currentGlobalState; + if (globalState) { + // Alle Tabellen-Daten in einem einzigen Dispatch synchronisieren + dispatch({ + type: "SYNC_GLOBAL_STATE", + payload: { + recipientRows: globalState.recipientRows, + freeAddressRows: globalState.freeAddressRows, + placeholderValues: globalState.placeholderValues, + }, + }); + } + // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure const currentState = window.currentGlobalState || state; @@ -98,12 +122,16 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag if (previewManager) { showValidationOverlay(); try { - const validation = await previewManager.validateTextLength(currentState); + const validation = + await previewManager.validateTextLength(currentState); if (!validation.valid) { hideValidationOverlay(); // Bei Overflow: Warnung anzeigen - if (validation.overflowFiles && validation.overflowFiles.length > 0) { + if ( + validation.overflowFiles && + validation.overflowFiles.length > 0 + ) { showOverflowWarning(validation.overflowFiles, dom.form); } else if (validation.error) { // Bei Fehler (z.B. keine Anfragen mehr): Fehlermeldung anzeigen @@ -117,11 +145,15 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag const envelopeManager = window.envelopePreviewManager; if (envelopeManager && currentState.answers?.envelope === true) { try { - envelopeManager.previewCount = parseInt(currentState.answers?.quantity) || 1; + envelopeManager.previewCount = + parseInt(currentState.answers?.quantity) || 1; await envelopeManager.loadAllPreviews(currentState, true, true); - console.log('[App] Envelope previews generated for cache'); + console.log("[App] Envelope previews generated for cache"); } catch (envError) { - console.error('[App] Envelope preview generation failed:', envError); + console.error( + "[App] Envelope preview generation failed:", + envError, + ); // Nicht blockieren - Umschläge sind nicht kritisch für Navigation } } @@ -129,8 +161,11 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag hideValidationOverlay(); } catch (error) { hideValidationOverlay(); - console.error('[App] Validation error:', error); - showValidationError(error.message || 'Validierung fehlgeschlagen', dom.form); + console.error("[App] Validation error:", error); + showValidationError( + error.message || "Validierung fehlgeschlagen", + dom.form, + ); return; // Nicht weiter navigieren } } @@ -147,21 +182,39 @@ import PreviewManager from './configurator-preview-manager.js'; // Preview Manag // Keyboard Navigation für Previews (nur innerhalb des aktuellen Batches) const keydownHandler = (e) => { - if (window.envelopePreviewManager && window.envelopePreviewManager.currentBatchPreviews.length > 0) { - window.envelopePreviewManager.handleKeyboardNavigation(e, state, dom.preview, true); + if ( + window.envelopePreviewManager && + window.envelopePreviewManager.currentBatchPreviews.length > 0 + ) { + window.envelopePreviewManager.handleKeyboardNavigation( + e, + state, + dom.preview, + true, + ); } - if (window.contentPreviewManager && window.contentPreviewManager.currentBatchPreviews.length > 0) { - window.contentPreviewManager.handleKeyboardNavigation(e, state, dom.preview, false); + if ( + window.contentPreviewManager && + window.contentPreviewManager.currentBatchPreviews.length > 0 + ) { + window.contentPreviewManager.handleKeyboardNavigation( + e, + state, + dom.preview, + false, + ); } }; - document.addEventListener('keydown', keydownHandler); - cleanupHandlers.push(() => document.removeEventListener('keydown', keydownHandler)); + document.addEventListener("keydown", keydownHandler); + cleanupHandlers.push(() => + document.removeEventListener("keydown", keydownHandler), + ); render({ state, dom, dispatch }); // Konfigurator sichtbar machen nachdem erster Render abgeschlossen ist requestAnimationFrame(() => { - root.classList.add('sk-ready'); + root.classList.add("sk-ready"); }); })(); diff --git a/skrift-configurator/assets/js/configurator-state.js b/skrift-configurator/assets/js/configurator-state.js index d986f20..ec40764 100644 --- a/skrift-configurator/assets/js/configurator-state.js +++ b/skrift-configurator/assets/js/configurator-state.js @@ -49,8 +49,11 @@ function getProductDefinitions() { products[key] = { ...baseConfig, label: backendSettings.label || key, - description: backendSettings.description || 'Professionelle handgeschriebene Korrespondenz', - basePrice: parseFloat(backendSettings.base_price) || 2.50, + description: + backendSettings.description || + "Professionelle handgeschriebene Korrespondenz", + basePrice: parseFloat(backendSettings.base_price) || 2.5, + imageUrl: backendSettings.image_url || "", }; } @@ -86,21 +89,22 @@ export function deriveContextFromUrl(search) { } // Quantity aus URL - const quantityParam = q.get('quantity'); + 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(); + 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 + 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'); + const noPrice = q.has("noPrice") || q.has("noprice"); // noLimits Parameter (keine Mindestmengen) - const noLimits = q.has('noLimits') || q.has('nolimits'); + const noLimits = q.has("noLimits") || q.has("nolimits"); return { urlParam: urlParam || null, @@ -162,11 +166,14 @@ export function createInitialState(ctx) { } // Standardmenge auf beste Preismenge (normalQuantity) setzen, außer URL-Parameter - const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {}; + 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)); + const defaultQuantity = + urlQuantity || + (isB2B + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50); // Format aus URL oder null const initialFormat = urlFormat || null; @@ -217,6 +224,8 @@ export function createInitialState(ctx) { // Inhalt contentCreateMode: null, // 'self' | 'textservice' letterText: "", + font: "tilda", + envelopeFont: "tilda", // Motiv motifNeed: null, @@ -232,7 +241,7 @@ export function createInitialState(ctx) { recipientRows: [], // Adressmodus: 'classic' (Name, Anschrift) oder 'free' (5 freie Zeilen) - addressMode: 'classic', + addressMode: "classic", // Freie Adresszeilen (separat gespeichert, damit beim Wechsel nichts verloren geht) freeAddressRows: [], placeholders: { @@ -314,9 +323,8 @@ export function calcEffectiveEnvelopeType(state) { } export function getAvailableProductsForCustomerType(customerType) { - return Object.values(PRODUCT_BY_PARAM).filter( - (p) => p.category === customerType - ); + const fresh = getProductDefinitions(); + return Object.values(fresh).filter((p) => p.category === customerType); } export function syncPlaceholders(state) { @@ -406,109 +414,17 @@ export function validateStep(state) { // 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 { + if (state.answers.shippingMode !== "direct") { // 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; @@ -520,31 +436,6 @@ export function validateStep(state) { // 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 @@ -594,7 +485,14 @@ export function validateStep(state) { 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"]; + const shippingReq = [ + "firstName", + "lastName", + "street", + "zip", + "city", + "country", + ]; for (const k of shippingReq) { if (!String(sh[k] || "").trim()) return false; } @@ -614,17 +512,17 @@ export function validateStep(state) { // Preisrelevante Felder die eine Neuberechnung auslösen const PRICE_RELEVANT_FIELDS = new Set([ - 'quantity', - 'format', - 'shippingMode', - 'envelope', - 'envelopeMode', - 'motifSource', - 'motifNeed', - 'contentCreateMode', - 'followupYearlyVolume', - 'followupCreateMode', - 'customerType', + "quantity", + "format", + "shippingMode", + "envelope", + "envelopeMode", + "motifSource", + "motifNeed", + "contentCreateMode", + "followupYearlyVolume", + "followupCreateMode", + "customerType", ]); /** @@ -632,7 +530,7 @@ const PRICE_RELEVANT_FIELDS = new Set([ */ function hasPriceRelevantChanges(patch) { if (!patch) return false; - return Object.keys(patch).some(key => PRICE_RELEVANT_FIELDS.has(key)); + return Object.keys(patch).some((key) => PRICE_RELEVANT_FIELDS.has(key)); } /** @@ -647,19 +545,20 @@ function countCountryDistribution(rows, addressMode) { for (const row of rows) { if (!row) continue; - let country = ''; - if (addressMode === 'free') { - country = row.line5 || ''; + let country = ""; + if (addressMode === "free") { + country = row.line5 || ""; } else { - country = row.country || ''; + country = row.country || ""; } const countryLower = country.toLowerCase().trim(); - const isDomestic = !countryLower || - countryLower === 'deutschland' || - countryLower === 'germany' || - countryLower === 'de' || - countryLower === 'ger'; + const isDomestic = + !countryLower || + countryLower === "deutschland" || + countryLower === "germany" || + countryLower === "de" || + countryLower === "ger"; if (isDomestic) { domestic++; @@ -710,6 +609,11 @@ export function reducer(state, action) { if (p.urlFormat !== null) delete answerPatch.format; next.answers = { ...next.answers, ...answerPatch }; + // Auto-Logik: Bei Direktversand immer envelopeMode sicherstellen + if (next.answers.shippingMode === "direct" && !next.answers.envelopeMode) { + next.answers = { ...next.answers, envelope: true, envelopeMode: "recipientData" }; + } + // Empfängerdaten if (Array.isArray(p.recipientRows)) { next.recipientRows = p.recipientRows; @@ -737,7 +641,11 @@ export function reducer(state, action) { } // Step wiederherstellen (nur wenn nicht im PRODUCT Step) - if (typeof p.step === "number" && p.step >= 0 && p.currentStep !== STEPS.PRODUCT) { + 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++) { @@ -745,6 +653,12 @@ export function reducer(state, action) { } } + // Wenn nur ein Format verfügbar und keins gespeichert: automatisch setzen + const hydrateFormats = next.ctx?.product?.formats || []; + if (hydrateFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: hydrateFormats[0] }; + } + // Quote einmal am Ende berechnen next = recalculateQuote(next); @@ -773,6 +687,11 @@ export function reducer(state, action) { next.answers = { ...next.answers, format: null }; } + // Wenn nur ein Format verfügbar: automatisch setzen + const availFormats = action.product?.formats || []; + if (availFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: availFormats[0] }; + } // Follow-ups: Automatisch Direktversand setzen if (action.product?.key === "follow-ups") { next.answers = { ...next.answers, shippingMode: "direct" }; @@ -796,6 +715,11 @@ export function reducer(state, action) { next.answers = { ...next.answers, format: null }; } + // Wenn nur ein Format verfügbar: automatisch setzen + const availFormats = action.product?.formats || []; + if (availFormats.length === 1 && !next.answers.format) { + next.answers = { ...next.answers, format: availFormats[0] }; + } // Follow-ups: Automatisch Direktversand setzen if (action.product?.key === "follow-ups") { next.answers = { ...next.answers, shippingMode: "direct" }; @@ -813,7 +737,7 @@ export function reducer(state, action) { // 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'); + console.log("[State] envelopeMode changed, clearing envelope previews"); if (window.envelopePreviewManager) { window.envelopePreviewManager.currentBatchPreviews = []; window.envelopePreviewManager.currentDocIndex = 0; @@ -834,10 +758,13 @@ export function reducer(state, action) { }; // Bei Einzelversand: Leere Länder-Felder auf "Deutschland" setzen (Default) if (Array.isArray(next.recipientRows)) { - next.recipientRows = next.recipientRows.map(row => ({ + next.recipientRows = next.recipientRows.map((row) => ({ ...row, // Nur setzen wenn Feld leer ist - country: row.country && row.country.trim() !== "" ? row.country : "Deutschland" + country: + row.country && row.country.trim() !== "" + ? row.country + : "Deutschland", })); } } @@ -848,20 +775,21 @@ export function reducer(state, action) { // 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 || {}; + 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); + ? 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); + ? dynamicPricing.business_normal_quantity || 200 + : dynamicPricing.private_normal_quantity || 50; next.answers = { ...next.answers, quantity: newDefaultQuantity }; } } @@ -885,7 +813,11 @@ export function reducer(state, action) { // 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 }; + next.answers = { + ...next.answers, + motifSource: null, + serviceDesign: false, + }; } } @@ -893,7 +825,10 @@ export function reducer(state, action) { 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") { + if ( + next.answers.motifSource === "upload" || + next.answers.motifSource === "design" + ) { next.answers = { ...next.answers, motifSource: "printed" }; } } @@ -914,9 +849,18 @@ export function reducer(state, action) { // 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) { + 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); } } @@ -936,9 +880,12 @@ export function reducer(state, action) { // 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) { + const oldDist = countCountryDistribution(state.freeAddressRows, "free"); + const newDist = countCountryDistribution(action.rows, "free"); + if ( + oldDist.domestic !== newDist.domestic || + oldDist.international !== newDist.international + ) { next = recalculateQuote(next); } } @@ -967,8 +914,35 @@ export function reducer(state, action) { return { ...state, placeholderValues: action.values || {} }; } + case "SYNC_GLOBAL_STATE": { + // Synchronisiert alle direkt mutierten Daten (aus window.currentGlobalState) + // in einem einzigen Dispatch ohne mehrfaches Re-Render + const p = action.payload || {}; + let next = { ...state }; + + if (Array.isArray(p.recipientRows)) { + next.recipientRows = p.recipientRows; + } + if (Array.isArray(p.freeAddressRows)) { + next.freeAddressRows = p.freeAddressRows; + } + if (p.placeholderValues && typeof p.placeholderValues === "object") { + next.placeholderValues = p.placeholderValues; + } + + // Quote neu berechnen falls relevant + if (next.answers?.shippingMode === "direct") { + next = recalculateQuote(next); + } + + return next; + } + case "SET_ORDER": { - const nextState = { ...state, order: { ...state.order, ...action.patch } }; + const nextState = { + ...state, + order: { ...state.order, ...action.patch }, + }; // Quote neu berechnen wenn Gutschein geändert wurde return recalculateQuote(nextState); } @@ -1000,7 +974,7 @@ export function reducer(state, action) { const nextStep = Math.min(STEPS.REVIEW, state.step + 1); // Nach oben scrollen - window.scrollTo({ top: 0, behavior: 'smooth' }); + window.scrollTo({ top: 0, behavior: "smooth" }); return { ...state, @@ -1015,7 +989,7 @@ export function reducer(state, action) { const prev = hist[hist.length - 1]; // Nach oben scrollen - window.scrollTo({ top: 0, behavior: 'smooth' }); + window.scrollTo({ top: 0, behavior: "smooth" }); return { ...state, step: prev, history: hist.slice(0, -1) }; } @@ -1026,5 +1000,7 @@ export function reducer(state, action) { } export function listProducts() { - return Object.values(PRODUCT_BY_PARAM); + // Immer frisch laden damit Backend-Settings (inkl. imageUrl) verfügbar sind + const fresh = getProductDefinitions(); + return Object.values(fresh); } diff --git a/skrift-configurator/assets/js/configurator-ui.js b/skrift-configurator/assets/js/configurator-ui.js index 6a50b16..8312e0b 100644 --- a/skrift-configurator/assets/js/configurator-ui.js +++ b/skrift-configurator/assets/js/configurator-ui.js @@ -10,7 +10,7 @@ import { normalizePlaceholderName, validateStep, getAvailableProductsForCustomerType, -} from "./configurator-state.js?ver=0.3.0"; +} from "./configurator-state.js?ver=0.3.1"; import { calculatePricePerPiece, @@ -725,7 +725,16 @@ function renderTopbar(dom, state) { if (state?.ctx?.product) { productInfo.appendChild( - h("div", { class: "sk-product-icon", text: state.ctx.product.label[0] }), + state.ctx.product.imageUrl + ? h("img", { + src: state.ctx.product.imageUrl, + alt: state.ctx.product.label, + class: "sk-product-icon sk-product-icon-img", + }) + : h("div", { + class: "sk-product-icon", + text: state.ctx.product.label[0], + }), ); productInfo.appendChild( h("div", {}, [ @@ -835,7 +844,13 @@ function renderProductStep(state, dispatch) { }, [ h("div", { class: "sk-selection-card-image" }, [ - h("div", { text: "📄", style: "font-size: 48px" }), // Placeholder + p.imageUrl + ? h("img", { + src: p.imageUrl, + alt: p.label, + class: "sk-selection-card-img", + }) + : h("div", { text: "📄", style: "font-size: 48px" }), ]), h("div", { class: "sk-selection-card-content" }, [ h("div", { class: "sk-selection-card-title", text: p.label }), @@ -1059,20 +1074,6 @@ function renderQuantityStep(state, dispatch) { h("div", { class: "sk-options" }, formatBody), ]), ); - } else if (formats.length === 1) { - // Auto-set single format (nur wenn noch nicht gesetzt) - if (!state.answers.format) { - dispatch({ type: "ANSWER", patch: { format: formats[0] } }); - } - } - - // Schriftart wird jetzt in Umschlag und Inhalt Steps separat gewählt (nicht hier) - // Default auf tilda wenn noch nicht gesetzt - if (!state.answers.font) { - dispatch({ type: "ANSWER", patch: { font: "tilda" } }); - } - if (!state.answers.envelopeFont) { - dispatch({ type: "ANSWER", patch: { envelopeFont: "tilda" } }); } // Follow-ups: Erstellungsmodus @@ -2223,6 +2224,9 @@ async function handleOrderSubmit(state) { finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { customer: state.order, quote: state.quote, + letterFormat: { a4: "A4", a6p: "A6 Hochformat", a6l: "A6 Querformat" }[state.answers.format] || state.answers.format || null, + envelopeFormat: state.answers.format === "a4" ? "DIN Lang" : (state.answers.format === "a6p" || state.answers.format === "a6l") ? "C6" : null, + envelopeBeschriftung: { recipientData: "Empfängeradresse", customText: "Individueller Text", none: "Keine Beschriftung" }[state.answers.envelopeMode] || state.answers.envelopeMode || null, }); console.log("[Order] Finalize result:", finalizeResult); } catch (error) { @@ -3356,6 +3360,9 @@ async function handlePayPalSuccess(state, paypalDetails) { status: paypalDetails.status, }, quote: state.quote, + letterFormat: { a4: "A4", a6p: "A6 Hochformat", a6l: "A6 Querformat" }[state.answers.format] || state.answers.format || null, + envelopeFormat: state.answers.format === "a4" ? "DIN Lang" : (state.answers.format === "a6p" || state.answers.format === "a6l") ? "C6" : null, + envelopeBeschriftung: { recipientData: "Empfängeradresse", customText: "Individueller Text", none: "Keine Beschriftung" }[state.answers.envelopeMode] || state.answers.envelopeMode || null, }); console.log("[PayPal] Finalize result:", finalizeResult); } catch (error) { @@ -3671,6 +3678,7 @@ function createTableModal(dispatch, config) { newData.push({}); } + // Werte in lokale Daten UND direkt ins DOM schreiben (kein Re-Render) let pastedCells = 0; rows.forEach((rowText, rowOffset) => { const cells = rowText.split("\t"); @@ -3686,23 +3694,30 @@ function createTableModal(dispatch, config) { if (!col.readOnly) { if (!newData[targetRow]) newData[targetRow] = {}; newData[targetRow][col.key] = cellValue.trim(); + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-key="${col.key}"]`, + ); + if (input) input.value = cellValue.trim(); + pastedCells++; } } }); }); + // Daten direkt in globalen State schreiben (OHNE Re-Render) if (pastedCells > 0) { - config.setData(dispatch, newData); - - // Tabelle im Modal aktualisieren - renderTable(); + const globalState = window.currentGlobalState; + if (globalState && config.stateKey) { + globalState[config.stateKey] = newData; + } + markPersistDirty(); } - // Flag nach kurzem Delay zurücksetzen (für evtl. verzögerte input-Events) - setTimeout(() => { - isPasting = false; - }, 50); + // Flag zurücksetzen + isPasting = false; }); return table; @@ -3933,7 +3948,9 @@ function createTableModal(dispatch, config) { { class: "sk-modal", onclick: (e) => { - if (e.target.classList.contains("sk-modal")) modal.remove(); + if (e.target.classList.contains("sk-modal")) { + modal.remove(); + } }, }, [modalContent], @@ -4013,6 +4030,12 @@ function renderRecipientsTable(state, dispatch) { }; } currentRows[idx][key] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.recipientRows = currentRows; + } }, }), ]); @@ -4040,6 +4063,12 @@ function renderRecipientsTable(state, dispatch) { }; } currentRows[idx].country = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.recipientRows = currentRows; + } }, }), ]); @@ -4085,7 +4114,7 @@ function renderRecipientsTable(state, dispatch) { hasLocalChanges = true; }); - // Paste-Handler + // Paste-Handler - Daten direkt in globalen State schreiben (OHNE Re-Render) table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); @@ -4121,25 +4150,30 @@ function renderRecipientsTable(state, dispatch) { "city", "country", ]; - const currentRows = table._rows; - const newData = []; - for (let i = 0; i < want; i++) { - newData.push( - currentRows[i] - ? { ...currentRows[i] } - : { - firstName: "", - lastName: "", - street: "", - houseNumber: "", - zip: "", - city: "", - country: isDirectShipping ? "Deutschland" : "", - }, - ); + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Rows erstellen + const currentRows = Array.isArray(globalState.recipientRows) + ? globalState.recipientRows.map((r) => ({ ...r })) + : []; + + // Sicherstellen dass genug Zeilen existieren + while (currentRows.length < want) { + currentRows.push({ + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }); } - let pastedCells = 0; + // Werte in Daten UND direkt ins DOM schreiben pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; @@ -4149,15 +4183,35 @@ function renderRecipientsTable(state, dispatch) { const targetCol = startCol + colOffset; if (targetCol >= keys.length) return; const key = keys[targetCol]; - newData[targetRow][key] = cellValue; - pastedCells++; + + // Daten aktualisieren + if (!currentRows[targetRow]) { + currentRows[targetRow] = { + firstName: "", + lastName: "", + street: "", + houseNumber: "", + zip: "", + city: "", + country: isDirectShipping ? "Deutschland" : "", + }; + } + currentRows[targetRow][key] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${key}"]`, + ); + if (input) input.value = cellValue; }); }); - if (pastedCells > 0) { - hasLocalChanges = false; // Paste übernimmt - keine alten Änderungen mehr relevant - dispatch({ type: "SET_RECIPIENT_ROWS", rows: newData }); - } + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + globalState.recipientRows = currentRows; + table._rows = currentRows; + + // Für Persistenz markieren + markPersistDirty(); }); return table; @@ -4174,6 +4228,9 @@ function renderRecipientsTable(state, dispatch) { { class: "sk-table sk-table-pasteable", tabindex: "0" }, [], ); + // Rows-Referenz am Table speichern für Event-Handler + table._rows = rows; + table.appendChild( h("thead", {}, [ h("tr", {}, [ @@ -4200,8 +4257,9 @@ function renderRecipientsTable(state, dispatch) { "data-col": key, "data-sk-focus": `freeaddr.${key}.${idx}`, oninput: (e) => { - if (!rows[idx]) { - rows[idx] = { + const currentRows = table._rows; + if (!currentRows[idx]) { + currentRows[idx] = { line1: "", line2: "", line3: "", @@ -4209,7 +4267,13 @@ function renderRecipientsTable(state, dispatch) { line5: "", }; } - rows[idx][key] = e.target.value; + currentRows[idx][key] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + globalState.freeAddressRows = currentRows; + } }, }), ]); @@ -4231,7 +4295,7 @@ function renderRecipientsTable(state, dispatch) { let hasLocalChanges = false; const saveLocalChanges = () => { if (hasLocalChanges) { - dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: [...rows] }); + dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: [...table._rows] }); hasLocalChanges = false; } }; @@ -4253,7 +4317,7 @@ function renderRecipientsTable(state, dispatch) { hasLocalChanges = true; }); - // Paste-Handler + // Paste-Handler - Daten direkt in globalen State schreiben (OHNE Re-Render) table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); @@ -4281,16 +4345,28 @@ function renderRecipientsTable(state, dispatch) { } const keys = ["line1", "line2", "line3", "line4", "line5"]; - const newData = []; - for (let i = 0; i < want; i++) { - newData.push( - rows[i] - ? { ...rows[i] } - : { line1: "", line2: "", line3: "", line4: "", line5: "" }, - ); + + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Rows erstellen + const currentRows = Array.isArray(globalState.freeAddressRows) + ? globalState.freeAddressRows.map((r) => ({ ...r })) + : []; + + // Sicherstellen dass genug Zeilen existieren + while (currentRows.length < want) { + currentRows.push({ + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }); } - let pastedCells = 0; + // Werte in Daten UND direkt ins DOM schreiben pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; @@ -4299,15 +4375,34 @@ function renderRecipientsTable(state, dispatch) { cells.forEach((cellValue, colOffset) => { const targetCol = startCol + colOffset; if (targetCol >= keys.length) return; - newData[targetRow][keys[targetCol]] = cellValue; - pastedCells++; + const key = keys[targetCol]; + + // Daten aktualisieren + if (!currentRows[targetRow]) { + currentRows[targetRow] = { + line1: "", + line2: "", + line3: "", + line4: "", + line5: "", + }; + } + currentRows[targetRow][key] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${key}"]`, + ); + if (input) input.value = cellValue; }); }); - if (pastedCells > 0) { - hasLocalChanges = false; // Paste übernimmt - keine alten Änderungen mehr relevant - dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: newData }); - } + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) + globalState.freeAddressRows = currentRows; + table._rows = currentRows; + + // Für Persistenz markieren + markPersistDirty(); }); return table; @@ -4346,6 +4441,7 @@ function renderRecipientsTable(state, dispatch) { setData: (dispatch, data) => { dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: data }); }, + stateKey: "freeAddressRows", rowCount: () => want, exportFilename: "freie_adressen.csv", helpText: "Geben Sie bis zu 5 Zeilen pro Empfänger ein.", @@ -4390,6 +4486,7 @@ function renderRecipientsTable(state, dispatch) { setData: (dispatch, data) => { dispatch({ type: "SET_RECIPIENT_ROWS", rows: data }); }, + stateKey: "recipientRows", rowCount: () => want, exportFilename: "empfaenger.csv", helpText: @@ -4585,6 +4682,22 @@ function renderCombinedPlaceholderTable( // Nur lokal speichern - kein Re-Render! localValues[name][row] = e.target.value; hasLocalChanges = true; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + if (!globalState.placeholderValues) { + globalState.placeholderValues = {}; + } + if (!globalState.placeholderValues[name]) { + globalState.placeholderValues[name] = []; + } + globalState.placeholderValues[name][row] = e.target.value; + // Array auf requiredRowCount-Länge bringen (für validateStep) + while (globalState.placeholderValues[name].length < want) { + globalState.placeholderValues[name].push(""); + } + } } }, }), @@ -4624,7 +4737,16 @@ function renderCombinedPlaceholderTable( } } - // Alle Änderungen in localValues sammeln und Inputs aktualisieren + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Platzhalter-Werte erstellen + const currentValues = globalState.placeholderValues + ? JSON.parse(JSON.stringify(globalState.placeholderValues)) + : {}; + + // Alle Änderungen in currentValues sammeln und Inputs aktualisieren let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; @@ -4640,7 +4762,11 @@ function renderCombinedPlaceholderTable( // Nur editierbare Spalten aktualisieren if (!isReadOnly) { + // Daten aktualisieren + if (!currentValues[name]) currentValues[name] = []; + currentValues[name][targetRow] = cellValue; localValues[name][targetRow] = cellValue; + // Input-Element direkt aktualisieren const input = table.querySelector( `input[data-row="${targetRow}"][data-col="${name}"]`, @@ -4651,10 +4777,15 @@ function renderCombinedPlaceholderTable( }); }); - // Nach Paste: Einmal speichern + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) if (pastedCells > 0) { - hasLocalChanges = true; - saveLocalChanges(); + // Arrays auf want-Länge normalisieren (für validateStep) + for (const name of Object.keys(currentValues)) { + while (currentValues[name].length < want) + currentValues[name].push(""); + } + globalState.placeholderValues = currentValues; + markPersistDirty(); } }); @@ -4827,6 +4958,22 @@ function renderPlaceholderTable( oninput: (e) => { // Wert nur lokal speichern (kein Re-Render) localValues[name][row] = e.target.value; + + // Auch globalen State aktualisieren für SYNC_GLOBAL_STATE + const globalState = window.currentGlobalState; + if (globalState) { + if (!globalState.placeholderValues) { + globalState.placeholderValues = {}; + } + if (!globalState.placeholderValues[name]) { + globalState.placeholderValues[name] = []; + } + globalState.placeholderValues[name][row] = e.target.value; + // Array auf requiredRowCount-Länge bringen (für validateStep) + while (globalState.placeholderValues[name].length < want) { + globalState.placeholderValues[name].push(""); + } + } }, }), ]), @@ -4890,7 +5037,16 @@ function renderPlaceholderTable( } } - // Alle Änderungen in localValues sammeln + // Aktuellen globalen State holen + const globalState = window.currentGlobalState; + if (!globalState) return; + + // Kopie der aktuellen Platzhalter-Werte erstellen + const currentValues = globalState.placeholderValues + ? JSON.parse(JSON.stringify(globalState.placeholderValues)) + : {}; + + // Alle Änderungen sammeln UND direkt ins DOM schreiben let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; @@ -4902,15 +5058,31 @@ function renderPlaceholderTable( if (targetCol >= clean.length) return; const name = clean[targetCol]; + + // Daten aktualisieren + if (!currentValues[name]) currentValues[name] = []; + currentValues[name][targetRow] = cellValue; localValues[name][targetRow] = cellValue; + + // DOM direkt aktualisieren (kein Re-Render nötig) + const input = table.querySelector( + `input[data-row="${targetRow}"][data-col="${name}"]`, + ); + if (input) input.value = cellValue; + pastedCells++; }); }); - // Einmal dispatch für alle Änderungen + // Globalen State DIREKT aktualisieren (OHNE dispatch/render) if (pastedCells > 0) { - hasLocalChanges = false; // Paste übernimmt - keine alten Änderungen mehr relevant - dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); + // Arrays auf want-Länge normalisieren (für validateStep) + for (const name of Object.keys(currentValues)) { + while (currentValues[name].length < want) + currentValues[name].push(""); + } + globalState.placeholderValues = currentValues; + markPersistDirty(); } });