import { STEPS, listProducts, isFollowups, isInvitation, isPostcardLike, supportsMotif, calcEffectiveEnvelopeType, extractPlaceholders, normalizePlaceholderName, validateStep, getAvailableProductsForCustomerType, } from "./configurator-state.js?ver=0.3.0"; import { calculatePricePerPiece, calculateFollowupBasePricePerPiece, formatPrice, calculateFollowupPricing, calculateDirectShippingPrices, formatEUR as fmtEUR, } from "./configurator-pricing.js"; import BackendIntegration from "./configurator-backend-integration.js"; /* Persistenz */ const LS_KEY = "skrift.configurator.v2"; let hydratedForProductKey = null; // Globaler State-Tracker für Modals let currentGlobalState = null; // Registry für Tabellen-Save-Funktionen (für flush vor Navigation) const tableSaveRegistry = new Set(); /** * Registriert eine Save-Funktion für eine Tabelle * @param {Function} saveFn - Funktion zum Speichern der lokalen Änderungen * @returns {Function} - Unregister-Funktion */ function registerTableSave(saveFn) { tableSaveRegistry.add(saveFn); return () => tableSaveRegistry.delete(saveFn); } /** * Speichert alle registrierten Tabellen (lokale Änderungen -> State) * Wird vor Navigation und bei Page Leave aufgerufen */ export function flushAllTables() { tableSaveRegistry.forEach((saveFn) => { try { saveFn(); } catch (e) { console.warn("[UI] Error flushing table:", e); } }); } // Performante Persistenz: Nur periodisch + bei Page Leave let persistDirty = false; let persistInterval = null; const PERSIST_INTERVAL_MS = 60000; // 1 Minute function markPersistDirty() { persistDirty = true; } function flushPersistIfDirty() { if (persistDirty && currentGlobalState) { writePersistedNow(currentGlobalState); persistDirty = false; } } function startPersistInterval() { if (persistInterval) return; persistInterval = setInterval(flushPersistIfDirty, PERSIST_INTERVAL_MS); // Page Leave Detection: beforeunload, visibilitychange, pagehide window.addEventListener("beforeunload", flushPersistIfDirty); window.addEventListener("pagehide", flushPersistIfDirty); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { flushPersistIfDirty(); } }); } function stopPersistInterval() { if (persistInterval) { clearInterval(persistInterval); persistInterval = null; } } // ========== Validierungs-Overlay und Overflow-Warnung ========== /** * Zeigt Lade-Overlay während der Textvalidierung */ export function showValidationOverlay() { // Entferne vorhandenes Overlay hideValidationOverlay(); const overlay = document.createElement("div"); overlay.id = "sk-validation-overlay"; overlay.className = "sk-validation-overlay"; overlay.innerHTML = `
Text wird geprüft...
`; document.body.appendChild(overlay); } /** * Versteckt das Lade-Overlay */ export function hideValidationOverlay() { const overlay = document.getElementById("sk-validation-overlay"); if (overlay) { overlay.remove(); } } /** * Zeigt Overflow-Warnung mit betroffenen Briefen * @param {Array} overflowFiles - Array von { index, lineCount, lineLimit } * @param {HTMLElement} container - Container für die Warnung */ export function showOverflowWarning(overflowFiles, container) { // Entferne vorherige Warnung hideOverflowWarning(container); if (!overflowFiles || overflowFiles.length === 0) return; const warning = document.createElement("div"); warning.className = "sk-overflow-warning"; warning.id = "sk-overflow-warning"; // Max 10 Fehler anzeigen const maxDisplay = 10; const displayFiles = overflowFiles.slice(0, maxDisplay); const remaining = overflowFiles.length - maxDisplay; const itemsHtml = displayFiles .map((f) => { // Zeige Tabellenzeile (index + 1 für 1-basierte Anzeige) const rowNumber = f.index + 1; return `
  • Empfänger in Tabellenzeile ${rowNumber} ${f.lineCount} / ${f.lineLimit} Zeilen
  • `; }) .join(""); // "und X weitere" Hinweis const remainingHtml = remaining > 0 ? `
  • ... und ${remaining} weitere Empfänger
  • ` : ""; warning.innerHTML = `
    Text zu lang für das gewählte Format

    Bitte kürzen Sie den Text oder reduzieren Sie die Zeilenanzahl, um fortzufahren. Die Zeilennummern beziehen sich auf die Empfänger-/Platzhaltertabelle.

    `; // Am Anfang des Containers einfügen container.insertBefore(warning, container.firstChild); // Scroll zur Warnung warning.scrollIntoView({ behavior: "smooth", block: "start" }); } /** * Versteckt die Overflow-Warnung */ export function hideOverflowWarning(container) { const warning = container?.querySelector("#sk-overflow-warning"); if (warning) { warning.remove(); } } /** * Zeigt eine Validierungs-Fehlermeldung an * @param {string} message - Fehlermeldung * @param {HTMLElement} container - Container für die Meldung */ export function showValidationError(message, container) { // Entferne vorherige Fehlermeldungen hideValidationError(container); hideOverflowWarning(container); const errorDiv = document.createElement("div"); errorDiv.className = "sk-validation-error"; errorDiv.id = "sk-validation-error"; errorDiv.innerHTML = `
    Validierung nicht möglich

    ${escapeHtml(message)}

    Bitte laden Sie die Seite neu und versuchen Sie es erneut.

    `; // Am Anfang des Containers einfügen container.insertBefore(errorDiv, container.firstChild); // Scroll zur Meldung errorDiv.scrollIntoView({ behavior: "smooth", block: "start" }); } /** * Versteckt die Validierungs-Fehlermeldung */ export function hideValidationError(container) { const error = container?.querySelector("#sk-validation-error"); if (error) { error.remove(); } } /** * HTML escaping für sichere Ausgabe */ function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } // Preview-Generierung Funktionen // Alte Preview-Funktionen entfernt - jetzt wird PreviewManager verwendet // Preview-Funktionen verwenden jetzt PreviewManager function readPersisted() { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return null; const data = JSON.parse(raw); if (!data || typeof data !== "object") return null; return data; } catch { return null; } } // Sofortige Speicherung (intern verwendet) function writePersistedNow(state) { try { const productKey = state?.ctx?.product?.key || null; // Auch ohne Produktauswahl speichern, wenn customerType gesetzt ist if (!productKey && !state.answers.customerType) return; // Order-Daten OHNE AGB/Datenschutz speichern (müssen immer neu bestätigt werden) const orderWithoutConsent = { ...(state.order || {}) }; delete orderWithoutConsent.acceptedAgb; delete orderWithoutConsent.acceptedPrivacy; const payload = { productKey, answers: state.answers || {}, recipientRows: Array.isArray(state.recipientRows) ? state.recipientRows : [], addressMode: state.addressMode || "classic", freeAddressRows: Array.isArray(state.freeAddressRows) ? state.freeAddressRows : [], placeholderValues: state.placeholderValues || {}, order: orderWithoutConsent, step: state.step || 0, history: state.history || [], }; localStorage.setItem(LS_KEY, JSON.stringify(payload)); } catch {} } // Markiert State als dirty - Speicherung erfolgt periodisch oder bei Page Leave function writePersisted(state) { markPersistDirty(); } function hydrateIfPossible(state, dispatch) { // Nur EINMAL hydrieren - beim ersten Laden if (hydratedForProductKey !== null) return false; // Flag SOFORT setzen um Mehrfachausführung zu verhindern hydratedForProductKey = "done"; const productKey = state?.ctx?.product?.key || null; const saved = readPersisted(); if (!saved) return false; // Wenn ein Produkt ausgewählt ist, nur hydrieren wenn es übereinstimmt if (productKey && saved.productKey !== productKey) return false; // URL-Parameter haben Vorrang const urlQuantity = state?.ctx?.quantity || null; const urlFormat = state?.ctx?.format || null; // EINEN großen HYDRATE dispatch statt vieler kleiner dispatch({ type: "HYDRATE_ALL", payload: { productKey: !productKey && saved.productKey ? saved.productKey : null, answers: saved.answers || {}, recipientRows: saved.recipientRows || null, addressMode: saved.addressMode || null, freeAddressRows: saved.freeAddressRows || null, placeholderValues: saved.placeholderValues || null, order: saved.order || null, step: saved.step, urlQuantity, urlFormat, currentStep: state.step, }, }); return true; // Hydration wurde durchgeführt, neuer Render wurde ausgelöst } /* DOM helper */ function h(tag, attrs = {}, children = []) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (k === "class") { el.className = v; continue; } if (k === "text") { el.textContent = v; continue; } if (k.startsWith("on") && typeof v === "function") { el.addEventListener(k.slice(2), v); continue; } if ( k === "value" && (tag === "input" || tag === "textarea" || tag === "select") ) { el.value = v ?? ""; continue; } if (k === "checked" && tag === "input") { el.checked = !!v; continue; } if (k === "disabled") { el.disabled = !!v; continue; } if (k === "selected" && tag === "option") { el.selected = !!v; continue; } if (v === true) el.setAttribute(k, ""); else if (v !== false && v != null) el.setAttribute(k, String(v)); } for (const c of children) el.appendChild(typeof c === "string" ? document.createTextNode(c) : c); return el; } function clear(el) { if (!el) return; while (el.firstChild) el.removeChild(el.firstChild); } /* Fokus Erhalt */ function captureFocus(dom) { const a = document.activeElement; if (!a || !dom.form || !dom.form.contains(a)) return null; const key = a.getAttribute("data-sk-focus"); if (!key) return null; const start = typeof a.selectionStart === "number" ? a.selectionStart : null; const end = typeof a.selectionEnd === "number" ? a.selectionEnd : null; // Scroll-Position des Textfelds speichern const scrollTop = typeof a.scrollTop === "number" ? a.scrollTop : null; const scrollLeft = typeof a.scrollLeft === "number" ? a.scrollLeft : null; return { key, start, end, scrollTop, scrollLeft }; } function restoreFocus(dom, snapshot) { if (!snapshot) return; const el = dom.form.querySelector(`[data-sk-focus="${snapshot.key}"]`); if (!el) return; el.focus({ preventScroll: true }); if ( snapshot.start != null && snapshot.end != null && typeof el.setSelectionRange === "function" ) { try { el.setSelectionRange(snapshot.start, snapshot.end); } catch {} } // Scroll-Position des Textfelds wiederherstellen if (snapshot.scrollTop != null) { el.scrollTop = snapshot.scrollTop; } if (snapshot.scrollLeft != null) { el.scrollLeft = snapshot.scrollLeft; } } /* Utils */ // fmtEUR is now imported from configurator-pricing.js function stepTitle(step) { switch (step) { case STEPS.PRODUCT: return "Produkt"; case STEPS.QUANTITY: return "Allgemein"; case STEPS.ENVELOPE: return "Umschlag"; case STEPS.CONTENT: return "Inhalt"; case STEPS.CUSTOMER_DATA: return "Kundendaten"; case STEPS.REVIEW: return "Prüfen"; default: return ""; } } /** * Rendert einen Link zu den Schriftbeispielen (öffnet in neuem Tab) */ function renderFontSampleLink() { const fontSampleUrl = window.SkriftConfigurator?.settings?.font_sample?.url || ""; if (!fontSampleUrl) return null; return h("div", { class: "sk-help", style: "margin-top: 8px;" }, [ h("a", { href: fontSampleUrl, target: "_blank", rel: "noopener noreferrer", style: "color: #0073aa; text-decoration: underline;", text: "Schriftbeispiele ansehen →", }), ]); } /** * Rendert Platzhalter-Hilfetext mit optionalem Link zur Hilfeseite * @param {string[]} hints - Zusätzliche Hinweise (optional) */ function renderPlaceholderHelpText(hints = []) { const helpUrl = window.SkriftConfigurator?.settings?.font_sample?.placeholder_help_url || ""; const elements = [ document.createTextNode( "Sie können Platzhalter verwenden, um dynamische Inhalte einzufügen. Platzhalter werden in doppelten eckigen Klammern geschrieben (z.B. ", ), h("code", { text: "[[vorname]]" }), document.createTextNode(" oder "), h("code", { text: "[[firma]]" }), document.createTextNode( "). Diese werden später durch die tatsächlichen Werte ersetzt.", ), ]; // Hints hinzufügen if (hints.length > 0) { elements.push(document.createTextNode(" | " + hints.join(" | "))); } // Link hinzufügen if (helpUrl) { elements.push(document.createTextNode(" ")); elements.push( h("a", { href: helpUrl, target: "_blank", rel: "noopener noreferrer", style: "color: #0073aa; text-decoration: underline;", text: "Mehr erfahren →", }), ); } return h("div", { class: "sk-help" }, elements); } function renderCard(title, subtitle, bodyNodes = [], footerNodes = []) { const card = h("div", { class: "sk-card" }, []); if (title) { const head = h("div", { class: "sk-card-head" }, [ h("h3", { class: "sk-card-title", text: title }), ]); if (subtitle) { head.appendChild(h("div", { class: "sk-card-subtitle", text: subtitle })); } card.appendChild(head); } card.appendChild(h("div", { class: "sk-card-body" }, bodyNodes)); if (footerNodes && footerNodes.length) { card.appendChild(h("div", { class: "sk-card-foot" }, footerNodes)); } return card; } function renderStepper(stepperEl, state, dispatch) { clear(stepperEl); const steps = [ { id: STEPS.PRODUCT, label: "Produkt", icon: "📦" }, { id: STEPS.QUANTITY, label: "Allgemein", icon: "⚙️" }, { id: STEPS.ENVELOPE, label: "Umschlag", icon: "✉️" }, { id: STEPS.CONTENT, label: "Inhalt", icon: "✍️" }, { id: STEPS.CUSTOMER_DATA, label: "Kundendaten", icon: "📋" }, { id: STEPS.REVIEW, label: "Prüfen", icon: "✓" }, ]; const stepperContent = h("div", { class: "sk-stepper" }, []); for (let i = 0; i < steps.length; i++) { const s = steps[i]; const isActive = state.step === s.id; const isComplete = s.id < state.step; const canJumpBack = s.id <= state.step; // Button Content: Häkchen-Icon für erledigte Steps const btnChildren = []; if (isComplete) { btnChildren.push(h("span", { class: "sk-chip-check", text: "✓" })); } btnChildren.push(s.label); const btn = h( "button", { type: "button", class: `sk-chip ${isActive ? "is-active" : ""} ${ isComplete ? "is-complete" : "" }`, disabled: !canJumpBack, onclick: () => dispatch({ type: "SET_STEP", step: s.id }), }, btnChildren, ); stepperContent.appendChild(btn); // Pfeil nach jedem Step außer dem letzten (dünner Chevron-Pfeil) if (i < steps.length - 1) { stepperContent.appendChild( h("span", { class: "sk-stepper-arrow", text: "›" }), ); } } // Stepper in Card integrieren (ohne Titel) const card = renderCard(null, null, [stepperContent]); stepperEl.appendChild(card); // Mobile: Auto-Scroll zum aktiven Step requestAnimationFrame(() => { const activeChip = stepperContent.querySelector(".sk-chip.is-active"); if (activeChip && stepperContent) { // Prüfe ob wir auf einem mobilen Gerät sind (stepper ist horizontal scrollbar) const isMobile = window.innerWidth <= 640; if (isMobile) { // Berechne die Scroll-Position um den aktiven Chip zu zentrieren const containerWidth = stepperContent.offsetWidth; const chipLeft = activeChip.offsetLeft; const chipWidth = activeChip.offsetWidth; const scrollLeft = chipLeft - containerWidth / 2 + chipWidth / 2; stepperContent.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth", }); } else { // Desktop: Standard scrollIntoView activeChip.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); } } }); } function renderTopbar(dom, state) { clear(dom.topbar); const q = state.quote || {}; const isB2B = state.answers?.customerType === "business"; const isFU = isFollowups(state); let priceText = "bitte Produkt wählen"; let priceNote = ""; if (isFU) { // Follow-ups: Zeige "Ab-Preis" OHNE Multiplikator und OHNE Einmalkosten (nur pro Stück) const basePrice = calculateFollowupBasePricePerPiece(state); if (basePrice > 0) { const formatted = formatPrice(basePrice, state); priceText = "ab " + formatted.display; priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; } else { priceText = "ab auf Anfrage"; priceNote = ""; } } else if (state.answers.quantity > 0 && state.ctx?.product) { // Standard-Produkte: Zeige "ab" + Preis pro Stück const product = state.ctx.product; const prices = window.SkriftConfigurator?.settings?.prices || {}; const taxRate = (prices.tax_rate || 19) / 100; // Im Product- und Quantity-Step: Nur dynamischer Mengenpreis (Basispreis × Multiplikator) // Keine zusätzlichen Kosten (A4, Motiv, Versand) einberechnen const isEarlyStep = state.step === STEPS.PRODUCT || state.step === STEPS.QUANTITY; if (isEarlyStep) { // Nur Basispreis × dynamische Mengenformel const basePrice = product.basePrice || 0; const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {}; const formula = isB2B ? dynamicPricing.business_formula || "1" : dynamicPricing.private_formula || "1"; const qty = Number(state.answers.quantity) || 1; const normalQty = isB2B ? dynamicPricing.business_normal_quantity || 200 : dynamicPricing.private_normal_quantity || 50; const minQty = isB2B ? dynamicPricing.business_min_quantity || 50 : dynamicPricing.private_min_quantity || 10; // Formel auswerten let multiplier = 1; try { const formulaWithValues = formula .replace(/%qty%/g, qty) .replace(/%norm_b%/g, dynamicPricing.business_normal_quantity || 200) .replace(/%mind_b%/g, dynamicPricing.business_min_quantity || 50) .replace(/%norm_p%/g, dynamicPricing.private_normal_quantity || 50) .replace(/%mind_p%/g, dynamicPricing.private_min_quantity || 10); multiplier = eval(formulaWithValues); if (isNaN(multiplier) || multiplier < 1) multiplier = 1; } catch (e) { multiplier = 1; } const priceNet = basePrice * multiplier; const priceGross = priceNet * (1 + taxRate); const displayPrice = isB2B ? Math.round(priceNet * 100) / 100 : Math.round(priceGross * 100) / 100; priceText = "ab " + fmtEUR(displayPrice); priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; } else { // Ab Envelope-Step: Vollständige Preisberechnung inkl. aller Optionen // calculatePricePerPiece verwendet bereits immer den Inlandspreis const pricePerPiece = calculatePricePerPiece(state); if (pricePerPiece > 0) { const formatted = formatPrice(pricePerPiece, state); priceText = "ab " + formatted.display; priceNote = isB2B ? "zzgl. MwSt. pro Stück" : "inkl. MwSt. pro Stück"; } } } const priceSection = h("div", { class: "sk-price" }, [ h("div", { class: "sk-price-label", text: "Ihr Preis" }), h("div", { class: "sk-price-value", text: priceText, }), h("div", { class: "sk-price-note", text: priceNote }), ]); const productInfo = h("div", { class: "sk-product-info" }, []); if (state?.ctx?.product) { productInfo.appendChild( h("div", { class: "sk-product-icon", text: state.ctx.product.label[0] }), ); productInfo.appendChild( h("div", {}, [ h("div", { class: "sk-product-label", text: state.ctx.product.label }), h("div", { class: "sk-text-small sk-text-muted", text: stepTitle(state.step), }), ]), ); } dom.topbar.appendChild(priceSection); dom.topbar.appendChild(productInfo); } /* STEP RENDERERS */ // Step 1: Produkt (Kundentyp + Produktauswahl kombiniert) function renderProductStep(state, dispatch) { const blocks = []; // Kundentyp Auswahl const customerOptions = [ { value: "business", label: "Geschäftskunde", desc: "Für Unternehmen, Selbstständige und gewerbliche Kunden", icon: "🏢", }, { value: "private", label: "Privatkunde", desc: "Für private Anlässe und persönliche Korrespondenz", icon: "🏠", }, ]; const customerBody = customerOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.customerType === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "SET_CUSTOMER_TYPE", customerType: opt.value }), }, [ h("input", { type: "radio", name: "customerType", checked: state.answers.customerType === opt.value, onchange: () => {}, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: `${opt.icon} ${opt.label}`, }), h("div", { class: "sk-option-desc", text: opt.desc }), ]), ], ), ); blocks.push( renderCard( "Als was möchten Sie einkaufen?", "Wählen Sie Ihren Kundentyp aus", [h("div", { class: "sk-options" }, customerBody)], ), ); // Produktauswahl (nur wenn Kundentyp gewählt) if (state.answers.customerType) { const products = getAvailableProductsForCustomerType( state.answers.customerType, ); const grid = h("div", { class: "sk-selection-grid" }, []); for (const p of products) { const isSelected = state?.ctx?.product?.key === p.key; // Preisberechnung für Anzeige: Nur Basispreis (ohne Versand, Optionen etc.) let displayPrice = p.basePrice || 2.5; // MwSt. aufschlagen (nur für Privatkunden) const isBusinessCustomer = state.answers?.customerType === "business"; const prices = window.SkriftConfigurator?.settings?.prices || {}; const taxRate = (prices.tax_rate || 19) / 100; // Business: Nettopreis, Privat: Bruttopreis const finalPrice = isBusinessCustomer ? displayPrice : displayPrice * (1 + taxRate); const priceText = `ab ${fmtEUR(finalPrice)}`; const descText = p.description || "Professionelle handgeschriebene Korrespondenz"; const card = h( "div", { class: `sk-selection-card ${isSelected ? "is-selected" : ""}`, onclick: () => dispatch({ type: "SET_PRODUCT", product: p }), }, [ h("div", { class: "sk-selection-card-image" }, [ h("div", { text: "📄", style: "font-size: 48px" }), // Placeholder ]), h("div", { class: "sk-selection-card-content" }, [ h("div", { class: "sk-selection-card-title", text: p.label }), h("div", { class: "sk-selection-card-price", text: priceText }), h("div", { class: "sk-selection-card-desc", text: descText, }), ]), ], ); grid.appendChild(card); } blocks.push( renderCard( "Welches Produkt benötigen Sie?", "Wählen Sie das passende Produkt für Ihren Bedarf", [grid], ), ); } return h("div", { class: "sk-stack" }, blocks); } // Step 3: Menge & Format function renderQuantityStep(state, dispatch) { const blocks = []; // Mengenabfrage (für normale Produkte) if (!isFollowups(state)) { // Mindestmenge ermitteln (bei noLimits = 1) const dynamicPricing = window.SkriftConfigurator?.settings?.dynamic_pricing || {}; const isB2B = state.answers?.customerType === "business"; const noLimits = state.noLimits || false; const minQuantity = noLimits ? 1 : isB2B ? dynamicPricing.business_min_quantity || 50 : dynamicPricing.private_min_quantity || 10; const normalQuantity = isB2B ? dynamicPricing.business_normal_quantity || 200 : dynamicPricing.private_normal_quantity || 50; // Standardmenge auf beste Preismenge setzen (normalQuantity statt minQuantity) const currentQty = state.answers.quantity || normalQuantity; const isBelow = currentQty < minQuantity; // Erstelle Input-Element mit manuellem Value-Management const inputEl = h("input", { class: `sk-input ${isBelow ? "sk-input-error" : ""}`, type: "number", min: String(minQuantity), "data-sk-focus": "quantity.amount", onchange: (e) => { // Update state nur bei change (Enter oder Blur) const val = e.target.value === "" ? minQuantity : Number(e.target.value); const finalVal = isNaN(val) || val < minQuantity ? minQuantity : val; e.target.value = String(finalVal); // Nur dispatchen wenn sich der Wert geändert hat if (finalVal !== state.answers.quantity) { dispatch({ type: "ANSWER", patch: { quantity: finalVal }, }); } }, }); // Setze initialen Wert manuell inputEl.value = String(currentQty); const quantityInput = [inputEl]; // Fehlermeldung bei Unterschreitung if (isBelow) { quantityInput.push( h("div", { class: "sk-error-message", style: "color: #d32f2f; margin-top: 8px; font-size: 14px;", text: `Mindestmenge: ${minQuantity} Stück`, }), ); } // Beschreibungstext je nach noLimits-Modus const quantityDescription = noLimits ? `Unser bester Preis gilt ab ${normalQuantity} Stück` : `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`; blocks.push( renderCard( "Wie viele Schriftstücke benötigen Sie?", quantityDescription, quantityInput, ), ); } // Follow-ups: Jahresvolumen if (isFollowups(state)) { const volumes = [ { value: "5-49", label: "5-49 Follow-ups pro Monat" }, { value: "50-199", label: "50-199 Follow-ups pro Monat" }, { value: "200-499", label: "200-499 Follow-ups pro Monat" }, { value: "500-999", label: "500-999 Follow-ups pro Monat" }, { value: "1000+", label: "1000+ Follow-ups pro Monat" }, ]; const volBody = volumes.map((vol) => h( "div", { class: `sk-option ${ state.answers.followupYearlyVolume === vol.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { followupYearlyVolume: vol.value }, }), }, [ h("input", { type: "radio", name: "volume", checked: state.answers.followupYearlyVolume === vol.value, onchange: () => {}, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: vol.label }), ]), ], ), ); blocks.push( renderCard( "Um wie viele Follow-ups geht es ca. pro Monat?", "Dies hilft uns bei der Preiskalkulation", [h("div", { class: "sk-options" }, volBody)], ), ); } // Format Auswahl const formats = state?.ctx?.product?.formats || []; if (formats.length > 1) { const prices = window.SkriftConfigurator?.settings?.prices || {}; const a4Surcharge = prices.a4_upgrade_surcharge || 0; const product = state?.ctx?.product; // B2C Brutto-Preis Hilfsfunktion const isB2B = state.answers?.customerType === "business"; const taxRate = (prices.tax_rate || 19) / 100; const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); // Prüfen ob A4 Aufpreis gilt (nur für Einladungen und Follow-ups) const hasA4Surcharge = (product?.key === "einladungen" || product?.key === "follow-ups") && formats.includes("a4"); const formatMap = { a4: { label: "A4", desc: "Standardformat für Briefe", price: hasA4Surcharge ? `+ ${fmtEUR(displayPrice(a4Surcharge))}` : "", }, a6p: { label: "A6 Hochformat", desc: "Kompakt, ideal für Postkarten", price: "", }, a6l: { label: "A6 Querformat", desc: "Kompakt, ideal für Postkarten", price: "", }, }; const formatBody = formats.map((f) => { const info = formatMap[f] || { label: f, desc: "", price: "" }; return h( "div", { class: `sk-option ${state.answers.format === f ? "is-selected" : ""}`, onclick: () => dispatch({ type: "ANSWER", patch: { format: f } }), }, [ h("input", { type: "radio", name: "format", checked: state.answers.format === f, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: info.label }), h("div", { class: "sk-option-desc", text: info.desc }), info.price ? h("div", { class: "sk-option-price", text: info.price }) : null, ].filter(Boolean), ), ], ); }); blocks.push( renderCard("Welches Format soll das Schriftstück haben?", null, [ 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 if (isFollowups(state)) { // API-Preis aus Backend holen const prices = window.SkriftConfigurator?.settings?.prices || {}; const apiPrice = prices.api_connection || 250; // B2C Brutto-Preis Hilfsfunktion const isB2B = state.answers?.customerType === "business"; const taxRate = (prices.tax_rate || 19) / 100; const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); const createModes = [ { value: "auto", label: "Automatisch aus System", desc: "Wir verbinden uns mit Ihrem CRM/Shop-System", price: `Einmalig ${fmtEUR(displayPrice(apiPrice))}`, }, { value: "manual", label: "Manuell", desc: "Sie senden uns die Empfängerliste im gewählten Rhythmus", }, ]; const createBody = createModes.map((mode) => h( "div", { class: `sk-option ${ state.answers.followupCreateMode === mode.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { followupCreateMode: mode.value }, }), }, [ h("input", { type: "radio", name: "createMode", checked: state.answers.followupCreateMode === mode.value, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: mode.label }), h("div", { class: "sk-option-desc", text: mode.desc }), mode.price ? h("div", { class: "sk-option-price", text: mode.price }) : null, ].filter(Boolean), ), ], ), ); blocks.push( renderCard("Wie sollen die Follow-ups erstellt werden?", null, [ h("div", { class: "sk-options" }, createBody), ]), ); // Automatische Erstellung Details if (state.answers.followupCreateMode === "auto") { blocks.push( renderCard("Details zur Systemanbindung", null, [ h("div", { class: "sk-field" }, [ h("label", { class: "sk-field-label is-required", text: "Aus welchem System?", }), h("input", { class: "sk-input", type: "text", value: state.answers.followupSourceSystem || "", "data-sk-focus": "followup.system", placeholder: "z.B. Shopify, WooCommerce, Sevdesk, HubSpot", oninput: (e) => dispatch({ type: "ANSWER", patch: { followupSourceSystem: e.target.value }, }), }), ]), h("div", { class: "sk-field" }, [ h("label", { class: "sk-field-label is-required", text: "Was soll die Erstellung eines Follow-ups auslösen?", }), h("textarea", { class: "sk-textarea", rows: "3", value: state.answers.followupTriggerDescription || "", "data-sk-focus": "followup.trigger", placeholder: "z.B. Wenn ein neuer Kundenauftrag eingeht oder der Kunde seit 3 Monaten nichts mehr bestellt hat.", oninput: (e) => dispatch({ type: "ANSWER", patch: { followupTriggerDescription: e.target.value }, }), }), ]), h("div", { class: "sk-field" }, [ h("label", { class: "sk-field-label", text: "Prüfrhythmus" }), h( "select", { class: "sk-select", value: state.answers.followupCheckCycle || "monthly", onchange: (e) => dispatch({ type: "ANSWER", patch: { followupCheckCycle: e.target.value }, }), }, [ h("option", { value: "monthly" }, ["Monatlich"]), h("option", { value: "2m" }, ["Alle 2 Monate"]), h("option", { value: "3m" }, ["Alle 3 Monate"]), h("option", { value: "6m" }, ["Alle 6 Monate"]), h("option", { value: "yearly" }, ["Jährlich"]), ], ), ]), ]), ); } // Info-Nachricht für Follow-ups (wird immer angezeigt) blocks.push( h("div", { class: "sk-alert sk-alert-info" }, [ "Nach Ihrer Bestellung werden wir uns mit Ihnen in Verbindung setzen, um alle Details zu besprechen und die optimale Lösung für Ihre Follow-ups zu finden.", ]), ); } return h("div", { class: "sk-stack" }, blocks); } // Step 3: Umschlag (Versand + Umschlag kombiniert) function renderEnvelopeStep(state, dispatch) { const blocks = []; // Schriftart-Auswahl für Umschlag (ERSTE FRAGE - Dropdown) blocks.push( renderCard("Schriftart für Umschlag", null, [ h( "div", { class: "sk-field" }, [ h( "select", { class: "sk-select", value: state.answers.envelopeFont || "tilda", onchange: (e) => dispatch({ type: "ANSWER", patch: { envelopeFont: e.target.value }, }), }, [ h("option", { value: "tilda", text: "Tilda - Druckschrift, leicht lesbar und steril", selected: (state.answers.envelopeFont || "tilda") === "tilda", }), h("option", { value: "alva", text: "Alva - Druckschrift, organisch und informell", selected: state.answers.envelopeFont === "alva", }), h("option", { value: "ellie", text: "Ellie - Schreibschrift, elegant und geschwungen", selected: state.answers.envelopeFont === "ellie", }), ], ), renderFontSampleLink(), ].filter(Boolean), ), ]), ); // Preise aus Backend holen const prices = window.SkriftConfigurator?.settings?.prices || {}; const shippingBulk = prices.shipping_bulk || 4.95; // B2C Brutto-Preis Hilfsfunktion const isB2B = state.answers?.customerType === "business"; const taxRate = (prices.tax_rate || 19) / 100; const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); // Direktversand-Preise berechnen (Inland/Ausland) const shippingPrices = calculateDirectShippingPrices(state); const domesticPriceText = fmtEUR(displayPrice(shippingPrices.domesticPrice)); const internationalPriceText = fmtEUR( displayPrice(shippingPrices.internationalPrice), ); // Versandart Auswahl // Bei Follow-ups ist nur Direktversand möglich und kein Aufpreis wird angezeigt const shippingOptions = isFollowups(state) ? [ { value: "direct", label: "Einzeln an die Empfänger", desc: "Direktversand an Ihre Empfänger – inklusive Kuvertierung und Beschriftung.", price: "", // Kein Aufpreis bei Follow-ups anzeigen }, ] : [ { value: "direct", label: "Einzeln an die Empfänger", desc: "Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung", price: `Inland: ${domesticPriceText} / Ausland: ${internationalPriceText} pro Stück`, }, { value: "bulk", label: "Sammelversand an Sie", desc: "Sie erhalten alle Schriftstücke zur eigenen Verteilung", price: `Einmalig ${fmtEUR(displayPrice(shippingBulk))} Versandkosten`, }, ]; const shippingBody = shippingOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.shippingMode === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { shippingMode: opt.value } }), }, [ h("input", { type: "radio", name: "shipping", checked: state.answers.shippingMode === opt.value, onchange: () => {}, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), h("div", { class: "sk-option-desc", text: opt.desc }), h("div", { class: "sk-option-price", text: opt.price }), ]), ], ), ); blocks.push( renderCard( "Wie sollen die Schriftstücke versendet werden?", "Wählen Sie die passende Versandart", [h("div", { class: "sk-options" }, shippingBody)], ), ); // Umschlag-Optionen (nur wenn Versandart gewählt) if (!state.answers.shippingMode) { return h("div", { class: "sk-stack" }, blocks); } // Bei Direktversand automatisch Umschlag if (state.answers.shippingMode === "direct") { blocks.push( h("div", { class: "sk-alert sk-alert-info" }, [ "Bei Direktversand an Empfänger wird automatisch ein beschrifteter Umschlag verwendet.", ]), ); } else { // Bulk: Umschlag optional const envelopeBase = prices.envelope_base || 0.5; const envOptions = [ { value: true, label: "Ja, ich wünsche ein Kuvert", price: `+ ${fmtEUR(displayPrice(envelopeBase))}`, }, { value: false, label: "Nein, ich benötige kein Kuvert", price: "" }, ]; const envBody = envOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.envelope === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { envelope: opt.value } }), }, [ h("input", { type: "radio", name: "envelope", checked: state.answers.envelope === opt.value, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), opt.price ? h("div", { class: "sk-option-price", text: opt.price }) : null, ].filter(Boolean), ), ], ), ); blocks.push( renderCard("Benötigen Sie ein Kuvert?", null, [ h("div", { class: "sk-options" }, envBody), ]), ); } // Beschriftungsmodus // Bei Direktversand: Direkt Empfängerdaten-Tabelle zeigen, keine Auswahl if (state.answers.shippingMode === "direct") { // envelopeMode wird automatisch durch den Reducer auf "recipientData" gesetzt // wenn shippingMode === "direct" (siehe configurator-state.js Zeile 486-492) // Bei Follow-ups: Unterscheidung zwischen auto und manual const isFollowupsProduct = state?.ctx?.product?.isFollowUp === true; if (!isFollowupsProduct) { // Normale Produkte: Empfängerdaten-Tabelle anzeigen blocks.push(renderRecipientsTable(state, dispatch)); } else if (state.answers.followupCreateMode === "auto") { // Follow-ups mit automatischer Erstellung: Daten kommen aus System blocks.push( renderCard("Empfängerdaten", null, [ h("div", { class: "sk-alert sk-alert-info" }, [ "Die Empfängerdaten werden automatisch aus Ihrem System übernommen. " + "Im Rahmen der Einrichtung werden wir gemeinsam die Schnittstelle zu Ihrem System konfigurieren.", ]), ]), ); } else { // Follow-ups mit manueller Erstellung: Daten werden später übermittelt blocks.push( renderCard("Empfängerdaten", null, [ h("div", { class: "sk-alert sk-alert-info" }, [ "Im Nachgang wird Ihnen eine E-Mail zugesendet mit weiteren Angaben, " + "wohin Sie die Empfängerdaten hochladen können.", ]), ]), ); } } else if (state.answers.envelope === true) { // Bei Bulk-Versand mit Umschlag: Beschriftungsmodus-Auswahl anzeigen // Neues Feld: envelope_labeling, Fallback auf alte Felder const envelopeLabeling = prices.envelope_labeling || prices.envelope_recipient_address || 0.5; const modeOptions = [ { value: "recipientData", label: "Empfängerdaten", desc: "Klassische Adressierung mit Name und Anschrift", price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, }, { value: "customText", label: "Individueller Text", desc: "Freier Text mit Platzhaltern", price: `+ ${fmtEUR(displayPrice(envelopeLabeling))}`, }, { value: "none", label: "Keine Beschriftung", desc: "Umschlag bleibt unbeschriftet", price: "", }, ]; const modeBody = modeOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.envelopeMode === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { envelopeMode: opt.value } }), }, [ h("input", { type: "radio", name: "envelopeMode", checked: state.answers.envelopeMode === opt.value, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), h("div", { class: "sk-option-desc", text: opt.desc }), opt.price ? h("div", { class: "sk-option-price", text: opt.price }) : null, ].filter(Boolean), ), ], ), ); blocks.push( renderCard("Womit soll das Kuvert beschriftet werden?", null, [ h("div", { class: "sk-options" }, modeBody), ]), ); // Empfängerdaten Tabelle (nur wenn gewählt) if (state.answers.envelopeMode === "recipientData") { blocks.push(renderRecipientsTable(state, dispatch)); } // Custom Text if (state.answers.envelopeMode === "customText") { blocks.push( renderCard( "Text für Umschlag", "Max. 160 Zeichen, keine Zeilenumbrüche", [ h("input", { type: "text", class: "sk-input", maxlength: "160", value: state.answers.envelopeCustomText || "", "data-sk-focus": "envelope.customText", placeholder: "z.B. Herzliche Grüße an [[vorname]] [[nachname]]", oninput: (() => { let debounceTimer = null; return (e) => { // Zeilenumbrüche entfernen (falls durch Paste eingefügt) const value = e.target.value.replace(/[\r\n]/g, " "); e.target.value = value; // Debounced dispatch (300ms) if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { dispatch({ type: "ANSWER", patch: { envelopeCustomText: value }, }); }, 300); }; })(), }), renderPlaceholderHelpText(), ], ), ); const ph = extractPlaceholders(state.answers.envelopeCustomText); if (ph.length > 0) { blocks.push( renderPlaceholderTable(state, dispatch, ph, "Platzhalter-Werte"), ); } } } return h("div", { class: "sk-stack" }, blocks); } // Step 6: Inhalt function renderContentStep(state, dispatch) { const blocks = []; // Schriftart-Auswahl für Schriftstück (ERSTE FRAGE - Dropdown) blocks.push( renderCard("Schriftart für Schriftstück", null, [ h( "div", { class: "sk-field" }, [ h( "select", { class: "sk-select", value: state.answers.font || "tilda", onchange: (e) => dispatch({ type: "ANSWER", patch: { font: e.target.value }, }), }, [ h("option", { value: "tilda", text: "Tilda - Druckschrift, leicht lesbar und steril", selected: (state.answers.font || "tilda") === "tilda", }), h("option", { value: "alva", text: "Alva - Druckschrift, organisch und informell", selected: state.answers.font === "alva", }), h("option", { value: "ellie", text: "Ellie - Schreibschrift, elegant und geschwungen", selected: state.answers.font === "ellie", }), ], ), renderFontSampleLink(), ].filter(Boolean), ), ]), ); // Texterstellung // Preise aus Backend holen const prices = window.SkriftConfigurator?.settings?.prices || {}; const textservicePrice = prices.textservice || 0; // B2C Brutto-Preis Hilfsfunktion const isB2B = state.answers?.customerType === "business"; const taxRate = (prices.tax_rate || 19) / 100; const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); const contentOptions = [ { value: "self", label: "Ich erstelle den Text selbst", desc: "Sie geben den vollständigen Text vor", }, { value: "textservice", label: "Textservice beauftragen", desc: "Wir erstellen den Text professionell für Sie", price: textservicePrice > 0 ? `Einmalig ${fmtEUR(displayPrice(textservicePrice))}` : "", }, ]; const contentBody = contentOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.contentCreateMode === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { contentCreateMode: opt.value } }), }, [ h("input", { type: "radio", name: "contentMode", checked: state.answers.contentCreateMode === opt.value, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), h("div", { class: "sk-option-desc", text: opt.desc }), opt.price ? h("div", { class: "sk-option-price", text: opt.price }) : null, ].filter(Boolean), ), ], ), ); blocks.push( renderCard("Möchten Sie den Inhalt selbst erstellen?", null, [ h("div", { class: "sk-options" }, contentBody), ]), ); // Textfeld (wenn selbst erstellt) if (state.answers.contentCreateMode === "self") { const maxChars = state.answers.format === "a4" ? 2500 : 500; const hints = []; if (state.answers.envelopeMode === "recipientData") { hints.push("Verfügbare Platzhalter: [[vorname]], [[name]]"); } else { const phEnv = state.placeholders?.envelope || []; if (phEnv.length > 0) { hints.push( `Umschlag-Platzhalter: ${phEnv.map((p) => `[[${p}]]`).join(", ")}`, ); } } blocks.push( renderCard( "Inhalt des Schriftstücks", `Max. ${maxChars} Zeichen`, [ h("textarea", { class: "sk-textarea", rows: "10", maxlength: String(maxChars), value: state.answers.letterText || "", "data-sk-focus": "content.text", placeholder: "Ihr Text hier. Nutzen Sie [[Platzhalter]] für individuelle Inhalte.", oninput: (() => { let debounceTimer = null; return (e) => { const value = e.target.value; // Debounced dispatch (300ms) if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { dispatch({ type: "ANSWER", patch: { letterText: value }, }); }, 300); }; })(), }), renderPlaceholderHelpText(hints), ].filter(Boolean), ), ); // Platzhalter Verarbeitung const usedInLetter = extractPlaceholders(state.answers.letterText); const envPlaceholders = state.placeholders?.envelope || []; const envSet = new Set(envPlaceholders); // Bei Follow-ups: Keine Platzhalter-Tabelle anzeigen // (Empfängerdaten kommen aus CRM, Platzhalter werden dort verwaltet) if (isFollowups(state)) { // Platzhalter-Tabelle für Follow-ups ausgeblendet } else { // Prüfen ob Empfängerdaten für Platzhalter verfügbar sind: // - envelopeMode muss "recipientData" sein // - addressMode muss "classic" sein (nicht "free") // - envelope darf nicht false sein (kein Kuvert) const hasClassicRecipientData = state.answers.envelopeMode === "recipientData" && (state.addressMode || "classic") === "classic" && state.answers.envelope !== false; if (hasClassicRecipientData) { // Bei klassischen Empfängerdaten: vorname, name, ort sind verfügbar + neue eigene Platzhalter const predefinedRecipient = new Set(["vorname", "name", "ort"]); const newPlaceholders = usedInLetter.filter( (p) => !predefinedRecipient.has(p), ); // Kombinierte Tabelle für reguläre Produkte blocks.push( renderCombinedPlaceholderTable( state, dispatch, Array.from(predefinedRecipient), newPlaceholders, "Platzhalter-Werte", ), ); } else if (state.answers.envelopeMode === "customText") { // Bei individuellem Text: Alle Umschlag-Platzhalter verfügbar + neue eigene Platzhalter const newPlaceholders = usedInLetter.filter((p) => !envSet.has(p)); // Kombinierte Tabelle: Umschlag-Platzhalter (read-only) + neue (editierbar) if (envPlaceholders.length > 0 || newPlaceholders.length > 0) { blocks.push( renderCombinedPlaceholderTable( state, dispatch, envPlaceholders, newPlaceholders, "Platzhalter-Werte", ), ); } } else { // Kein Kuvert oder freie Adresse: Nur eigene Platzhalter anzeigen (ohne vorname, name, ort) if (usedInLetter.length > 0) { blocks.push( renderPlaceholderTable( state, dispatch, usedInLetter, "Platzhalter-Werte", false, ), ); } } } } else if (state.answers.contentCreateMode === "textservice") { blocks.push( h("div", { class: "sk-alert sk-alert-info" }, [ "Unser Textservice erstellt professionelle, individuell auf Ihre Bedürfnisse zugeschnittene Texte. " + "Nach der Bestellung werden wir Sie kontaktieren, um Details zu besprechen.", ]), ); } // Motiv (nur bei Postkarten, oder bei Einladungen/Follow-ups wenn A6 Format) if (supportsMotif(state)) { const motifOptions = [ { value: true, label: "Ja, ich möchte ein Motiv" }, { value: false, label: "Nein, kein Motiv" }, ]; const motifBody = motifOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.motifNeed === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { motifNeed: opt.value } }), }, [ h("input", { type: "radio", name: "motif", checked: state.answers.motifNeed === opt.value, onchange: () => {}, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), ]), ], ), ); blocks.push( renderCard( "Soll ein Motiv auf der Vorderseite abgebildet werden?", null, [h("div", { class: "sk-options" }, motifBody)], ), ); // Motiv Quelle if (state.answers.motifNeed === true) { const motifUploadPrice = prices.motif_upload || 0.3; const motifDesignPrice = prices.motif_design || 0; // Bei A4-Format ist Upload nicht möglich, nur bedruckte Karten const isA4 = state.answers.format === "a4"; const sourceOptions = [ !isA4 && { value: "upload", label: "Eigenes Motiv hochladen", desc: "Sie laden Ihre Datei hoch", price: motifUploadPrice > 0 ? `Einmalig ${fmtEUR(displayPrice(motifUploadPrice))}` : "", }, { value: "printed", label: "Bedruckte Karten zusenden", desc: isA4 ? "Sie senden uns fertig bedruckte A4-Karten" : "Sie senden uns fertig bedruckte A6-Karten", price: "", }, !isA4 && { value: "design", label: "Designservice beauftragen", desc: "Wir erstellen ein individuelles Design", price: motifDesignPrice > 0 ? `Einmalig ${fmtEUR(displayPrice(motifDesignPrice))}` : "", }, ].filter(Boolean); const sourceBody = sourceOptions.map((opt) => h( "div", { class: `sk-option ${ state.answers.motifSource === opt.value ? "is-selected" : "" }`, onclick: () => dispatch({ type: "ANSWER", patch: { motifSource: opt.value } }), }, [ h("input", { type: "radio", name: "motifSource", checked: state.answers.motifSource === opt.value, onchange: () => {}, }), h( "div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: opt.label }), h("div", { class: "sk-option-desc", text: opt.desc }), opt.price ? h("div", { class: "sk-option-price", text: opt.price }) : null, ].filter(Boolean), ), ], ), ); blocks.push( renderCard("Woher kommt das Motiv?", null, [ h("div", { class: "sk-options" }, sourceBody), ]), ); // Upload if (state.answers.motifSource === "upload") { blocks.push( renderCard( "Motiv hochladen", "Erlaubt: SVG, PDF, PNG, JPG, WEBP im A6-Format", [ h("input", { class: "sk-input", type: "file", accept: ".svg,.pdf,image/*", "data-sk-focus": "motif.upload", onchange: (e) => { const f = e.target.files && e.target.files[0]; if (!f) return; // Datei global speichern für späteren Upload window.motifFileToUpload = f; dispatch({ type: "ANSWER", patch: { motifFileName: f.name, motifFileMeta: { name: f.name, size: f.size, type: f.type, }, }, }); }, }), state.answers.motifFileName ? h("div", { class: "sk-help", text: `Ausgewählt: ${state.answers.motifFileName}`, }) : null, ].filter(Boolean), ), ); } // Info-Nachricht für "Bedruckte Karten zusenden" if (state.answers.motifSource === "printed") { blocks.push( h("div", { class: "sk-alert sk-alert-info" }, [ "Alle weiteren Informationen, wohin die bedruckten Karten gesendet werden sollen, erhalten Sie danach per E-Mail.", ]), ); } // Info-Nachricht für "Designservice" if (state.answers.motifSource === "design") { blocks.push( h("div", { class: "sk-alert sk-alert-info" }, [ "Unser Designservice erstellt ein individuelles Design, das perfekt auf Ihre Bedürfnisse zugeschnitten ist. " + "Nach der Bestellung werden wir Sie kontaktieren, um Ihre Vorstellungen und Wünsche im Detail zu besprechen.", ]), ); } } } return h("div", { class: "sk-stack" }, blocks); } // Step 7: Kundendaten function renderCustomerDataStep(state, dispatch) { const blocks = []; const b = state.order.billing; const fields = [ { key: "firstName", label: "Vorname", required: true, type: "text" }, { key: "lastName", label: "Nachname", required: true, type: "text" }, ]; if (state.answers.customerType === "business") { fields.push({ key: "company", label: "Unternehmensname", required: true, type: "text", }); } // E-Mail und Telefon (Pflichtfelder) fields.push( { key: "email", label: "E-Mail-Adresse", required: true, type: "email" }, { key: "phone", label: "Telefonnummer", required: true, type: "tel" }, ); fields.push( { key: "street", label: "Straße und Hausnummer", required: true, type: "text", }, { key: "zip", label: "PLZ", required: true, type: "text" }, { key: "city", label: "Stadt", required: true, type: "text" }, ); const billingFields = fields.map((f) => h("div", { class: "sk-field" }, [ h("label", { class: `sk-field-label ${f.required ? "is-required" : ""}`, text: f.label, }), h("input", { class: "sk-input", type: f.type, value: b[f.key] || "", "data-sk-focus": `billing.${f.key}`, ...(f.key === "zip" ? { pattern: "[0-9]*", inputmode: "numeric" } : {}), oninput: (e) => { let value = e.target.value; // PLZ: Nur Zahlen erlauben if (f.key === "zip") { value = value.replace(/[^0-9]/g, ""); e.target.value = value; } dispatch({ type: "SET_ORDER_BILLING", patch: { [f.key]: value }, }); }, }), ]), ); billingFields.push( h("div", { class: "sk-field" }, [ h("label", { class: "sk-field-label is-required", text: "Land" }), h( "select", { class: "sk-select", value: b.country || "Deutschland", "data-sk-focus": "billing.country", onchange: (e) => dispatch({ type: "SET_ORDER_BILLING", patch: { country: e.target.value }, }), }, [ h("option", { value: "Deutschland" }, ["Deutschland"]), h("option", { value: "Luxemburg" }, ["Luxemburg"]), h("option", { value: "Frankreich" }, ["Frankreich"]), h("option", { value: "Österreich" }, ["Österreich"]), ], ), ]), ); blocks.push(renderCard("Rechnungsdaten", null, billingFields)); // Alternative Lieferadresse blocks.push( renderCard("Lieferadresse", null, [ h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ h("input", { type: "checkbox", checked: !!state.order.shippingDifferent, onclick: (e) => e.stopPropagation(), onchange: (e) => { e.stopPropagation(); dispatch({ type: "SET_ORDER", patch: { shippingDifferent: e.target.checked }, }); }, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label", text: "Alternative Lieferadresse angeben", }), ]), ]), ]), ); if (state.order.shippingDifferent) { const sh = state.order.shipping; const shippingFields = fields.map((f) => h("div", { class: "sk-field" }, [ h("label", { class: `sk-field-label ${f.required ? "is-required" : ""}`, text: f.label, }), h("input", { class: "sk-input", type: f.type, value: sh[f.key] || "", "data-sk-focus": `shipping.${f.key}`, ...(f.key === "zip" ? { pattern: "[0-9]*", inputmode: "numeric" } : {}), oninput: (e) => { let value = e.target.value; // PLZ: Nur Zahlen erlauben if (f.key === "zip") { value = value.replace(/[^0-9]/g, ""); e.target.value = value; } dispatch({ type: "SET_ORDER_SHIPPING", patch: { [f.key]: value }, }); }, }), ]), ); shippingFields.push( h("div", { class: "sk-field" }, [ h("label", { class: "sk-field-label is-required", text: "Land" }), h( "select", { class: "sk-select", value: sh.country || "Deutschland", "data-sk-focus": "shipping.country", onchange: (e) => dispatch({ type: "SET_ORDER_SHIPPING", patch: { country: e.target.value }, }), }, [ h("option", { value: "Deutschland" }, ["Deutschland"]), h("option", { value: "Luxemburg" }, ["Luxemburg"]), h("option", { value: "Frankreich" }, ["Frankreich"]), h("option", { value: "Österreich" }, ["Österreich"]), ], ), ]), ); blocks.push(renderCard("Alternative Lieferadresse", null, shippingFields)); } return h("div", { class: "sk-stack" }, blocks); } // Motiv hochladen wenn vorhanden (vor Bestellabschluss) async function uploadMotifIfNeeded(state, orderNumber) { const api = window.SkriftBackendAPI; if ( state.answers.motifSource === "upload" && window.motifFileToUpload && api ) { try { console.log("[Order] Uploading motif file..."); const motifResult = await api.uploadMotif( window.motifFileToUpload, orderNumber, ); if (motifResult.success) { console.log("[Order] Motif uploaded:", motifResult.url); return motifResult.url; } else { console.error("[Order] Motif upload failed:", motifResult.error); } } catch (error) { console.error("[Order] Motif upload error:", error); } } return null; } // Webhook-Aufruf nach Bestellung async function handleOrderSubmit(state) { const isB2B = state.answers?.customerType === "business"; const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; const webhookUrlBusiness = backend.webhook_url_business; const webhookUrlPrivate = backend.webhook_url_private; const redirectUrlBusiness = backend.redirect_url_business; const redirectUrlPrivate = backend.redirect_url_private; // Bestellnummer generieren (fortlaufend vom WP-Backend) const api = window.SkriftBackendAPI; const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; // Motiv hochladen wenn vorhanden const motifUrl = await uploadMotifIfNeeded(state, orderNumber); if (motifUrl) { state.answers.motifFileUrl = motifUrl; } // Gutschein als verwendet markieren (falls vorhanden) const voucherCode = state.order?.voucherStatus?.valid ? state.order.voucherCode : null; if (voucherCode) { try { const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; await fetch(restUrl + "skrift/v1/voucher/use", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ code: voucherCode }), }); } catch (error) { // Fehler beim Markieren des Gutscheins - trotzdem fortfahren } } // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren let finalizeResult = null; if (api && api.sessionId) { try { finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { customer: state.order, quote: state.quote, }); console.log("[Order] Finalize result:", finalizeResult); } catch (error) { console.error("[Order] Finalize error:", error); } } else { console.warn("[Order] Cannot finalize - no API or session available"); } // Für Business: Webhook sofort aufrufen // Für Privat: Erst PayPal-Zahlung, dann Webhook if (isB2B) { // Business-Kunde: Webhook aufrufen und dann weiterleiten if (webhookUrlBusiness) { try { const orderData = { order_number: orderNumber, customer_type: state.answers.customerType, product: state.ctx?.product?.key, quantity: state.answers.quantity, format: state.answers.format, shipping_mode: state.answers.shippingMode, envelope: state.answers.envelope, envelope_mode: state.answers.envelopeMode, customer_data: state.order, quote: state.quote, timestamp: new Date().toISOString(), finalize_result: finalizeResult, }; const response = await fetch(webhookUrlBusiness, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(orderData), }); if (!response.ok) { alert( "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", ); } } catch (error) { alert( "Bestellung wurde aufgenommen, aber es gab ein Problem bei der Übermittlung. Bitte kontaktieren Sie uns.", ); } } // Weiterleitung für Business-Kunden if (redirectUrlBusiness) { window.location.href = redirectUrlBusiness; } else { alert( "Vielen Dank für Ihre Bestellung! Sie erhalten in Kürze eine Bestätigungs-E-Mail mit allen Details.", ); } } else { // Privatkunde: Ohne PayPal direkt zur Privat-URL weiterleiten if (redirectUrlPrivate) { window.location.href = redirectUrlPrivate; } else { alert( "Weiterleitung zu PayPal folgt - Integration wird vorbereitet.\n\nNach erfolgreicher Zahlung wird der Webhook aufgerufen.", ); } } } // Hilfsfunktion: Gutschein validieren function validateVoucherCode(voucherCode, dispatch) { const code = voucherCode.trim(); if (!code) { dispatch({ type: "SET_ORDER", patch: { voucherStatus: { valid: false, error: "Bitte Code eingeben" } }, }); return; } const vouchers = window.SkriftConfigurator?.vouchers || {}; const voucher = vouchers[code]; if (!voucher) { dispatch({ type: "SET_ORDER", patch: { voucherStatus: { valid: false, error: "Gutschein nicht gefunden" }, }, }); return; } if (voucher.expiry_date) { // Vergleiche nur das Datum, nicht die Uhrzeit const expiryDate = new Date(voucher.expiry_date); const today = new Date(); today.setHours(0, 0, 0, 0); expiryDate.setHours(0, 0, 0, 0); if (expiryDate < today) { dispatch({ type: "SET_ORDER", patch: { voucherStatus: { valid: false, error: "Gutschein ist abgelaufen" }, }, }); return; } } if (voucher.usage_limit > 0 && voucher.usage_count >= voucher.usage_limit) { dispatch({ type: "SET_ORDER", patch: { voucherStatus: { valid: false, error: "Gutschein wurde bereits zu oft eingelöst", }, }, }); return; } dispatch({ type: "SET_ORDER", patch: { voucherStatus: { valid: true, voucher: voucher, message: voucher.type === "percent" ? `${voucher.value}% Rabatt` : `${voucher.value.toFixed(2)}€ Rabatt`, }, }, }); } // Step 8: Review function renderReviewStep(state, dispatch) { const blocks = []; // Zusammenfassung const summary = [ [ "Kundentyp", state.answers.customerType === "business" ? "Geschäftskunde" : "Privatkunde", ], ["Produkt", state?.ctx?.product?.label || ""], ["Menge", String(state.answers.quantity || "")], [ "Format", (() => { const fmt = state.answers.format; if (fmt === "a6p") return "A6 Hochformat"; if (fmt === "a6l") return "A6 Querformat"; if (fmt === "a4") return "A4"; return fmt?.toUpperCase() || ""; })(), ], [ "Versand", state.answers.shippingMode === "direct" ? "Einzeln an Empfänger" : "Gesammelt", ], ["Umschlag", state.answers.envelope ? "Ja" : "Nein"], [ "Beschriftung", state.answers.envelopeMode === "recipientData" ? "Empfängerdaten" : state.answers.envelopeMode === "customText" ? "Individuell" : "Keine", ], ]; const table = h("table", { class: "sk-table" }, []); const tbody = h("tbody", {}, []); for (const [k, v] of summary) { tbody.appendChild( h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), ); } table.appendChild(tbody); blocks.push(renderCard("Ihre Auswahl", null, [table])); // Kundendaten Übersicht (vor Preis!) const b = state.order.billing; const customerDataRows = [ ["Vorname", b.firstName || ""], ["Nachname", b.lastName || ""], ]; if (state.answers.customerType === "business" && b.company) { customerDataRows.push(["Unternehmen", b.company]); } customerDataRows.push( ["E-Mail", b.email || ""], ["Telefon", b.phone || ""], ["Straße und Hausnummer", b.street || ""], ["PLZ", b.zip || ""], ["Stadt", b.city || ""], ["Land", b.country || "Deutschland"], ); const customerDataTable = h("table", { class: "sk-table" }, []); const customerDataBody = h("tbody", {}, []); for (const [k, v] of customerDataRows) { customerDataBody.appendChild( h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), ); } customerDataTable.appendChild(customerDataBody); blocks.push(renderCard("Ihre Daten", null, [customerDataTable])); // Preis const isB2B = state.answers?.customerType === "business"; const isFU = isFollowups(state); if (isFU) { // Follow-ups: Zeige Staffelung + Einrichtungskosten const followupPricing = calculateFollowupPricing(state); if (followupPricing) { // Staffelungs-Tabelle const staffelTable = h("table", { class: "sk-table" }, []); staffelTable.appendChild( h("thead", {}, [ h("tr", {}, [ h("th", { text: "Menge/Monat" }), h("th", { text: isB2B ? "Preis pro Schriftstück (netto)" : "Preis pro Schriftstück (brutto)", }), ]), ]), ); // Grundpreis pro Stück (wie in Kopfzeile, OHNE Multiplikator) const basePrice = calculateFollowupBasePricePerPiece(state); const staffelBody = h("tbody", {}, []); for (const tier of followupPricing.tiers) { // Preis pro Schriftstück = Grundpreis * Multiplikator const pricePerItem = basePrice * tier.multiplier; const formatted = formatPrice(pricePerItem, state); const pricePerItemFormatted = formatted.display; staffelBody.appendChild( h("tr", {}, [ h("td", { text: tier.label }), h("td", { text: pricePerItemFormatted }), ]), ); } staffelTable.appendChild(staffelBody); blocks.push(renderCard("Preisstaffelung", null, [staffelTable])); // Einrichtungskosten - detailliert aufschlüsseln if (followupPricing.setup > 0) { const setupItems = []; const prices = window.SkriftConfigurator?.settings?.prices || {}; // API-Anbindung if (state.answers.followupCreateMode === "auto") { const apiPrice = prices.api_connection || 0; if (apiPrice > 0) { const formatted = formatPrice(apiPrice, state); setupItems.push( h( "div", { style: "display: flex; justify-content: space-between; padding: 8px 0;", }, [ h("span", { text: "API-Anbindung" }), h("span", { style: "font-weight: 600", text: formatted.display, }), ], ), ); } } // Motiv Upload if (state.answers.motifSource === "upload") { const motifPrice = prices.motif_upload || 0; if (motifPrice > 0) { const formatted = formatPrice(motifPrice, state); setupItems.push( h( "div", { style: "display: flex; justify-content: space-between; padding: 8px 0;", }, [ h("span", { text: "Motiv Upload" }), h("span", { style: "font-weight: 600", text: formatted.display, }), ], ), ); } } // Designservice if (state.answers.motifSource === "design") { const designPrice = prices.motif_design || 0; if (designPrice > 0) { const formatted = formatPrice(designPrice, state); setupItems.push( h( "div", { style: "display: flex; justify-content: space-between; padding: 8px 0;", }, [ h("span", { text: "Designservice" }), h("span", { style: "font-weight: 600", text: formatted.display, }), ], ), ); } } // Textservice if (state.answers.contentCreateMode === "textservice") { const textPrice = prices.textservice || 0; if (textPrice > 0) { const formatted = formatPrice(textPrice, state); setupItems.push( h( "div", { style: "display: flex; justify-content: space-between; padding: 8px 0;", }, [ h("span", { text: "Textservice" }), h("span", { style: "font-weight: 600", text: formatted.display, }), ], ), ); } } // Gesamt const setupFormatted = formatPrice(followupPricing.setup, state); setupItems.push( h( "div", { style: "display: flex; justify-content: space-between; padding: 12px 0; border-top: 2px solid #ddd; margin-top: 8px; font-size: 18px; font-weight: 700", }, [ h("span", { text: "Gesamt (einmalig)" }), h("span", { text: setupFormatted.display }), ], ), ); blocks.push(renderCard("Einrichtungskosten", null, setupItems)); } // Versandhinweis if (followupPricing.shippingNote) { blocks.push( h("div", { class: "sk-alert sk-alert-info", text: followupPricing.shippingNote, }), ); } } } else { // Standard-Produkte: Detaillierte Rechnung mit 4 Spalten const lines = state.quote?.lines || []; const priceTable = h("table", { class: "sk-table" }, []); priceTable.appendChild( h("thead", {}, [ h("tr", {}, [ h("th", { text: "Position" }), h("th", { text: "Menge", style: "text-align: center;" }), h("th", { text: "Preis pro Stück", style: "text-align: right;" }), h("th", { text: "Gesamt", style: "text-align: right;" }), ]), ]), ); const priceBody = h("tbody", {}, []); if (lines.length === 0) { priceBody.appendChild( h("tr", {}, [ h("td", { colspan: "4", text: "Preisberechnung folgt nach Bestellung", }), ]), ); } else { // Preis pro Stück für Hauptprodukt mit dynamischer Formel berechnen const quantity = state.answers.quantity || 1; const pricePerPieceNet = calculatePricePerPiece(state); const taxRate = (window.SkriftConfigurator?.settings?.prices?.tax_rate || 19) / 100; const pricePerPieceGross = pricePerPieceNet * (1 + taxRate); for (const ln of lines) { const isSubItem = ln.isSubItem || false; const isMainProduct = ln.isMainProduct || false; // Direktversand-Zeile soll Menge und Gesamtpreis anzeigen (nicht wie normale Sub-Items) const isShippingLine = ln.isShippingLine || ln.description?.includes("Direktversand") || ln.description?.includes("Sammelversand"); const rowStyle = isSubItem ? "padding-left: 20px; font-size: 14px; color: #666;" : ""; // Bei Privatkunden: Bruttopreise anzeigen, bei Business: Nettopreise const unitPrice = isB2B ? ln.unitNet : ln.unitGross; const totalPrice = isB2B ? ln.totalNet : ln.totalGross; // Position: Bei Add-Ons (SubItems) "inkl." voranstellen const positionText = isSubItem ? `inkl. ${ln.description}` : ln.description; // Bei Hauptprodukt: Summierten Preis pro Stück und Gesamtpreis anzeigen // Preise auf 2 Dezimalstellen runden für konsistente Anzeige const pricePerPieceDisplay = isMainProduct ? isB2B ? Math.round(pricePerPieceNet * 100) / 100 : Math.round(pricePerPieceGross * 100) / 100 : Math.round(unitPrice * 100) / 100; // Gesamt = gerundeter Einzelpreis × Menge (damit Menge × Stückpreis = Gesamt stimmt) // Sub-Items zeigen normalerweise keinen Gesamtpreis, AUSSER Versand-Zeilen const showQuantityAndTotal = !isSubItem || isShippingLine; const totalPriceDisplay = isMainProduct ? Math.round(pricePerPieceDisplay * quantity * 100) / 100 : showQuantityAndTotal ? Math.round(totalPrice * 100) / 100 : null; const row = h("tr", {}, [ h("td", { text: positionText, style: rowStyle }), h("td", { text: showQuantityAndTotal && ln.quantity > 0 ? ln.quantity.toString() : "—", style: "text-align: center;" + (isSubItem ? " color: #666;" : ""), }), h("td", { text: fmtEUR(pricePerPieceDisplay), style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), }), h("td", { text: showQuantityAndTotal && totalPriceDisplay !== null ? fmtEUR(totalPriceDisplay) : "—", style: "text-align: right;" + (isSubItem ? " color: #666;" : ""), }), ]); priceBody.appendChild(row); // Steuerhinweis bei Versand (0% MwSt Anteil) if (ln.taxNote) { priceBody.appendChild( h("tr", {}, [ h("td", { colspan: "4", style: "font-size: 12px; color: #999; padding-left: 40px;", text: ln.taxNote, }), ]), ); } } } // Gutschein-Rabatt anzeigen (falls vorhanden) const hasDiscount = state.quote?.discountAmount > 0; if (hasDiscount) { // Zwischensumme VOR Rabatt priceBody.appendChild( h("tr", { style: "border-top: 2px solid #ddd;" }, [ h("td", { text: "Zwischensumme", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.totalBeforeDiscount || 0), style: "text-align: right;", }), ]), ); // Rabatt-Zeile const voucherLabel = state.quote?.voucher?.type === "percent" ? `Gutschein (-${state.quote.voucher.value}%)` : `Gutschein`; priceBody.appendChild( h("tr", { style: "color: #2e7d32; font-weight: 600;" }, [ h("td", { text: voucherLabel, colspan: "3" }), h("td", { text: "-" + fmtEUR(state.quote?.discountAmount || 0), style: "text-align: right;", }), ]), ); } if (isB2B) { // Business: Netto + MwSt getrennt priceBody.appendChild( h( "tr", { style: "font-weight: 700; border-top: " + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + ";", }, [ h("td", { text: "Gesamtpreis (zzgl. MwSt.)", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.subtotalNet || 0), style: "text-align: right;", }), ], ), ); priceBody.appendChild( h("tr", { style: "font-weight: 700; font-size: 16px" }, [ h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.totalGross || 0), style: "text-align: right;", }), ]), ); // MwSt. Ausweis unter Gesamtpreis (inkl. MwSt.) priceBody.appendChild( h("tr", { style: "font-size: 14px; color: #666;" }, [ h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.vatAmount || 0), style: "text-align: right;", }), ]), ); } else { // Privatkunde: Gesamtpreis + MwSt. Ausweis darunter priceBody.appendChild( h( "tr", { style: "font-weight: 700; font-size: 16px; border-top: " + (hasDiscount ? "1px solid #ddd" : "2px solid #ddd") + ";", }, [ h("td", { text: "Gesamtpreis (inkl. MwSt.)", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.totalGross || 0), style: "text-align: right;", }), ], ), ); // MwSt. Ausweis unter Gesamtpreis priceBody.appendChild( h("tr", { style: "font-size: 14px; color: #666;" }, [ h("td", { text: "enthaltene MwSt. 19%", colspan: "3" }), h("td", { text: fmtEUR(state.quote?.vatAmount || 0), style: "text-align: right;", }), ]), ); } priceTable.appendChild(priceBody); // Gutschein-Bereich const voucherCode = state.order?.voucherCode || ""; const voucherStatus = state.order?.voucherStatus || null; const showVoucherInput = state.order?.showVoucherInput || false; const voucherSection = []; if (!showVoucherInput && !voucherStatus?.valid) { // Zeige klickbaren Link voucherSection.push( h("a", { style: "margin-top: 15px; display: block; text-decoration: underline; color: #999; font-size: 13px; cursor: pointer;", text: "Gutschein oder Aktionscode?", onclick: (e) => { e.preventDefault(); dispatch({ type: "SET_ORDER", patch: { showVoucherInput: true }, }); }, }), ); } else if (showVoucherInput || voucherStatus?.valid) { // Zeige Eingabefeld voucherSection.push( h( "div", { style: "margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;", }, [ h( "div", { style: "display: flex; gap: 10px; align-items: flex-start;", }, [ h("input", { type: "text", class: "sk-input", style: "flex: 1; text-transform: uppercase;", placeholder: "Gutschein- oder Aktionscode eingeben", value: voucherCode, disabled: voucherStatus?.valid, "data-sk-focus": "voucher.code", oninput: (e) => { dispatch({ type: "SET_ORDER", patch: { voucherCode: e.target.value.toUpperCase(), voucherStatus: null, }, }); }, }), voucherStatus?.valid ? h("button", { type: "button", class: "sk-btn sk-btn-secondary", text: "Entfernen", onclick: () => { dispatch({ type: "SET_ORDER", patch: { voucherCode: "", voucherStatus: null, showVoucherInput: false, }, }); }, }) : h("button", { type: "button", class: "sk-btn sk-btn-secondary", text: "Einlösen", onclick: () => validateVoucherCode(voucherCode, dispatch), }), ], ), voucherStatus ? h("div", { class: voucherStatus.valid ? "sk-help" : "sk-error-message", style: `margin-top: 8px; color: ${ voucherStatus.valid ? "#2e7d32" : "#d32f2f" };`, text: voucherStatus.valid ? `✓ ${voucherStatus.message}` : `✗ ${voucherStatus.error}`, }) : null, ].filter(Boolean), ), ); } const priceCardContent = [priceTable, ...voucherSection]; // Preis-Card mit spezieller Klasse für noPrice-Modus const priceCard = renderCard("Preis", null, priceCardContent); priceCard.classList.add("sk-price-card"); blocks.push(priceCard); } // Gutschein ist jetzt für alle Produkte in der Preis-Card integriert (siehe oben) // Alternative Lieferadresse (falls vorhanden) if (state.order.shippingDifferent) { const sh = state.order.shipping; const shippingDataRows = [ ["Vorname", sh.firstName || ""], ["Nachname", sh.lastName || ""], ]; if (state.answers.customerType === "business" && sh.company) { shippingDataRows.push(["Unternehmen", sh.company]); } shippingDataRows.push( ["Straße und Hausnummer", sh.street || ""], ["PLZ", sh.zip || ""], ["Stadt", sh.city || ""], ["Land", sh.country || "Deutschland"], ); const shippingDataTable = h("table", { class: "sk-table" }, []); const shippingDataBody = h("tbody", {}, []); for (const [k, v] of shippingDataRows) { shippingDataBody.appendChild( h("tr", {}, [h("td", { text: k }), h("td", { text: v })]), ); } shippingDataTable.appendChild(shippingDataBody); blocks.push(renderCard("Lieferadresse", null, [shippingDataTable])); } // Rechtliches mit Bestellbutton blocks.push( renderCard("Rechtliches", null, [ h( "label", { class: "sk-option", style: "margin-bottom: 12px", onclick: (e) => e.stopPropagation(), }, [ h("input", { type: "checkbox", id: "sk-agb-checkbox", checked: !!state.order.acceptedAgb, onclick: (e) => e.stopPropagation(), onchange: (e) => { e.stopPropagation(); dispatch({ type: "SET_ORDER", patch: { acceptedAgb: e.target.checked }, }); }, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label" }, [ document.createTextNode("Ich habe die "), h("a", { href: "https://skrift.de/agb/", target: "_blank", rel: "noopener noreferrer", text: "Allgemeinen Geschäftsbedingungen", onclick: (e) => e.stopPropagation(), }), document.createTextNode( " gelesen und akzeptiere deren Geltung. Ein Widerrufsrecht besteht gemäß § 312g Abs. 2 Nr. 1 BGB nicht, da Skrift ausschließlich Waren anbietet, die nicht vorgefertigt sind und für deren Herstellung eine individuelle Auswahl oder Bestimmung durch den Kunden maßgeblich ist oder die eindeutig auf die persönlichen Bedürfnisse des Kunden zugeschnitten sind.", ), ]), ]), ], ), h("label", { class: "sk-option", onclick: (e) => e.stopPropagation() }, [ h("input", { type: "checkbox", id: "sk-privacy-checkbox", checked: !!state.order.acceptedPrivacy, onclick: (e) => e.stopPropagation(), onchange: (e) => { e.stopPropagation(); dispatch({ type: "SET_ORDER", patch: { acceptedPrivacy: e.target.checked }, }); }, }), h("div", { class: "sk-option-content" }, [ h("div", { class: "sk-option-label" }, [ document.createTextNode("Ja, ich habe die "), h("a", { href: "https://skrift.de/datenschutz/", target: "_blank", rel: "noopener noreferrer", text: "Datenschutzerklärung", onclick: (e) => e.stopPropagation(), }), document.createTextNode( " zur Kenntnis genommen und bin damit einverstanden, dass die von mir angegebenen Daten elektronisch erhoben und gespeichert werden. Meine Daten werden dabei nur streng zweckgebunden zur Bearbeitung und Beantwortung meiner Anfrage benutzt. Mit dem Absenden des Kontaktformulars erkläre ich mich mit der Verarbeitung einverstanden. Die Daten werden im Falle eines ausbleibenden Auftrags gelöscht.", ), ]), ]), ]), // Bestellbutton - für Privatkunden mit PayPal oder normaler Button renderOrderButton(state), ]), ); return h("div", { class: "sk-stack" }, blocks); } /** * Rendert den Bestellbutton basierend auf Kundentyp und PayPal-Konfiguration */ function renderOrderButton(state) { const isB2B = state.answers?.customerType === "business"; const paypalConfig = window.SkriftConfigurator?.paypal || {}; const isPayPalEnabled = paypalConfig.enabled && paypalConfig.client_id; const isNoPrice = state.noPrice || false; // Geschäftskunden, PayPal nicht aktiviert, oder noPrice-Modus: Normaler Button if (isB2B || !isPayPalEnabled || isNoPrice) { return h("button", { type: "button", class: "sk-btn sk-btn-primary sk-btn-large", style: "width: 100%; margin-top: 20px;", disabled: !validateStep(state), onclick: () => handleOrderSubmit(state), text: "Jetzt kostenpflichtig bestellen", }); } // Prüfen ob AGB und Datenschutz akzeptiert wurden const agbAccepted = state.order?.acceptedAgb || false; const privacyAccepted = state.order?.acceptedPrivacy || false; const isValid = agbAccepted && privacyAccepted; // Privatkunden mit PayPal: PayPal-Button-Container const container = h( "div", { style: "margin-top: 20px; position: relative;", }, [ h("div", { id: "paypal-button-container", style: `min-height: 50px; ${ !isValid ? "opacity: 0.5; pointer-events: none;" : "" }`, }), h("div", { class: "sk-paypal-loading", style: "text-align: center; padding: 15px; color: #666;", text: "PayPal wird geladen...", }), // Hinweis wenn Checkboxen nicht aktiviert !isValid ? h("div", { style: "text-align: center; padding: 10px; color: #dc2626; font-size: 14px; margin-top: 5px;", text: "Bitte akzeptieren Sie die AGB und Datenschutzerklärung, um fortzufahren.", }) : null, ].filter(Boolean), ); // PayPal SDK laden und Button initialisieren loadPayPalSDK(paypalConfig.client_id) .then(() => { initPayPalButton(state); }) .catch((err) => { console.error("[PayPal] SDK load failed:", err); const loadingEl = document.querySelector(".sk-paypal-loading"); if (loadingEl) { loadingEl.innerHTML = "PayPal konnte nicht geladen werden. Bitte versuchen Sie es später erneut."; loadingEl.style.color = "#dc2626"; } }); return container; } /** * Lädt die PayPal SDK dynamisch */ let paypalSDKLoaded = false; let paypalSDKPromise = null; function loadPayPalSDK(clientId) { if (paypalSDKLoaded) { return Promise.resolve(); } if (paypalSDKPromise) { return paypalSDKPromise; } paypalSDKPromise = new Promise((resolve, reject) => { const script = document.createElement("script"); // SDK URL - einfache Version ohne buyer-country (das verursacht 400 Fehler) script.src = `https://www.paypal.com/sdk/js?client-id=${clientId}¤cy=EUR&intent=capture&disable-funding=venmo,paylater,credit`; script.async = true; script.onload = () => { paypalSDKLoaded = true; resolve(); }; script.onerror = () => { paypalSDKPromise = null; reject(new Error("Failed to load PayPal SDK")); }; document.head.appendChild(script); }); return paypalSDKPromise; } /** * Initialisiert den PayPal-Button mit Client-Side Integration * Keine Backend-Aufrufe nötig - alles läuft direkt über PayPal API */ function initPayPalButton(state) { const container = document.getElementById("paypal-button-container"); const loadingEl = document.querySelector(".sk-paypal-loading"); if (!container) { console.error("[PayPal] Button container not found"); return; } // Loading-Text entfernen if (loadingEl) { loadingEl.style.display = "none"; } // Prüfen ob Button bereits existiert if (container.hasChildNodes()) { return; } if (!window.paypal) { console.error("[PayPal] SDK not available"); return; } window.paypal .Buttons({ style: { shape: "rect", layout: "vertical", color: "blue", label: "paypal", }, // Bestellung direkt über PayPal API erstellen (Client-Side) createOrder(data, actions) { const currentState = window.currentGlobalState; // State muss vorhanden sein if (!currentState) { alert( "Fehler: State nicht verfügbar. Bitte laden Sie die Seite neu.", ); return Promise.reject(new Error("State nicht verfügbar")); } // Checkbox-Werte direkt aus dem DOM lesen als Fallback const agbCheckbox = document.getElementById("sk-agb-checkbox"); const privacyCheckbox = document.getElementById("sk-privacy-checkbox"); const agbAccepted = currentState.order?.acceptedAgb || agbCheckbox?.checked; const privacyAccepted = currentState.order?.acceptedPrivacy || privacyCheckbox?.checked; // Validierung prüfen - spezifische Fehlermeldung if (!agbAccepted) { alert("Bitte akzeptieren Sie die Allgemeinen Geschäftsbedingungen."); return Promise.reject(new Error("AGB nicht akzeptiert")); } if (!privacyAccepted) { alert("Bitte akzeptieren Sie die Datenschutzerklärung."); return Promise.reject(new Error("Datenschutz nicht akzeptiert")); } const quote = currentState.quote || {}; const total = quote.totalGross || 0; if (total <= 0) { alert( "Ungültiger Bestellbetrag. Bitte konfigurieren Sie Ihre Bestellung.", ); return Promise.reject(new Error("Ungültiger Bestellbetrag")); } // Betrag auf 2 Dezimalstellen formatieren const formattedTotal = parseFloat(total).toFixed(2); // Produktname für PayPal const productName = currentState.ctx?.product?.label || "Skrift Handschriftservice"; const quantity = currentState.answers?.quantity || 1; return actions.order.create({ intent: "CAPTURE", purchase_units: [ { amount: { currency_code: "EUR", value: formattedTotal, }, description: `${quantity}x ${productName}`, }, ], application_context: { brand_name: "Skrift", locale: "de-DE", shipping_preference: "NO_SHIPPING", user_action: "PAY_NOW", }, }); }, // Zahlung direkt über PayPal API erfassen (Client-Side) onApprove(data, actions) { return actions.order.capture().then(function (orderData) { // Erfolgreiche Transaktion const transaction = orderData?.purchase_units?.[0]?.payments?.captures?.[0]; if (transaction?.status === "COMPLETED") { // Webhook aufrufen und weiterleiten handlePayPalSuccess(window.currentGlobalState || state, orderData); } else { alert( "Zahlung konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.", ); } }); }, // Zahlung abgebrochen onCancel() { alert("Zahlung wurde abgebrochen."); }, // Fehler onError(err) { console.error("[PayPal] Error:", err); alert( "Bei der Zahlung ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.", ); }, }) .render("#paypal-button-container"); } /** * Behandelt erfolgreiche PayPal-Zahlung */ async function handlePayPalSuccess(state, paypalDetails) { const backend = window.SkriftConfigurator?.settings?.backend_connection || {}; const webhookUrlPrivate = backend.webhook_url_private; const redirectUrlPrivate = backend.redirect_url_private; // Bestellnummer generieren (fortlaufend vom WP-Backend) const api = window.SkriftBackendAPI; const orderNumber = api ? await api.generateOrderNumber() : `S-${Date.now()}`; // Motiv hochladen wenn vorhanden const motifUrl = await uploadMotifIfNeeded(state, orderNumber); if (motifUrl) { state.answers.motifFileUrl = motifUrl; } // Gutschein als verwendet markieren (falls vorhanden) const voucherCode = state.order?.voucherStatus?.valid ? state.order.voucherCode : null; if (voucherCode) { try { const restUrl = window.SkriftConfigurator?.restUrl || "/wp-json/"; await fetch(restUrl + "skrift/v1/voucher/use", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ code: voucherCode }), }); } catch (error) { // Fehler beim Markieren des Gutscheins - trotzdem fortfahren } } // Bestellung finalisieren: Preview-Dateien aus Cache in Output-Ordner kopieren let finalizeResult = null; if (api && api.sessionId) { try { finalizeResult = await api.finalizeOrder(api.sessionId, orderNumber, { customer: state.order, payment: { method: "paypal", transaction_id: paypalDetails.id, payer_email: paypalDetails.payer?.email_address, status: paypalDetails.status, }, quote: state.quote, }); console.log("[PayPal] Finalize result:", finalizeResult); } catch (error) { console.error("[PayPal] Finalize error:", error); } } else { console.warn("[PayPal] Cannot finalize - no API or session available"); } // Webhook für Privatkunden mit PayPal-Details aufrufen if (webhookUrlPrivate) { try { const orderData = { order_number: orderNumber, customer_type: state.answers.customerType, product: state.ctx?.product?.key, quantity: state.answers.quantity, format: state.answers.format, shipping_mode: state.answers.shippingMode, envelope: state.answers.envelope, envelope_mode: state.answers.envelopeMode, customer_data: state.order, quote: state.quote, timestamp: new Date().toISOString(), payment: { method: "paypal", transaction_id: paypalDetails.id, payer_email: paypalDetails.payer?.email_address, status: paypalDetails.status, }, finalize_result: finalizeResult, }; const response = await fetch(webhookUrlPrivate, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(orderData), }); if (!response.ok) { console.error("[PayPal] Webhook failed:", response.status); } } catch (error) { console.error("[PayPal] Webhook error:", error); } } // Weiterleitung für Privatkunden if (redirectUrlPrivate) { window.location.href = redirectUrlPrivate; } else { alert( "Vielen Dank für Ihre Zahlung! Ihre Bestellung wurde erfolgreich aufgenommen. Sie erhalten in Kürze eine Bestätigungs-E-Mail.", ); } } // Helper: CSV function parseCsv(text) { const raw = String(text || "").trim(); if (!raw) return []; const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); const sample = lines[0]; const delim = sample.includes(";") ? ";" : ","; return lines.map((ln) => { const cells = ln.split(delim).map((c) => { let value = c.trim(); // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang // Dies verhindert Formula Injection in Excel/LibreOffice if (value.length > 0 && /^[=+\-@]/.test(value)) { value = "'" + value; // Prefix mit ' um als Text zu markieren } return value; }); return cells; }); } function escapeCsvValue(value) { let str = String(value || ""); // CSV Injection Prevention: Entferne gefährliche Zeichen am Anfang // Dies verhindert Formula Injection in Excel/LibreOffice if (str.length > 0 && /^[=+\-@]/.test(str)) { str = "'" + str; // Prefix mit ' um als Text zu markieren } // Semikolon und Anführungszeichen escapen if (str.includes(";") || str.includes('"') || str.includes("\n")) { return '"' + str.replaceAll('"', '""') + '"'; } return str; } function rowsToCsv(rows) { const header = [ "vorname", "nachname", "straße", "hausnummer", "plz", "stadt", "land", ]; const lines = [header.join(";")]; for (const r of rows) { lines.push( [ r.firstName || "", r.lastName || "", r.street || "", r.houseNumber || "", r.zip || "", r.city || "", r.country || "", ] .map((v) => escapeCsvValue(v)) .join(";"), ); } return lines.join("\n"); } function downloadText(filename, text) { const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); } // Generische Tabellen-Modal Funktion // Diese Funktion erstellt ein Modal mit einer Tabelle, die Import/Export unterstützt // und sich automatisch nach einem Import aktualisiert. // // config Parameter: // - title: Titel des Modals // - columns: Array von {key, label} Objekten // - getData: Funktion die den aktuellen State nimmt und die Daten zurückgibt (Array von Objekten) // - setData: Funktion die dispatch und neue Daten nimmt und den State aktualisiert // - rowCount: Anzahl der Zeilen // - exportFilename: Dateiname für CSV Export // - helpText: Optionaler Hilfetext unter der Tabelle // - getStateFunc: Funktion die den aktuellen State zurückgibt (wichtig für Re-Rendering!) function createTableModal(dispatch, config) { let tableWrapper; let currentState = config.getStateFunc(); // State-Referenz aktualisieren (wird nach jedem Dispatch aufgerufen) const updateState = () => { currentState = config.getStateFunc(); }; // Render-Funktion die die Tabelle neu zeichnet (muss vor buildTable definiert werden) const renderTable = () => { requestAnimationFrame(() => { const oldTable = tableWrapper.querySelector("table"); if (oldTable) oldTable.remove(); tableWrapper.appendChild(buildTable()); }); }; // Baut die Tabelle mit den aktuellen Daten function buildTable() { updateState(); // State aktualisieren vor dem Rendern const data = config.getData(currentState); const rowCount = config.rowCount(currentState); const table = h( "table", { class: "sk-table sk-table-pasteable", tabindex: "0" }, [], ); // Header (Fragment verwenden für bessere Performance) const headerCells = [ h("th", { text: "Nr.", class: "sk-table-row-number" }), ]; config.columns.forEach((col) => { const attrs = { text: col.label }; if (col.readOnly) { attrs.class = "sk-table-readonly-header"; } headerCells.push(h("th", attrs)); }); table.appendChild(h("thead", {}, [h("tr", {}, headerCells)])); // Body - Batch DOM updates const tbody = h("tbody", {}, []); const rows = []; for (let rowIdx = 0; rowIdx < rowCount; rowIdx++) { const rowData = data[rowIdx] || {}; const cells = [ h("td", { text: String(rowIdx + 1), class: "sk-table-row-number" }), ]; config.columns.forEach((col, colIdx) => { const value = rowData[col.key] || ""; const cellAttrs = {}; if (col.readOnly) { cellAttrs.class = "sk-table-readonly-cell"; } // Optimierung: Input Handler mit direktem Dispatch ohne komplette Daten-Kopie cells.push( h("td", cellAttrs, [ h("input", { class: "sk-input", type: "text", value: value, placeholder: col.placeholder || "", readonly: col.readOnly || false, "data-row": String(rowIdx), "data-col": String(colIdx), "data-key": col.key, "data-sk-focus": `modal.${col.key}.${rowIdx}`, oninput: col.readOnly ? null : (e) => { // Scroll-Position VOR dem dispatch speichern const modalContent = e.target.closest(".sk-modal-content"); const scrollTop = modalContent ? modalContent.scrollTop : 0; const cursorPos = e.target.selectionStart; const inputValue = e.target.value; updateState(); const newData = config .getData(currentState) .map((r) => ({ ...r })); if (!newData[rowIdx]) newData[rowIdx] = {}; newData[rowIdx][col.key] = inputValue; config.setData(dispatch, newData); // Fokus und Scroll-Position nach Render wiederherstellen setTimeout(() => { const input = document.querySelector( `input[data-row="${rowIdx}"][data-key="${col.key}"]`, ); if (input) { input.focus({ preventScroll: true }); input.setSelectionRange(cursorPos, cursorPos); // Modal Scroll-Position wiederherstellen const modal = input.closest(".sk-modal-content"); if (modal) { modal.scrollTop = scrollTop; } } }, 0); }, }), ]), ); }); rows.push(h("tr", {}, cells)); } // Batch append mit DocumentFragment für maximale Performance const fragment = document.createDocumentFragment(); rows.forEach((row) => fragment.appendChild(row)); tbody.appendChild(fragment); table.appendChild(tbody); // Excel Paste Handler mit automatischer Tabellen-Aktualisierung table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text || !text.trim()) return; const rows = text.split(/\r?\n/).filter((r) => r.trim()); if (rows.length === 0) return; const activeInput = document.activeElement; if (!activeInput || !activeInput.classList.contains("sk-input")) return; const startRow = parseInt(activeInput.dataset.row || "0"); const startCol = parseInt(activeInput.dataset.col || "0"); updateState(); const currentData = config.getData(currentState); const currentRowCount = config.rowCount(currentState); const newData = currentData.map((r) => ({ ...r })); // Sicherstellen dass genug Zeilen existieren while (newData.length < currentRowCount) { newData.push({}); } let pastedCells = 0; rows.forEach((rowText, rowOffset) => { const cells = rowText.split("\t"); cells.forEach((cellValue, colOffset) => { const targetRow = startRow + rowOffset; const targetCol = startCol + colOffset; if ( targetRow < currentRowCount && targetCol < config.columns.length ) { const col = config.columns[targetCol]; if (!col.readOnly) { if (!newData[targetRow]) newData[targetRow] = {}; newData[targetRow][col.key] = cellValue.trim(); pastedCells++; } } }); }); if (pastedCells > 0) { config.setData(dispatch, newData); // Tabelle im Modal aktualisieren renderTable(); } }); return table; } // CSV Export function exportCsv() { updateState(); const data = config.getData(currentState); const headers = config.columns.map((col) => col.label).join(";"); const rows = data.map((row) => config.columns.map((col) => escapeCsvValue(row[col.key] || "")).join(";"), ); const csv = [headers, ...rows].join("\n"); downloadText(config.exportFilename, csv); } // CSV Import async function handleImport(file) { if (!file) return; const text = await file.text(); const parsed = parseCsv(text); if (parsed.length === 0) { alert("Die CSV-Datei ist leer."); return; } // Strikte Header-Validierung let startRow = 0; let headerValid = false; if (parsed.length > 0 && parsed[0].length > 0) { const firstRow = parsed[0]; // Prüfe ob erste Zeile wie eine Header-Zeile aussieht const commonHeaders = [ "vorname", "nachname", "name", "straße", "strasse", "plz", "stadt", "land", "nr", "ort", ]; const hasCommonHeaders = firstRow.some((cell) => { const lower = cell.toLowerCase().trim(); return commonHeaders.some((header) => lower.includes(header)); }); const hasPlaceholderSyntax = firstRow.some( (cell) => cell.includes("[[") && cell.includes("]]"), ); const looksLikeHeader = hasCommonHeaders || hasPlaceholderSyntax; if (looksLikeHeader) { // Validiere die Spaltenüberschriften const editableColumns = config.columns.filter((col) => !col.readOnly); // Prüfe ob alle erwarteten Spalten vorhanden sind let matchCount = 0; const expectedHeaders = editableColumns.map((col) => { const labelCleaned = col.label .replace(/\[\[|\]\]/g, "") .toLowerCase() .trim(); return { key: col.key.toLowerCase(), label: labelCleaned, original: col.label, }; }); for ( let i = 0; i < Math.min(firstRow.length, config.columns.length); i++ ) { const cellLower = firstRow[i].toLowerCase().trim(); const col = config.columns[i]; if (col.readOnly) continue; // Überspringe read-only Spalten const labelCleaned = col.label .replace(/\[\[|\]\]/g, "") .toLowerCase() .trim(); const keyLower = col.key.toLowerCase(); // Exakte oder ähnliche Übereinstimmung if ( cellLower === keyLower || cellLower === labelCleaned || cellLower.includes(labelCleaned) || labelCleaned.includes(cellLower) ) { matchCount++; } } // Header ist gültig wenn mindestens 50% der editierbaren Spalten übereinstimmen const requiredMatches = Math.ceil(editableColumns.length * 0.5); headerValid = matchCount >= requiredMatches; if (headerValid) { startRow = 1; } else { // Header-Zeile erkannt aber ungültig const expectedHeaderNames = expectedHeaders .map((h) => h.original) .join(", "); const actualHeaderNames = firstRow.join(", "); alert( `Die Spaltenüberschriften stimmen nicht überein.\n\n` + `Erwartet: ${expectedHeaderNames}\n\n` + `Gefunden: ${actualHeaderNames}\n\n` + `Bitte stellen Sie sicher, dass die CSV-Datei die richtigen Spaltenüberschriften hat.`, ); return; } } } // Prüfe ob genug Datenzeilen vorhanden sind if (parsed.length <= startRow) { alert("Die CSV-Datei enthält keine Datenzeilen."); return; } updateState(); const rowCount = config.rowCount(currentState); const newData = []; for (let i = 0; i < rowCount; i++) { const row = {}; const sourceRow = parsed[startRow + i]; if (sourceRow) { config.columns.forEach((col, colIdx) => { if (!col.readOnly) { row[col.key] = sourceRow[colIdx] || ""; } }); } newData.push(row); } config.setData(dispatch, newData); // Tabelle neu rendern nach Import (requestAnimationFrame für bessere Performance) requestAnimationFrame(() => renderTable()); } const fileInput = h("input", { type: "file", accept: ".csv,text/csv", style: "display: none;", onchange: async (e) => { const f = e.target.files && e.target.files[0]; await handleImport(f); e.target.value = ""; }, }); const actions = h( "div", { class: "sk-inline", style: "margin-bottom: 16px;" }, [ fileInput, h("button", { type: "button", class: "sk-btn", text: "CSV importieren", onclick: () => fileInput.click(), }), h("button", { type: "button", class: "sk-btn", text: "CSV exportieren", onclick: exportCsv, }), ], ); tableWrapper = h("div", { class: "sk-table-wrapper-modal" }, [buildTable()]); const modalContent = h("div", { class: "sk-modal-content" }, [ h("div", { class: "sk-modal-header" }, [ h("h3", { text: config.title }), h("button", { type: "button", class: "sk-modal-close", text: "×", onclick: () => modal.remove(), }), ]), h( "div", { class: "sk-modal-body" }, [ config.infoText ? h("div", { class: "sk-alert sk-alert-info", style: "margin-bottom: 16px; font-size: 13px;", text: config.infoText, }) : null, actions, tableWrapper, config.helpText ? h("div", { class: "sk-help", style: "margin-top: 12px;", text: config.helpText, }) : null, ].filter((x) => x !== null), ), ]); const modal = h( "div", { class: "sk-modal", onclick: (e) => { if (e.target.classList.contains("sk-modal")) modal.remove(); }, }, [modalContent], ); const configuratorElement = document.querySelector(".sk-configurator") || document.body; configuratorElement.appendChild(modal); // Focus auf erste editierbare Zelle setTimeout(() => { const firstInput = tableWrapper.querySelector("input:not([readonly])"); if (firstInput) firstInput.focus(); }, 100); } // Helper: Empfänger Tabelle function renderRecipientsTable(state, dispatch) { const addressMode = state.addressMode || "classic"; const qty = Number(state.answers.quantity || 0); const want = qty > 0 ? qty : 0; const isDirectShipping = state.answers.shippingMode === "direct"; // Klassische Adresstabelle function buildClassicTable(currentState) { const rows = Array.isArray(currentState.recipientRows) ? currentState.recipientRows : []; const table = h( "table", { 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", {}, [ h("th", { text: "Nr.", class: "sk-table-row-number" }), h("th", { text: "Vorname" }), h("th", { text: "Nachname" }), h("th", { text: "Straße" }), h("th", { text: "Nr." }), h("th", { text: "PLZ" }), h("th", { text: "Stadt" }), h("th", { text: "Land" }), ]), ]), ); const tbody = h("tbody", {}, []); for (let idx = 0; idx < want; idx++) { const r = rows[idx] || {}; const cell = (key, placeholder = "") => h("td", {}, [ h("input", { class: "sk-input", type: "text", value: r[key] || "", placeholder, "data-row": idx, "data-col": key, "data-sk-focus": `recipient.${key}.${idx}`, oninput: (e) => { const currentRows = table._rows; if (!currentRows[idx]) { currentRows[idx] = { firstName: "", lastName: "", street: "", houseNumber: "", zip: "", city: "", country: isDirectShipping ? "Deutschland" : "", }; } currentRows[idx][key] = e.target.value; }, }), ]); const countryCell = h("td", {}, [ h("input", { class: "sk-input", type: "text", value: r.country || (isDirectShipping ? "Deutschland" : ""), placeholder: isDirectShipping ? "Deutschland" : "", "data-row": idx, "data-col": "country", "data-sk-focus": `recipient.country.${idx}`, oninput: (e) => { const currentRows = table._rows; if (!currentRows[idx]) { currentRows[idx] = { firstName: "", lastName: "", street: "", houseNumber: "", zip: "", city: "", country: isDirectShipping ? "Deutschland" : "", }; } currentRows[idx].country = e.target.value; }, }), ]); tbody.appendChild( h("tr", {}, [ h("td", { text: String(idx + 1), class: "sk-table-row-number" }), cell("firstName"), cell("lastName"), cell("street"), cell("houseNumber"), cell("zip"), cell("city"), countryCell, ]), ); } table.appendChild(tbody); // Focusout-Handler mit Debounce um Flackern zu vermeiden let focusoutTimer = null; let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden table.addEventListener("focusout", (e) => { if (focusoutTimer) clearTimeout(focusoutTimer); focusoutTimer = setTimeout(() => { const newFocus = document.activeElement; if (!table.contains(newFocus) && hasLocalChanges) { // Nur dispatchen wenn lokale Änderungen gemacht wurden dispatch({ type: "SET_RECIPIENT_ROWS", rows: [...table._rows] }); hasLocalChanges = false; } }, 150); }); // Änderungen tracken bei Input table.addEventListener("input", () => { hasLocalChanges = true; }); // Paste-Handler table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text) return; const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); if (pasteRows.length === 0) return; const activeInput = document.activeElement; let startRow = 0; let startCol = 0; if (activeInput && activeInput.tagName === "INPUT") { const td = activeInput.closest("td"); const tr = td?.closest("tr"); if (tr) { const allRows = Array.from(tbody.children); startRow = allRows.indexOf(tr); if (startRow >= 0) { const allCells = Array.from(tr.children); const cellIdx = allCells.indexOf(td); startCol = cellIdx > 0 ? cellIdx - 1 : 0; } } } const keys = [ "firstName", "lastName", "street", "houseNumber", "zip", "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" : "", }, ); } let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; const cells = rowText.split("\t"); cells.forEach((cellValue, colOffset) => { const targetCol = startCol + colOffset; if (targetCol >= keys.length) return; const key = keys[targetCol]; newData[targetRow][key] = cellValue; pastedCells++; }); }); if (pastedCells > 0) { dispatch({ type: "SET_RECIPIENT_ROWS", rows: newData }); } }); return table; } // Freie Adresstabelle (5 Zeilen) function buildFreeTable(currentState) { const rows = Array.isArray(currentState.freeAddressRows) ? currentState.freeAddressRows : []; const table = h( "table", { class: "sk-table sk-table-pasteable", tabindex: "0" }, [], ); table.appendChild( h("thead", {}, [ h("tr", {}, [ h("th", { text: "Nr.", class: "sk-table-row-number" }), h("th", { text: "Zeile 1" }), h("th", { text: "Zeile 2" }), h("th", { text: "Zeile 3" }), h("th", { text: "Zeile 4" }), h("th", { text: "Land" }), ]), ]), ); const tbody = h("tbody", {}, []); for (let idx = 0; idx < want; idx++) { const r = rows[idx] || {}; const cell = (key) => h("td", {}, [ h("input", { class: "sk-input", type: "text", value: r[key] || "", "data-row": idx, "data-col": key, "data-sk-focus": `freeaddr.${key}.${idx}`, oninput: (e) => { if (!rows[idx]) { rows[idx] = { line1: "", line2: "", line3: "", line4: "", line5: "", }; } rows[idx][key] = e.target.value; }, }), ]); tbody.appendChild( h("tr", {}, [ h("td", { text: String(idx + 1), class: "sk-table-row-number" }), cell("line1"), cell("line2"), cell("line3"), cell("line4"), cell("line5"), ]), ); } table.appendChild(tbody); // Focusout-Handler mit Debounce um Flackern zu vermeiden let focusoutTimer = null; let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden table.addEventListener("focusout", (e) => { if (focusoutTimer) clearTimeout(focusoutTimer); focusoutTimer = setTimeout(() => { const newFocus = document.activeElement; if (!table.contains(newFocus) && hasLocalChanges) { // Nur dispatchen wenn lokale Änderungen gemacht wurden dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: [...rows] }); hasLocalChanges = false; } }, 150); }); // Änderungen tracken bei Input table.addEventListener("input", () => { hasLocalChanges = true; }); // Paste-Handler table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text) return; const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); if (pasteRows.length === 0) return; const activeInput = document.activeElement; let startRow = 0; let startCol = 0; if (activeInput && activeInput.tagName === "INPUT") { const td = activeInput.closest("td"); const tr = td?.closest("tr"); if (tr) { const allRows = Array.from(tbody.children); startRow = allRows.indexOf(tr); if (startRow >= 0) { const allCells = Array.from(tr.children); const cellIdx = allCells.indexOf(td); startCol = cellIdx > 0 ? cellIdx - 1 : 0; } } } 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: "" }, ); } let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; const cells = rowText.split("\t"); cells.forEach((cellValue, colOffset) => { const targetCol = startCol + colOffset; if (targetCol >= keys.length) return; newData[targetRow][keys[targetCol]] = cellValue; pastedCells++; }); }); if (pastedCells > 0) { dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: newData }); } }); return table; } // Modal öffnen function openRecipientsModal() { if (addressMode === "free") { createTableModal(dispatch, { title: "Freie Adresse", columns: [ { key: "line1", label: "Zeile 1" }, { key: "line2", label: "Zeile 2" }, { key: "line3", label: "Zeile 3" }, { key: "line4", label: "Zeile 4" }, { key: "line5", label: "Land" }, ], getData: (state) => { const rows = Array.isArray(state.freeAddressRows) ? state.freeAddressRows : []; const normalized = []; for (let i = 0; i < want; i++) { normalized.push( rows[i] || { line1: "", line2: "", line3: "", line4: "", line5: "", }, ); } return normalized; }, setData: (dispatch, data) => { dispatch({ type: "SET_FREE_ADDRESS_ROWS", rows: data }); }, rowCount: () => want, exportFilename: "freie_adressen.csv", helpText: "Geben Sie bis zu 5 Zeilen pro Empfänger ein.", getStateFunc: () => currentGlobalState, }); } else { createTableModal(dispatch, { title: "Empfänger-Daten", columns: [ { key: "firstName", label: "Vorname" }, { key: "lastName", label: "Nachname" }, { key: "street", label: "Straße" }, { key: "houseNumber", label: "Nr." }, { key: "zip", label: "PLZ" }, { key: "city", label: "Stadt" }, { key: "country", label: "Land", placeholder: isDirectShipping ? "Deutschland" : "", }, ], getData: (state) => { const rows = Array.isArray(state.recipientRows) ? state.recipientRows : []; const normalized = []; for (let i = 0; i < want; i++) { normalized.push( rows[i] || { firstName: "", lastName: "", street: "", houseNumber: "", zip: "", city: "", country: isDirectShipping ? "Deutschland" : "", }, ); } return normalized; }, setData: (dispatch, data) => { dispatch({ type: "SET_RECIPIENT_ROWS", rows: data }); }, rowCount: () => want, exportFilename: "empfaenger.csv", helpText: "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", getStateFunc: () => currentGlobalState, }); } } // Switch-Element für Adressmodus const addressModeSwitch = h( "div", { class: "sk-address-mode-switch", style: "display: flex; gap: 8px; margin-bottom: 12px;", }, [ h("button", { type: "button", class: addressMode === "classic" ? "sk-btn sk-btn-primary" : "sk-btn sk-btn-outline", text: "Klassische Adresse", onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "classic" }), }), h("button", { type: "button", class: addressMode === "free" ? "sk-btn sk-btn-primary" : "sk-btn sk-btn-outline", text: "Freie Adresse (bis zu 5 Zeilen)", onclick: () => dispatch({ type: "SET_ADDRESS_MODE", mode: "free" }), }), ], ); const tableContent = addressMode === "free" ? buildFreeTable(state) : buildClassicTable(state); // Porto-Hinweis entfernt const shippingNotice = null; const cardContent = [ addressModeSwitch, h("div", { class: "sk-help", text: "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", }), h("button", { type: "button", class: "sk-btn", text: "Tabelle maximieren", onclick: openRecipientsModal, }), h("div", { class: "sk-table-wrapper" }, [tableContent]), ]; if (shippingNotice) { cardContent.push(shippingNotice); } return renderCard( "Empfänger-Daten", addressMode === "free" ? "Geben Sie bis zu 5 Zeilen pro Empfänger ein" : "Pflicht: Alle Felder müssen ausgefüllt werden", cardContent, ); } // Helper: Kombinierte Platzhalter Tabelle (read-only + editierbar) function renderCombinedPlaceholderTable( state, dispatch, readOnlyPlaceholders, editablePlaceholders, title, ) { const qty = Number(state.answers.quantity || 0); const want = qty > 0 ? qty : 0; const cleanReadOnly = readOnlyPlaceholders .map((p) => normalizePlaceholderName(p)) .filter((p) => !!p); const cleanEditable = editablePlaceholders .map((p) => normalizePlaceholderName(p)) .filter((p) => !!p); const allPlaceholders = [...cleanReadOnly, ...cleanEditable]; if (allPlaceholders.length === 0) return null; // Vorschau-Tabelle bauen function buildPreviewTable(currentState) { const table = h( "table", { class: "sk-table sk-table-pasteable", tabindex: "0", }, [], ); // Lokaler Cache für Platzhalter-Werte (kein Re-Render bei Eingabe) const localValues = {}; for (const name of cleanEditable) { localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; while (localValues[name].length < want) { localValues[name].push(""); } } let hasLocalChanges = false; let autoSaveInterval = null; // Funktion zum Speichern der lokalen Änderungen const saveLocalChanges = () => { if (hasLocalChanges) { dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); hasLocalChanges = false; } }; // Bei globaler Registry registrieren (für flush vor Navigation) const unregisterSave = registerTableSave(saveLocalChanges); // Auto-Save alle 60 Sekunden autoSaveInterval = setInterval(saveLocalChanges, 60000); // Cleanup-Funktion für Interval und Registry table._cleanup = () => { if (autoSaveInterval) clearInterval(autoSaveInterval); unregisterSave(); }; // Header table.appendChild( h("thead", {}, [ h("tr", {}, [ h("th", { text: "Nr.", class: "sk-table-row-number" }), ...allPlaceholders.map((p) => { const isReadOnly = cleanReadOnly.includes(p); return h("th", { text: `[[${p}]]`, class: isReadOnly ? "sk-table-readonly-header" : "", }); }), ]), ]), ); // Body const tbody = h("tbody", {}, []); for (let row = 0; row < want; row++) { const tr = h("tr", {}, [ h("td", { text: String(row + 1), class: "sk-table-row-number" }), ]); for (const name of allPlaceholders) { const isReadOnly = cleanReadOnly.includes(name); let val = ""; // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen if ( currentState.answers.envelopeMode === "recipientData" && (name === "vorname" || name === "name" || name === "ort") ) { const recipients = currentState.recipientRows || []; const recipient = recipients[row] || {}; if (name === "vorname") val = recipient.firstName || ""; else if (name === "name") val = recipient.lastName || ""; else if (name === "ort") val = recipient.city || ""; } else if (isReadOnly) { val = (currentState.placeholderValues?.[name] || [])[row] || ""; } else { val = localValues[name][row] || ""; } tr.appendChild( h("td", { class: isReadOnly ? "sk-table-readonly-cell" : "" }, [ h("input", { class: "sk-input", type: "text", value: val, readonly: isReadOnly, "data-row": row, "data-col": name, "data-sk-focus": `placeholder.${name}.${row}`, oninput: (e) => { if (!isReadOnly) { // Nur lokal speichern - kein Re-Render! localValues[name][row] = e.target.value; hasLocalChanges = true; } }, }), ]), ); } tbody.appendChild(tr); } table.appendChild(tbody); // Focusout-Handler: Speichern wenn Fokus die Tabelle verlässt let focusoutTimer = null; table.addEventListener("focusout", (e) => { if (focusoutTimer) clearTimeout(focusoutTimer); focusoutTimer = setTimeout(() => { const newFocus = document.activeElement; if (!table.contains(newFocus)) { saveLocalChanges(); } }, 150); }); // Paste-Handler für Excel-Daten table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text) return; const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); if (pasteRows.length === 0) return; // Startposition finden const activeInput = document.activeElement; let startRow = 0; let startCol = 0; if (activeInput && activeInput.tagName === "INPUT") { const td = activeInput.closest("td"); const tr = td?.closest("tr"); if (tr) { const allRows = Array.from(tbody.children); startRow = allRows.indexOf(tr); if (startRow >= 0) { const allCells = Array.from(tr.children); const cellIdx = allCells.indexOf(td); // cellIdx 0 ist die Nummer-Spalte, daher -1 für die Daten-Spalten startCol = cellIdx > 0 ? cellIdx - 1 : 0; } } } // Alle Änderungen in localValues sammeln und Inputs aktualisieren let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; const cells = rowText.split("\t"); cells.forEach((cellValue, colOffset) => { const targetCol = startCol + colOffset; if (targetCol >= allPlaceholders.length) return; const name = allPlaceholders[targetCol]; const isReadOnly = cleanReadOnly.includes(name); // Nur editierbare Spalten aktualisieren if (!isReadOnly) { localValues[name][targetRow] = cellValue; // Input-Element direkt aktualisieren const input = table.querySelector( `input[data-row="${targetRow}"][data-col="${name}"]`, ); if (input) input.value = cellValue; pastedCells++; } }); }); // Nach Paste: Einmal speichern if (pastedCells > 0) { hasLocalChanges = true; saveLocalChanges(); } }); return table; } // Modal mit generischer Funktion öffnen function openCombinedModal() { createTableModal(dispatch, { title: title, columns: allPlaceholders.map((name) => ({ key: name, label: `[[${name}]]`, readOnly: cleanReadOnly.includes(name), })), getData: (state) => { const qty = Number(state.answers.quantity || 0); const want = qty > 0 ? qty : 0; const result = []; for (let row = 0; row < want; row++) { const rowData = {}; for (const name of allPlaceholders) { // Bei Empfängerdaten: vorname, name, ort aus recipientRows holen if ( state.answers.envelopeMode === "recipientData" && (name === "vorname" || name === "name" || name === "ort") ) { const recipients = state.recipientRows || []; const recipient = recipients[row] || {}; if (name === "vorname") rowData[name] = recipient.firstName || ""; else if (name === "name") rowData[name] = recipient.lastName || ""; else if (name === "ort") rowData[name] = recipient.city || ""; } else { rowData[name] = (state.placeholderValues?.[name] || [])[row] || ""; } } result.push(rowData); } return result; }, setData: (dispatch, data) => { // Nur editierbare Felder updaten data.forEach((rowData, rowIdx) => { cleanEditable.forEach((name) => { dispatch({ type: "SET_PLACEHOLDER_VALUE", name, row: rowIdx, value: rowData[name] || "", }); }); }); }, rowCount: (state) => { const qty = Number(state.answers.quantity || 0); return qty > 0 ? qty : 0; }, exportFilename: "platzhalter.csv", infoText: "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", helpText: "Nutzen Sie [[platzhalter]] zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", getStateFunc: () => currentGlobalState, }); } return renderCard(title, null, [ h("button", { type: "button", class: "sk-btn", text: "Tabelle maximieren", onclick: openCombinedModal, }), h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), (() => { const helpUrl = window.SkriftConfigurator?.settings?.font_sample ?.placeholder_help_url || ""; const elements = [ "Nutzen Sie ", h("strong", { text: "[[platzhalter]]" }), " zur Individualisierung im Text. Vorhandene Platzhalter (grau hinterlegt) kommen aus dem Schritt Umschlag, neue können Sie selbst definieren.", ]; if (helpUrl) { elements.push(" "); elements.push( h("a", { href: helpUrl, target: "_blank", rel: "noopener noreferrer", style: "color: #0073aa; text-decoration: underline;", text: "Mehr erfahren →", }), ); } return h( "div", { class: "sk-help", style: "margin-top: 12px;" }, elements, ); })(), ]); } // Helper: Platzhalter Tabelle (mit Maximieren-Button und Modal) function renderPlaceholderTable( state, dispatch, placeholderNames, title, readOnly = false, helpText = null, ) { const qty = Number(state.answers.quantity || 0); const want = qty > 0 ? qty : 0; const clean = placeholderNames .map((p) => normalizePlaceholderName(p)) .filter((p) => !!p); if (clean.length === 0) return null; // Vorschau-Tabelle bauen function buildPreviewTable(currentState) { const table = h( "table", { class: "sk-table sk-table-pasteable", tabindex: "0", }, [], ); table.appendChild( h("thead", {}, [ h("tr", {}, [ h("th", { text: "Nr.", class: "sk-table-row-number" }), ...clean.map((p) => h("th", { text: `[[${p}]]` })), ]), ]), ); // Lokaler Cache für Platzhalter-Werte (wird bei focusout synchronisiert) const localValues = {}; for (const name of clean) { localValues[name] = [...(currentState.placeholderValues?.[name] || [])]; // Array auf richtige Länge bringen while (localValues[name].length < want) { localValues[name].push(""); } } const tbody = h("tbody", {}, []); for (let row = 0; row < want; row++) { const tr = h("tr", {}, [ h("td", { text: String(row + 1), class: "sk-table-row-number" }), ]); for (const name of clean) { const val = localValues[name][row] || ""; tr.appendChild( h("td", {}, [ h("input", { class: "sk-input", type: "text", value: val, "data-row": row, "data-col": name, "data-sk-focus": `placeholder.${name}.${row}`, oninput: (e) => { // Wert nur lokal speichern (kein Re-Render) localValues[name][row] = e.target.value; }, }), ]), ); } tbody.appendChild(tr); } table.appendChild(tbody); // Focusout-Handler mit Debounce - analog zur Empfängertabelle let focusoutTimer = null; let hasLocalChanges = false; // Track ob lokale Änderungen gemacht wurden table.addEventListener("focusout", (e) => { if (focusoutTimer) clearTimeout(focusoutTimer); focusoutTimer = setTimeout(() => { const newFocus = document.activeElement; if (!table.contains(newFocus) && hasLocalChanges) { // Nur dispatchen wenn lokale Änderungen gemacht wurden dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); hasLocalChanges = false; } }, 150); }); // Änderungen tracken bei Input table.addEventListener("input", () => { hasLocalChanges = true; }); // Paste-Handler für Excel-Daten - analog zur Empfängertabelle table.addEventListener("paste", (e) => { e.preventDefault(); const text = e.clipboardData.getData("text/plain"); if (!text) return; const pasteRows = text.split(/\r?\n/).filter((r) => r.trim()); if (pasteRows.length === 0) return; // Startposition finden const activeInput = document.activeElement; let startRow = 0; let startCol = 0; if (activeInput && activeInput.tagName === "INPUT") { const td = activeInput.closest("td"); const tr = td?.closest("tr"); if (tr) { const allRows = Array.from(tbody.children); startRow = allRows.indexOf(tr); if (startRow >= 0) { const allCells = Array.from(tr.children); const cellIdx = allCells.indexOf(td); startCol = cellIdx > 0 ? cellIdx - 1 : 0; } } } // Alle Änderungen in localValues sammeln let pastedCells = 0; pasteRows.forEach((rowText, rowOffset) => { const targetRow = startRow + rowOffset; if (targetRow >= want) return; const cells = rowText.split("\t"); cells.forEach((cellValue, colOffset) => { const targetCol = startCol + colOffset; if (targetCol >= clean.length) return; const name = clean[targetCol]; localValues[name][targetRow] = cellValue; pastedCells++; }); }); // Einmal dispatch für alle Änderungen if (pastedCells > 0) { dispatch({ type: "SET_PLACEHOLDER_VALUES", values: localValues }); } }); return table; } // Modal mit generischer Funktion öffnen function openPlaceholderModal() { createTableModal(dispatch, { title: title, columns: clean.map((name) => ({ key: name, label: `[[${name}]]`, })), getData: (state) => { const qty = Number(state.answers.quantity || 0); const want = qty > 0 ? qty : 0; const result = []; for (let row = 0; row < want; row++) { const rowData = {}; clean.forEach((name) => { rowData[name] = (state.placeholderValues?.[name] || [])[row] || ""; }); result.push(rowData); } return result; }, setData: (dispatch, data) => { // Daten in einzelne SET_PLACEHOLDER_VALUE Aktionen umwandeln data.forEach((rowData, rowIdx) => { clean.forEach((name) => { dispatch({ type: "SET_PLACEHOLDER_VALUE", name, row: rowIdx, value: rowData[name] || "", }); }); }); }, rowCount: (state) => { const qty = Number(state.answers.quantity || 0); return qty > 0 ? qty : 0; }, exportFilename: "platzhalter.csv", infoText: "Bitte erstellen Sie neue Platzhalter über die Weboberfläche. Anschließend können Sie auch diese importieren.", helpText: "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus.", getStateFunc: () => currentGlobalState, }); } const defaultHelpText = "Importieren Sie eine CSV-Datei, fügen Sie Daten aus Excel ein (Strg+V) oder füllen Sie die Tabelle manuell aus."; return renderCard(title, null, [ h("button", { type: "button", class: "sk-btn", text: "Tabelle maximieren", onclick: openPlaceholderModal, }), h("div", { class: "sk-table-wrapper" }, [buildPreviewTable(state)]), h("div", { class: "sk-help", style: "margin-top: 12px;" }, [ helpText || defaultHelpText, ]), ]); } // Preview Renderer function renderPreview(dom, state, dispatch) { // Prüfe ob wir eine existierende Vorschau-Box haben const existingPreviewCard = dom.preview.querySelector(".sk-preview-card"); const existingPreviewBox = existingPreviewCard?.querySelector(".sk-preview-box"); const hasActivePreview = existingPreviewBox && !existingPreviewBox.querySelector(".sk-preview-placeholder"); // Prüfe ob Step gespeichert ist und ob er sich geändert hat const lastStep = dom.preview.dataset.lastStep; const currentStep = state.step; const stepChanged = lastStep && lastStep !== String(currentStep); // Bei Step-Wechsel: Immer clearen und Preview-Manager clearen if (stepChanged) { clear(dom.preview); // Preview Manager resetten if (window.envelopePreviewManager) { window.envelopePreviewManager.currentBatchPreviews = []; window.envelopePreviewManager.currentBatchIndex = 0; window.envelopePreviewManager.previewCount = 0; } if (window.contentPreviewManager) { window.contentPreviewManager.currentBatchPreviews = []; window.contentPreviewManager.currentBatchIndex = 0; window.contentPreviewManager.previewCount = 0; } } // Sonst: Nur clearen wenn keine Preview geladen ist else { const hasEnvelopePreview = window.envelopePreviewManager?.currentBatchPreviews?.length > 0; const hasContentPreview = window.contentPreviewManager?.currentBatchPreviews?.length > 0; if (!hasEnvelopePreview && !hasContentPreview) { clear(dom.preview); } } // Step speichern für nächstes Mal dom.preview.dataset.lastStep = String(currentStep); // Show contact card on product, quantity, customer data and review step // (statt Vorschau bei Produkt und Allgemein) if ( state.step === STEPS.PRODUCT || state.step === STEPS.QUANTITY || state.step === STEPS.CUSTOMER_DATA || state.step === STEPS.REVIEW ) { clear(dom.preview); // Hier immer clearen, da Contact Card const contactCard = renderCard("Noch Fragen?", null, [ h("p", { style: "margin: 0 0 20px; font-size: 14px; color: #666; line-height: 1.6;", text: "Sie haben Fragen zu Ihrer Bestellung oder möchten eine individuelle Beratung? Wir sind gerne für Sie da und helfen Ihnen weiter!", }), h( "div", { style: "display: flex; align-items: center; gap: 12px; margin-bottom: 12px;", }, [ h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", style: "flex-shrink: 0; color: #0073aa;", innerHTML: '', }), h("a", { href: "tel:+4968722153", style: "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", text: "+49 (0)6872 2153", }), ], ), h("div", { style: "display: flex; align-items: center; gap: 12px;" }, [ h("svg", { xmlns: "http://www.w3.org/2000/svg", width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", "stroke-width": "2", "stroke-linecap": "round", "stroke-linejoin": "round", style: "flex-shrink: 0; color: #0073aa;", innerHTML: '', }), h("a", { href: "mailto:hello@skrift.de", style: "font-size: 15px; color: #333; text-decoration: none; font-weight: 500;", text: "hello@skrift.de", }), ]), ]); dom.preview.appendChild(contactCard); // Auch für Contact Card mobile synchronisieren syncMobilePreview(dom); return; } const envType = calcEffectiveEnvelopeType(state); const previewTitle = state?.ctx?.product?.label ? `Vorschau: ${state.ctx.product.label}` : "Vorschau"; // Format-Informationen ermitteln const fmt = state.answers.format; const productFormat = fmt === "a6p" ? "A6 Hochformat" : fmt === "a6l" ? "A6 Querformat" : fmt === "a4" ? "A4" : fmt?.toUpperCase() || "A4"; const envelopeFormat = envType === "dinlang" ? "DIN lang" : envType === "c6" ? "C6" : ""; // Formatinformationen für Umschlag-Step let formatInfo = ""; if (state.step === STEPS.ENVELOPE && envelopeFormat) { formatInfo = `Umschlagformat: ${envelopeFormat}`; } // Formatinformationen für Content-Step else if (state.step === STEPS.CONTENT) { formatInfo = `Schriftstückformat: ${productFormat}`; } const content = [ // Header mit Titel und Navigation h( "div", { class: "sk-preview-header", style: "display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;", }, [ h( "div", { style: "flex: 1;" }, [ h("div", { class: "sk-preview-title", text: previewTitle }), formatInfo ? h("div", { class: "sk-preview-sub", text: formatInfo, }) : null, ].filter(Boolean), ), h("div", { class: "sk-preview-navigation-container" }), ], ), h("div", { class: "sk-preview-box" }, [ h("div", { class: "sk-preview-placeholder", text: "Hier können Sie sich die Vorschau Ihres Produktes anzeigen lassen.", }), ]), // Status Anzeige direkt unter dem Dokument h("div", { class: "sk-preview-status", style: "margin-top: 10px; font-size: 14px; color: #666; text-align: center;", }), ].filter(Boolean); // Vorschau-Button für Umschlag (nur im Envelope-Step) if (state.step === STEPS.ENVELOPE) { const canGenerateEnvelopePreview = state.answers.shippingMode === "direct" || (state.answers.shippingMode === "bulk" && state.answers.envelope === true && (state.answers.envelopeMode === "recipientData" || state.answers.envelopeMode === "customText")); if (canGenerateEnvelopePreview) { const manager = window.envelopePreviewManager; const requestsRemaining = manager?.requestsRemaining || 10; const maxRequests = manager?.maxRequests || 10; // Request Counter content.push( h("div", { class: "sk-preview-request-counter", style: "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, }), ); // Button content.push( h("button", { type: "button", class: "sk-btn sk-btn-secondary sk-preview-generate-btn", style: "width: 100%; margin-top: 10px;", text: "Umschlag Vorschau generieren", onclick: async () => { if (window.envelopePreviewManager) { // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure const currentState = window.currentGlobalState || state; await window.envelopePreviewManager.generatePreviews( currentState, dom.preview, true, ); } }, }), ); } } // Vorschau-Button für Schriftstück (nur im Content-Step) if (state.step === STEPS.CONTENT) { const canGenerateContentPreview = state.answers.contentCreateMode === "self"; if (canGenerateContentPreview) { const manager = window.contentPreviewManager; const requestsRemaining = manager?.requestsRemaining || 10; const maxRequests = manager?.maxRequests || 10; // Request Counter content.push( h("div", { class: "sk-preview-request-counter", style: "margin-top: 10px; padding: 8px; background: #e3f2fd; border: 1px solid #2196f3; border-radius: 4px; color: #1565c0; font-size: 14px; text-align: center; font-weight: 500;", text: `Verbleibende Vorschau-Anfragen: ${requestsRemaining}/${maxRequests}`, }), ); // Button content.push( h("button", { type: "button", class: "sk-btn sk-btn-secondary sk-preview-generate-btn", style: "width: 100%; margin-top: 10px;", text: "Vorschau generieren", onclick: async () => { if (window.contentPreviewManager) { // WICHTIG: Aktuellen State verwenden, nicht den gecachten aus dem Closure const currentState = window.currentGlobalState || state; const result = await window.contentPreviewManager.generatePreviews( currentState, dom.preview, false, ); // Overflow-Warnung anzeigen wenn nötig const formContainer = document.getElementById("sk-form"); if (result && result.hasOverflow && formContainer) { showOverflowWarning(result.overflowFiles, formContainer); } else if (formContainer) { hideOverflowWarning(formContainer); } } }, }), ); } } // Nur hinzufügen wenn keine Preview-Card existiert const existingCard = dom.preview.querySelector(".sk-preview-card"); if (!existingCard) { dom.preview.appendChild(h("div", { class: "sk-preview-card" }, content)); } else { // Preview-Card existiert bereits - nur Button und Counter aktualisieren const existingCounter = existingCard.querySelector( ".sk-preview-request-counter", ); const newCounter = content.find( (el) => el && el.class === "sk-preview-request-counter", ); if (existingCounter && newCounter) { existingCounter.textContent = newCounter.text; } } // Mobile Preview synchronisieren syncMobilePreview(dom); } /** * Synchronisiert den Mobile Preview Container mit dem Desktop Preview * Klont den Inhalt und passt Event-Handler für die Buttons an * * Mobile Layout: * - previewMobile: Nur echte Vorschau-Inhalte (vor dem Weiter-Button) * - contactMobile: Nur Contact Card "Noch Fragen?" (nach dem Weiter-Button) */ function syncMobilePreview(dom) { // Prüfe ob Contact Card im Desktop-Preview ist const hasContactCard = dom.preview.querySelector(".sk-card-head h3")?.textContent === "Noch Fragen?"; // Contact Card Mobile synchronisieren if (dom.contactMobile) { clear(dom.contactMobile); if (hasContactCard) { // Contact Card in den Mobile-Contact-Container klonen dom.contactMobile.innerHTML = dom.preview.innerHTML; } } // Preview Mobile synchronisieren - nur wenn KEINE Contact Card if (dom.previewMobile) { clear(dom.previewMobile); // Nur echte Vorschau-Inhalte klonen (nicht Contact Card) if (!hasContactCard) { const desktopContent = dom.preview.innerHTML; if (desktopContent) { dom.previewMobile.innerHTML = desktopContent; // Event-Handler für Buttons im Mobile-Container neu binden bindMobilePreviewHandlers(dom); } } } } /** * Bindet Event-Handler für den Mobile Preview Container */ function bindMobilePreviewHandlers(dom) { if (!dom.previewMobile) return; const mobileGenerateBtn = dom.previewMobile.querySelector( ".sk-preview-generate-btn", ); if (mobileGenerateBtn) { mobileGenerateBtn.onclick = async () => { const currentState = window.currentGlobalState; if (!currentState) return; // Bestimme welcher Manager verwendet werden soll basierend auf dem aktuellen Step const isEnvelope = currentState.step === 2; // STEPS.ENVELOPE const manager = isEnvelope ? window.envelopePreviewManager : window.contentPreviewManager; if (manager) { // Preview in BEIDEN Containern aktualisieren const result = await manager.generatePreviews( currentState, dom.preview, isEnvelope, ); // Nach Generierung nochmal synchronisieren syncMobilePreview(dom); // Overflow-Warnung bei Briefen (nicht Umschläge) if (!isEnvelope) { const formContainer = document.getElementById("sk-form"); if (result && result.hasOverflow && formContainer) { showOverflowWarning(result.overflowFiles, formContainer); } else if (formContainer) { hideOverflowWarning(formContainer); } } } }; } // Navigation Buttons im Mobile-Container const mobilePrevBtn = dom.previewMobile.querySelector(".sk-preview-nav-prev"); const mobileNextBtn = dom.previewMobile.querySelector(".sk-preview-nav-next"); if (mobilePrevBtn) { mobilePrevBtn.onclick = () => { // Finde und klicke den Desktop-Button const desktopBtn = dom.preview.querySelector(".sk-preview-nav-prev"); if (desktopBtn) desktopBtn.click(); // Nach Navigation synchronisieren setTimeout(() => syncMobilePreview(dom), 50); }; } if (mobileNextBtn) { mobileNextBtn.onclick = () => { // Finde und klicke den Desktop-Button const desktopBtn = dom.preview.querySelector(".sk-preview-nav-next"); if (desktopBtn) desktopBtn.click(); // Nach Navigation synchronisieren setTimeout(() => syncMobilePreview(dom), 50); }; } // Klick auf Preview-Box für Modal const mobilePreviewBox = dom.previewMobile.querySelector(".sk-preview-box"); if (mobilePreviewBox) { mobilePreviewBox.onclick = () => { // Desktop-Preview-Box klicken, um Modal zu öffnen const desktopBox = dom.preview.querySelector(".sk-preview-box"); if (desktopBox) desktopBox.click(); }; } } // Main Render Function export function render({ state, dom, dispatch }) { // Globalen State aktualisieren für Modals und PreviewManager currentGlobalState = state; window.currentGlobalState = state; // noPrice-Modus: CSS-Klasse auf Root-Element setzen const root = dom.form?.closest(".sk-configurator"); if (root) { if (state.noPrice) { root.classList.add("sk-no-price"); } else { root.classList.remove("sk-no-price"); } } // Periodische Speicherung starten (nur einmal) startPersistInterval(); // Hydration VOR dem Rendern prüfen - wenn hydriert wird, // wird ein neuer Render mit dem hydrierten State ausgelöst, // daher diesen Render-Durchlauf abbrechen const didHydrate = hydrateIfPossible(state, dispatch); if (didHydrate) return; if (dom.stepper) renderStepper(dom.stepper, state, dispatch); renderTopbar(dom, state); const focusSnapshot = captureFocus(dom); clear(dom.form); let view = null; switch (state.step) { case STEPS.PRODUCT: view = renderProductStep(state, dispatch); break; case STEPS.QUANTITY: view = renderQuantityStep(state, dispatch); break; case STEPS.ENVELOPE: view = renderEnvelopeStep(state, dispatch); break; case STEPS.CONTENT: view = renderContentStep(state, dispatch); break; case STEPS.CUSTOMER_DATA: view = renderCustomerDataStep(state, dispatch); break; case STEPS.REVIEW: view = renderReviewStep(state, dispatch); break; } if (view) dom.form.appendChild(view); renderPreview(dom, state, dispatch); restoreFocus(dom, focusSnapshot); dom.prev.disabled = state.history.length === 0; dom.next.disabled = !validateStep(state) || state.step === STEPS.REVIEW; dom.next.textContent = state.step === STEPS.CUSTOMER_DATA ? "Weiter zur Prüfung" : "Weiter"; writePersisted(state); }