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

5447 lines
166 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `
<div class="sk-validation-spinner"></div>
<div class="sk-validation-text">Text wird geprüft...</div>
`;
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 `
<li class="sk-overflow-warning-item">
<span class="sk-overflow-warning-name">Empfänger in Tabellenzeile ${rowNumber}</span>
<span class="sk-overflow-warning-lines">${f.lineCount} / ${f.lineLimit} Zeilen</span>
</li>
`;
})
.join("");
// "und X weitere" Hinweis
const remainingHtml =
remaining > 0
? `<li class="sk-overflow-warning-item sk-overflow-warning-more">
<span class="sk-overflow-warning-name">... und ${remaining} weitere Empfänger</span>
<span class="sk-overflow-warning-lines"></span>
</li>`
: "";
warning.innerHTML = `
<div class="sk-overflow-warning-title">
Text zu lang für das gewählte Format
</div>
<ul class="sk-overflow-warning-list">
${itemsHtml}
${remainingHtml}
</ul>
<p class="sk-overflow-warning-hint">
Bitte kürzen Sie den Text oder reduzieren Sie die Zeilenanzahl, um fortzufahren.
Die Zeilennummern beziehen sich auf die Empfänger-/Platzhaltertabelle.
</p>
`;
// 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 = `
<div class="sk-validation-error-title">
Validierung nicht möglich
</div>
<p class="sk-validation-error-message">${escapeHtml(message)}</p>
<p class="sk-validation-error-hint">
Bitte laden Sie die Seite neu und versuchen Sie es erneut.
</p>
`;
// 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}&currency=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:
'<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path>',
}),
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:
'<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline>',
}),
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);
}