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

1146 lines
39 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.
/**
* 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();
}