/** * Skrift Preisrechner * Einfacher Preisrechner mit 2-Step-Flow: Produktauswahl -> Details & Berechnung * Nutzt die gleiche Preislogik und Produktdefinitionen wie der Konfigurator */ import { formatEUR, } from "./configurator-pricing.js?ver=0.3.0"; import { listProducts, getAvailableProductsForCustomerType, isFollowups as checkIsFollowups, supportsMotif as checkSupportsMotif, } from "./configurator-state.js?ver=0.3.0"; // ========== KONSTANTEN ========== const STEPS = { PRODUCT: 0, CALCULATOR: 1, }; // ========== STATE MANAGEMENT ========== function createInitialState() { return { step: STEPS.PRODUCT, customerType: null, // 'business' | 'private' product: null, // Calculator-Felder quantity: 100, format: 'a6h', // a4, a6h (a6p), a6q (a6l) shippingMode: 'direct', // direct, bulk envelope: true, // Bei Sammelversand: Kuvert ja/nein envelopeMode: 'recipientData', // recipientData, customText, none (Beschriftungsart) followupYearlyVolume: '50-199', followupCreateMode: 'manual', // auto, manual // Motiv (nur für Postkarten/Einladungen mit A6) motifNeed: false, motifSource: null, // upload, design // Inhalt contentCreateMode: 'self', // self, textservice }; } function reducer(state, action) { switch (action.type) { case 'SET_CUSTOMER_TYPE': return { ...state, customerType: action.customerType, product: null, // Reset Produkt bei Kundentyp-Wechsel }; case 'SET_PRODUCT': { // Default-Format setzen const defaultFormat = action.product.formats?.[0] || 'a4'; // Default-Menge je nach Kundentyp const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; const dynamicPricing = settings.dynamic_pricing || {}; const isB2B = state.customerType === 'business'; const normalQty = isB2B ? (dynamicPricing.business_normal_quantity || 200) : (dynamicPricing.private_normal_quantity || 50); return { ...state, product: action.product, format: defaultFormat, quantity: normalQty, step: STEPS.CALCULATOR, // Reset Motiv bei Produktwechsel motifNeed: false, motifSource: null, }; } case 'SET_QUANTITY': return { ...state, quantity: action.quantity }; case 'SET_FORMAT': return { ...state, format: action.format, // Motiv zurücksetzen wenn nicht mehr A6 motifNeed: action.format !== 'a4' ? state.motifNeed : false, motifSource: action.format !== 'a4' ? state.motifSource : null, }; case 'SET_SHIPPING_MODE': return { ...state, shippingMode: action.shippingMode, // Bei Direktversand ist Umschlag immer dabei mit Beschriftung envelope: action.shippingMode === 'direct' ? true : state.envelope, envelopeMode: action.shippingMode === 'direct' ? 'recipientData' : state.envelopeMode, }; case 'SET_ENVELOPE': return { ...state, envelope: action.envelope, // Wenn kein Kuvert, dann auch keine Beschriftung envelopeMode: action.envelope ? state.envelopeMode : 'none', }; case 'SET_ENVELOPE_MODE': return { ...state, envelopeMode: action.mode }; case 'SET_FOLLOWUP_VOLUME': return { ...state, followupYearlyVolume: action.volume }; case 'SET_FOLLOWUP_CREATE_MODE': return { ...state, followupCreateMode: action.mode }; case 'SET_MOTIF_NEED': return { ...state, motifNeed: action.motifNeed, motifSource: action.motifNeed ? (state.motifSource || 'upload') : null, }; case 'SET_MOTIF_SOURCE': return { ...state, motifSource: action.source }; case 'SET_CONTENT_CREATE_MODE': return { ...state, contentCreateMode: action.mode }; case 'GO_BACK': return { ...state, step: STEPS.PRODUCT, product: null, }; default: return state; } } // ========== PRODUKT-DEFINITIONEN ========== // Nutzt getAvailableProductsForCustomerType und listProducts aus configurator-state.js // Diese enthalten die korrekten Formate, supportsMotif, isFollowUp etc. // ========== PREIS-BERECHNUNG ========== function calculatePrice(state) { if (!state.product) return null; const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; const prices = settings.prices || {}; const dynamicPricing = settings.dynamic_pricing || {}; const isB2B = state.customerType === 'business'; const taxRate = (prices.tax_rate || 19) / 100; // Follow-ups haben spezielle Preislogik if (state.product.isFollowUp === true) { return calculateFollowupsPrice(state, prices, taxRate, isB2B); } // Normale Produkte return calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B); } function calculateFollowupsPrice(state, prices, taxRate, isB2B) { const basePrice = state.product.basePrice; // Multiplikator basierend auf Monatsvolumen const multipliers = { '5-49': prices.followup_mult_5_49 || 2.0, '50-199': prices.followup_mult_50_199 || 1.7, '200-499': prices.followup_mult_200_499 || 1.4, '500-999': prices.followup_mult_500_999 || 1.2, '1000+': prices.followup_mult_1000_plus || 1.0, }; const multiplier = multipliers[state.followupYearlyVolume] || 1.7; // A4-Aufschlag bei Follow-ups (wie bei anderen Produkten mit supportsMotif) let formatSurcharge = 0; if (state.format === 'a4' && state.product.supportsMotif) { formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; } const pricePerPiece = (basePrice * multiplier) + formatSurcharge; // Versandkosten (immer Direktversand bei Follow-ups, immer mit Kuvert + Beschriftung) const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; const shippingService = parseFloat(prices.shipping_service) || 0.95; const envelopeBase = parseFloat(prices.envelope_base) || 0.50; const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer dabei const shippingPerPiece = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; const totalPerPiece = pricePerPiece + shippingPerPiece; // Brutto/Netto const netPerPiece = totalPerPiece; const grossPerPiece = totalPerPiece * (1 + taxRate); // Einmalige Kosten const setupCosts = []; // API-Anbindung (nur bei automatischer Erstellung) if (state.followupCreateMode === 'auto') { const apiConnection = parseFloat(prices.api_connection) || 250.00; setupCosts.push({ label: 'API-Anbindung', amount: apiConnection }); } // Textservice (auch bei Follow-ups möglich) if (state.contentCreateMode === 'textservice') { const textservicePrice = parseFloat(prices.textservice) || 0; if (textservicePrice > 0) { setupCosts.push({ label: 'Textservice', amount: textservicePrice }); } } return { pricePerPiece: isB2B ? netPerPiece : grossPerPiece, isGross: !isB2B, details: { basePrice: basePrice, multiplier: multiplier, volumeTier: state.followupYearlyVolume, formatSurcharge: formatSurcharge, productPrice: pricePerPiece, shippingDomestic: shippingDomestic, shippingService: shippingService, envelopeBase: envelopeBase, envelopeLabeling: envelopeLabeling, shippingTotal: shippingPerPiece, netPerPiece: netPerPiece, grossPerPiece: grossPerPiece, taxRate: taxRate * 100, setupCosts: setupCosts, }, }; } function calculateStandardPrice(state, prices, dynamicPricing, taxRate, isB2B) { const basePrice = state.product.basePrice; const quantity = state.quantity || 1; // Dynamischer Multiplikator basierend auf Menge const normalQty = isB2B ? (dynamicPricing.business_normal_quantity || 200) : (dynamicPricing.private_normal_quantity || 50); let multiplier = 1.0; if (quantity < normalQty) { // Formel: 2 - sqrt(qty / normalQty) multiplier = 2 - Math.sqrt(quantity / normalQty); } // A4-Aufschlag für Postkarten/Einladungen let formatSurcharge = 0; const isPostcardLike = state.product.supportsMotif; if (state.format === 'a4' && isPostcardLike) { formatSurcharge = parseFloat(prices.a4_upgrade_surcharge) || 0.50; } const productPrice = (basePrice * multiplier) + formatSurcharge; // Versandkosten let shippingTotal = 0; let shippingDetails = {}; if (state.shippingMode === 'direct') { // Direktversand: Porto + Service + Kuvert + Beschriftung (immer dabei) const shippingDomestic = parseFloat(prices.shipping_domestic) || 0.95; const shippingService = parseFloat(prices.shipping_service) || 0.95; const envelopeBase = parseFloat(prices.envelope_base) || 0.50; const envelopeLabeling = parseFloat(prices.envelope_labeling) || 0.50; // Immer bei Direktversand shippingTotal = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; shippingDetails = { type: 'direct', porto: shippingDomestic, service: shippingService, envelope: envelopeBase, labeling: envelopeLabeling, }; } else { // Sammelversand: Nur Kuvert + optionale Beschriftung (KEINE anteilige Versandpauschale pro Stück!) let envelopeCost = 0; let labelingCost = 0; if (state.envelope) { envelopeCost = parseFloat(prices.envelope_base) || 0.50; // Beschriftung nur wenn envelopeMode nicht 'none' ist if (state.envelopeMode && state.envelopeMode !== 'none') { labelingCost = parseFloat(prices.envelope_labeling) || 0.50; } } shippingTotal = envelopeCost + labelingCost; shippingDetails = { type: 'bulk', flatRate: parseFloat(prices.shipping_bulk) || 4.95, // Wird separat als Pauschale angezeigt envelopeCost: envelopeCost, labelingCost: labelingCost, envelopeMode: state.envelopeMode, }; } const totalPerPiece = productPrice + shippingTotal; // Brutto/Netto const netPerPiece = totalPerPiece; const grossPerPiece = totalPerPiece * (1 + taxRate); // Einmalige Kosten const setupCosts = []; // Motiv-Upload if (state.motifNeed && state.motifSource === 'upload') { const motifUploadPrice = parseFloat(prices.motif_upload) || 0.30; if (motifUploadPrice > 0) { setupCosts.push({ label: 'Motiv-Upload', amount: motifUploadPrice }); } } // Motiv-Design if (state.motifNeed && state.motifSource === 'design') { const motifDesignPrice = parseFloat(prices.motif_design) || 0; if (motifDesignPrice > 0) { setupCosts.push({ label: 'Motiv-Design', amount: motifDesignPrice }); } } // Textservice if (state.contentCreateMode === 'textservice') { const textservicePrice = parseFloat(prices.textservice) || 0; if (textservicePrice > 0) { setupCosts.push({ label: 'Textservice', amount: textservicePrice }); } } // Bei Sammelversand: Versandpauschale zum Gesamtpreis hinzufügen (einmalig, nicht pro Stück) const bulkShippingFlat = (shippingDetails.type === 'bulk') ? (shippingDetails.flatRate || 0) : 0; const totalNet = (netPerPiece * quantity) + bulkShippingFlat; const totalGross = (grossPerPiece * quantity) + (bulkShippingFlat * (1 + taxRate)); return { pricePerPiece: isB2B ? netPerPiece : grossPerPiece, isGross: !isB2B, totalPrice: isB2B ? totalNet : totalGross, details: { basePrice: basePrice, multiplier: multiplier, normalQuantity: normalQty, formatSurcharge: formatSurcharge, productPrice: productPrice, shipping: shippingDetails, shippingTotal: shippingTotal, netPerPiece: netPerPiece, grossPerPiece: grossPerPiece, taxRate: taxRate * 100, quantity: quantity, setupCosts: setupCosts, }, }; } // ========== DOM HELPER ========== function h(tag, attrs = {}, children = []) { const el = document.createElement(tag); for (const [key, val] of Object.entries(attrs)) { if (key === 'text') { el.textContent = val; } else if (key === 'html') { el.innerHTML = val; } else if (key.startsWith('on') && typeof val === 'function') { el.addEventListener(key.slice(2).toLowerCase(), val); } else if (key === 'class') { el.className = val; } else if (key === 'style' && typeof val === 'object') { Object.assign(el.style, val); } else if (val !== null && val !== undefined) { el.setAttribute(key, val); } } for (const child of children) { if (typeof child === 'string') { el.appendChild(document.createTextNode(child)); } else if (child instanceof Node) { el.appendChild(child); } } return el; } // ========== UI RENDERING ========== function renderCard(title, subtitle, bodyContent) { return h('div', { class: 'sk-card' }, [ h('div', { class: 'sk-card-head' }, [ h('h3', { class: 'sk-card-title', text: title }), subtitle ? h('p', { class: 'sk-card-subtitle', text: subtitle }) : null, ].filter(Boolean)), h('div', { class: 'sk-card-body' }, bodyContent), ]); } function renderProductStep(state, dispatch) { const blocks = []; // Kundentyp Auswahl (identisch zum Konfigurator) 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.customerType === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_CUSTOMER_TYPE', customerType: opt.value }), }, [ h('input', { type: 'radio', name: 'customerType', checked: state.customerType === opt.value ? 'checked' : null, }), 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) - nutzt getAvailableProductsForCustomerType aus Konfigurator if (state.customerType) { const baseProducts = getAvailableProductsForCustomerType(state.customerType); const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; const productSettings = settings.products || {}; const prices = settings.prices || {}; const taxRate = (prices.tax_rate || 19) / 100; const isB2B = state.customerType === 'business'; // Produkte mit korrekten Labels und Preisen aus Settings anreichern const products = baseProducts.map(p => { const pSettings = productSettings[p.key] || {}; return { ...p, label: pSettings.label || p.label, description: pSettings.description || p.description, basePrice: parseFloat(pSettings.base_price) || p.basePrice || 2.50, }; }); const grid = h('div', { class: 'sk-selection-grid' }); for (const p of products) { const displayPrice = isB2B ? p.basePrice : p.basePrice * (1 + taxRate); const priceText = `ab ${formatEUR(displayPrice)}`; const card = h('div', { class: `sk-selection-card ${state.product?.key === p.key ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_PRODUCT', product: p }), }, [ h('div', { class: 'sk-selection-card-image' }, [ h('div', { text: '📄', style: 'font-size: 48px' }), ]), 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: p.description }), ]), ]); 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); } function renderCalculatorStep(state, dispatch) { const blocks = []; const settings = window.SkriftPreisrechner?.settings || window.SkriftConfigurator?.settings || {}; const prices = settings.prices || {}; const dynamicPricing = settings.dynamic_pricing || {}; const isB2B = state.customerType === 'business'; const taxRate = (prices.tax_rate || 19) / 100; // Hilfsfunktion: Bei B2C Brutto-Preis anzeigen (inkl. 19% MwSt.) const displayPrice = (nettoPrice) => isB2B ? nettoPrice : nettoPrice * (1 + taxRate); // Nutze Helper-Funktionen aus Konfigurator für korrekte Prüfung const isFollowups = state.product?.isFollowUp === true; const isPostcardLike = state.product?.supportsMotif === true; // Produkt-Info Header blocks.push(h('div', { class: 'sk-topbar' }, [ h('div', { class: 'sk-product-info' }, [ h('div', { class: 'sk-product-icon', text: state.product?.icon || '📄' }), h('div', {}, [ h('div', { class: 'sk-product-label', text: state.product?.label }), h('div', { class: 'sk-text-small sk-text-muted', text: isB2B ? 'Geschäftskunde' : 'Privatkunde' }), ]), ]), h('button', { class: 'sk-btn sk-btn-secondary', onclick: () => dispatch({ type: 'GO_BACK' }), text: '← Produkt ändern', }), ])); // ========== MENGE / FOLLOW-UP VOLUMEN ========== if (isFollowups) { // Follow-ups: Monatsvolumen (identisch zum Konfigurator) 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.followupYearlyVolume === vol.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_FOLLOWUP_VOLUME', volume: vol.value }), }, [ h('input', { type: 'radio', name: 'volume', checked: state.followupYearlyVolume === vol.value ? 'checked' : null, }), 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)] )); } else { // Normale Produkte: Menge (identisch zum Konfigurator) const minQuantity = isB2B ? (dynamicPricing.business_min_quantity || 50) : (dynamicPricing.private_min_quantity || 10); const normalQuantity = isB2B ? (dynamicPricing.business_normal_quantity || 200) : (dynamicPricing.private_normal_quantity || 50); const quantityInput = h('input', { class: 'sk-input', type: 'number', min: String(minQuantity), value: String(state.quantity), onchange: (e) => { let val = parseInt(e.target.value) || minQuantity; if (val < minQuantity) val = minQuantity; dispatch({ type: 'SET_QUANTITY', quantity: val }); }, }); blocks.push(renderCard( 'Wie viele Schriftstücke benötigen Sie?', `Mindestmenge: ${minQuantity} Stück • Unser bester Preis gilt ab ${normalQuantity} Stück`, [quantityInput] )); } // ========== FORMAT (nur A4 vs A6 fragen - Hoch/Querformat macht keinen Preisunterschied) ========== // Prüfen ob sowohl A4 als auch A6 verfügbar sind const hasA4 = state.product?.formats?.includes('a4'); const hasA6 = state.product?.formats?.some(f => f === 'a6p' || f === 'a6l'); if (hasA4 && hasA6) { const a4Surcharge = prices.a4_upgrade_surcharge || 0; const hasA4Surcharge = isPostcardLike; const formatOptions = [ { value: 'a6p', label: 'A6', desc: 'Kompaktes Format, ideal für Postkarten und kurze Nachrichten', price: '' }, { value: 'a4', label: 'A4', desc: 'Standardformat für ausführliche Briefe', price: hasA4Surcharge ? `+ ${formatEUR(displayPrice(a4Surcharge))}` : '' }, ]; const formatBody = formatOptions.map(opt => { // Für A6: prüfen ob a6p oder a6l aktuell gewählt ist const isSelected = opt.value === 'a4' ? state.format === 'a4' : (state.format === 'a6p' || state.format === 'a6l'); return h('div', { class: `sk-option ${isSelected ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_FORMAT', format: opt.value }), }, [ h('input', { type: 'radio', name: 'format', checked: isSelected ? 'checked' : null, }), 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( 'Welches Format soll das Schriftstück haben?', null, [h('div', { class: 'sk-options' }, formatBody)] )); } // ========== FOLLOW-UP ERSTELLUNGSMODUS ========== if (isFollowups) { const apiPrice = prices.api_connection || 250; const createModes = [ { value: 'auto', label: 'Automatisch aus System', desc: 'Wir verbinden uns mit Ihrem CRM/Shop-System', price: `Einmalig ${formatEUR(displayPrice(apiPrice))}`, }, { value: 'manual', label: 'Manuell', desc: 'Sie senden uns die Empfängerliste im gewählten Rhythmus', price: '', }, ]; const createBody = createModes.map(mode => h('div', { class: `sk-option ${state.followupCreateMode === mode.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_FOLLOWUP_CREATE_MODE', mode: mode.value }), }, [ h('input', { type: 'radio', name: 'createMode', checked: state.followupCreateMode === mode.value ? 'checked' : null, }), 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)] )); } // ========== VERSANDART (nicht bei Follow-ups) ========== if (!isFollowups) { const shippingBulk = prices.shipping_bulk || 4.95; const shippingDomestic = prices.shipping_domestic || 0.95; const shippingService = prices.shipping_service || 0.95; const envelopeBase = prices.envelope_base || 0.50; const envelopeLabeling = prices.envelope_labeling || 0.50; const domesticPrice = shippingDomestic + shippingService + envelopeBase + envelopeLabeling; const internationalPrice = (prices.shipping_international || 1.25) + shippingService + envelopeBase + envelopeLabeling; const shippingOptions = [ { value: 'direct', label: 'Einzeln an die Empfänger', desc: 'Wir versenden direkt an Ihre Empfänger – inklusive Kuvertierung und Beschriftung', price: `Inland: ${formatEUR(displayPrice(domesticPrice))} / Ausland: ${formatEUR(displayPrice(internationalPrice))} pro Stück`, }, { value: 'bulk', label: 'Sammelversand an Sie', desc: 'Sie erhalten alle Schriftstücke zur eigenen Verteilung', price: `Einmalig ${formatEUR(displayPrice(shippingBulk))} Versandkosten`, }, ]; const shippingBody = shippingOptions.map(opt => h('div', { class: `sk-option ${state.shippingMode === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_SHIPPING_MODE', shippingMode: opt.value }), }, [ h('input', { type: 'radio', name: 'shippingMode', checked: state.shippingMode === opt.value ? 'checked' : null, }), 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)] )); // ========== KUVERT BEI SAMMELVERSAND ========== if (state.shippingMode === 'bulk') { const envOptions = [ { value: true, label: 'Ja, ich wünsche ein Kuvert', price: `+ ${formatEUR(displayPrice(envelopeBase))}` }, { value: false, label: 'Nein, ich benötige kein Kuvert', price: '' }, ]; const envBody = envOptions.map(opt => h('div', { class: `sk-option ${state.envelope === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_ENVELOPE', envelope: opt.value }), }, [ h('input', { type: 'radio', name: 'envelope', checked: state.envelope === opt.value ? 'checked' : null, }), 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)] )); // Beschriftungsart nur wenn Kuvert gewählt if (state.envelope) { const modeOptions = [ { value: 'recipientData', label: 'Empfängerdaten', desc: 'Klassische Adressierung mit Name und Anschrift', price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, }, { value: 'customText', label: 'Individueller Text', desc: 'Freier Text mit Platzhaltern', price: `+ ${formatEUR(displayPrice(envelopeLabeling))}`, }, { value: 'none', label: 'Keine Beschriftung', desc: 'Umschlag bleibt unbeschriftet', price: '', }, ]; const modeBody = modeOptions.map(opt => h('div', { class: `sk-option ${state.envelopeMode === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_ENVELOPE_MODE', mode: opt.value }), }, [ h('input', { type: 'radio', name: 'envelopeMode', checked: state.envelopeMode === opt.value ? 'checked' : null, }), 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( 'Wie soll das Kuvert beschriftet werden?', null, [h('div', { class: 'sk-options' }, modeBody)] )); } } } // Follow-ups: Kein Umschlag-Optionen Block - immer Direktversand mit Kuvert // ========== MOTIV (nur bei Postkarten/Einladungen mit A6) ========== if (isPostcardLike && state.format !== 'a4') { 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.motifNeed === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_MOTIF_NEED', motifNeed: opt.value }), }, [ h('input', { type: 'radio', name: 'motif', checked: state.motifNeed === opt.value ? 'checked' : null, }), 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.motifNeed) { const motifUploadPrice = prices.motif_upload || 0.30; const motifDesignPrice = prices.motif_design || 0; const sourceOptions = [ { value: 'upload', label: 'Eigenes Motiv hochladen', desc: 'Sie laden Ihr eigenes Bild hoch', price: motifUploadPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifUploadPrice))}` : '', }, { value: 'design', label: 'Motiv erstellen lassen', desc: 'Wir gestalten ein Motiv nach Ihren Wünschen', price: motifDesignPrice > 0 ? `Einmalig ${formatEUR(displayPrice(motifDesignPrice))}` : '', }, ]; const sourceBody = sourceOptions.map(opt => h('div', { class: `sk-option ${state.motifSource === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_MOTIF_SOURCE', source: opt.value }), }, [ h('input', { type: 'radio', name: 'motifSource', checked: state.motifSource === opt.value ? 'checked' : null, }), 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)] )); } } // ========== TEXTERSTELLUNG (für alle Produkte inkl. Follow-ups) ========== { const textservicePrice = prices.textservice || 0; const contentOptions = [ { value: 'self', label: 'Ich erstelle den Text selbst', desc: 'Sie geben den vollständigen Text vor', price: '', }, { value: 'textservice', label: 'Textservice beauftragen', desc: 'Wir erstellen den Text professionell für Sie', price: textservicePrice > 0 ? `Einmalig ${formatEUR(displayPrice(textservicePrice))}` : '', }, ]; const contentBody = contentOptions.map(opt => h('div', { class: `sk-option ${state.contentCreateMode === opt.value ? 'is-selected' : ''}`, onclick: () => dispatch({ type: 'SET_CONTENT_CREATE_MODE', mode: opt.value }), }, [ h('input', { type: 'radio', name: 'contentMode', checked: state.contentCreateMode === opt.value ? 'checked' : null, }), 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)] )); } return h('div', { class: 'sk-stack' }, blocks); } function renderPriceSidebar(state) { const priceData = calculatePrice(state); if (!priceData) { return h('div', { class: 'sk-preview-card' }, [ h('div', { class: 'sk-preview-title', text: 'Preisberechnung' }), h('div', { class: 'sk-preview-sub', text: 'Wählen Sie ein Produkt aus' }), ]); } const isFollowups = state.product?.isFollowUp === true; const taxRate = Math.round(priceData.details.taxRate) || 19; const taxMultiplier = priceData.isGross ? (1 + taxRate / 100) : 1; // Brutto-Multiplikator für B2C const taxNote = priceData.isGross ? `inkl. ${taxRate}% MwSt.` : `zzgl. ${taxRate}% MwSt.`; // Preiszeilen sammeln - bei B2C (isGross) Brutto-Werte anzeigen const priceRows = []; // Produktpreis (dynamisch) - OHNE Formataufschlag, da dieser separat angezeigt wird const baseProductPrice = priceData.details.productPrice - (priceData.details.formatSurcharge || 0); priceRows.push({ label: 'Schriftstück', value: baseProductPrice * taxMultiplier }); // Format-Aufschlag (falls vorhanden) if (priceData.details.formatSurcharge > 0) { priceRows.push({ label: 'A4-Aufschlag', value: priceData.details.formatSurcharge * taxMultiplier }); } // Direktversand: Zusammengefasst als ein Posten if (priceData.details.shipping?.type === 'direct') { const totalDirectShipping = priceData.details.shipping.porto + priceData.details.shipping.service + priceData.details.shipping.envelope + (priceData.details.shipping.labeling || 0); priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); } else if (priceData.details.shipping?.type === 'bulk') { // Sammelversand: Kuvert + Beschriftung (falls gewählt) if (priceData.details.shipping.envelopeCost > 0) { priceRows.push({ label: 'Kuvert', value: priceData.details.shipping.envelopeCost * taxMultiplier }); } if (priceData.details.shipping.labelingCost > 0) { // Label je nach Modus const labelText = priceData.details.shipping.envelopeMode === 'customText' ? 'Individueller Text' : 'Empfängerdaten'; priceRows.push({ label: labelText, value: priceData.details.shipping.labelingCost * taxMultiplier }); } // KEINE anteilige Versandpauschale pro Stück - wird separat als Pauschale angezeigt } // Follow-ups: Versand zusammengefasst if (isFollowups) { const totalDirectShipping = priceData.details.shippingDomestic + priceData.details.shippingService + priceData.details.envelopeBase + (priceData.details.envelopeLabeling || 0); priceRows.push({ label: 'Einzeln an Empfänger', value: totalDirectShipping * taxMultiplier }); } // Preistabelle rendern const priceTable = h('table', { class: 'sk-calc-price-table' }, [ h('tbody', {}, priceRows.map(row => h('tr', {}, [ h('td', { text: row.label }), h('td', { text: formatEUR(row.value) }), ]) )), ]); // Einmalige Kosten let setupSection = null; if (priceData.details.setupCosts?.length > 0) { const setupTable = h('table', { class: 'sk-calc-price-table' }, [ h('tbody', {}, priceData.details.setupCosts.map(cost => h('tr', {}, [ h('td', { text: cost.label }), h('td', { text: formatEUR(cost.amount * taxMultiplier) }), ]) )), ]); setupSection = h('div', { class: 'sk-calc-setup-section' }, [ h('div', { class: 'sk-calc-section-label', text: 'Einmalige Kosten' }), setupTable, ]); } // Gesamtpreis (nur bei Standardprodukten) - netto für B2B, brutto für B2C let totalSection = null; if (!isFollowups && priceData.totalPrice) { const totalLabel = priceData.isGross ? `Gesamt inkl. MwSt. (${state.quantity} St.)` : `Gesamt netto (${state.quantity} St.)`; totalSection = h('div', { class: 'sk-calc-total-section' }, [ h('div', { class: 'sk-calc-total-row' }, [ h('span', { text: totalLabel }), h('span', { class: 'sk-calc-total-value', text: formatEUR(priceData.totalPrice) }), ]), ]); } // Sammelversand Hinweis let bulkNote = null; if (priceData.details.shipping?.type === 'bulk') { bulkNote = h('div', { class: 'sk-calc-note' }, [ h('span', { text: `zzgl. ${formatEUR(priceData.details.shipping.flatRate)} Versandpauschale` }), ]); } // Mengenhinweis let quantityNote = null; if (!isFollowups && priceData.details.multiplier > 1.01) { quantityNote = h('div', { class: 'sk-calc-note sk-calc-note--info' }, [ h('span', { text: `Tipp: Ab ${priceData.details.normalQuantity} Stück erhalten Sie unseren besten Preis.` }), ]); } // Staffelpreis-Hinweis bei Follow-ups let tierNote = null; if (isFollowups) { tierNote = h('div', { class: 'sk-calc-note' }, [ h('span', { text: `Staffelpreis für ${priceData.details.volumeTier} Follow-ups/Monat` }), ]); } return h('div', { class: 'sk-calc-sidebar-card' }, [ // Hauptpreis h('div', { class: 'sk-calc-main-price' }, [ h('div', { class: 'sk-calc-main-price__value', text: formatEUR(priceData.pricePerPiece) }), h('div', { class: 'sk-calc-main-price__label', text: 'pro Stück' }), h('div', { class: 'sk-calc-main-price__note', text: taxNote }), ]), // Preisaufschlüsselung h('div', { class: 'sk-calc-details' }, [ priceTable, bulkNote, quantityNote, tierNote, ].filter(Boolean)), // Einmalige Kosten setupSection, // Gesamtpreis totalSection, // CTA Button h('a', { class: 'sk-calc-cta-btn', href: `/konfigurator/?${state.product.key}`, }, [ h('span', { text: 'Jetzt konfigurieren' }), h('span', { class: 'sk-calc-cta-arrow', text: '→' }), ]), ].filter(Boolean)); } // ========== MAIN RENDER ========== function render(state, dispatch) { const container = document.querySelector('[data-skrift-preisrechner]'); if (!container) return; // Bei Produktauswahl: Volle Breite (1200px wie Calculator) if (state.step === STEPS.PRODUCT) { const wrapper = h('div', {}); wrapper.appendChild(renderProductStep(state, dispatch)); container.innerHTML = ''; container.appendChild(wrapper); return; } // Calculator-Step: 2-Spalten-Layout mit Sidebar const layout = h('div', { class: 'sk-configurator__layout' }); const main = h('div', { class: 'sk-main' }); const side = h('aside', { class: 'sk-side' }); // Hauptinhalt const calculatorContent = renderCalculatorStep(state, dispatch); main.appendChild(calculatorContent); // Mobile Sidebar (wird am Ende des Hauptinhalts angezeigt) const mobileSidebar = h('div', { class: 'sk-calc-mobile-sidebar' }); mobileSidebar.appendChild(renderPriceSidebar(state)); main.appendChild(mobileSidebar); // Desktop Sidebar side.appendChild(renderPriceSidebar(state)); layout.appendChild(main); layout.appendChild(side); container.innerHTML = ''; container.appendChild(layout); } // ========== INIT ========== export function initPriceCalculator() { const container = document.querySelector('[data-skrift-preisrechner]'); if (!container) return; let state = createInitialState(); function dispatch(action) { state = reducer(state, action); render(state, dispatch); } // Initial render render(state, dispatch); } // Auto-Init wenn DOM ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPriceCalculator); } else { initPriceCalculator(); }