// 5-Schritt-Konfigurator + Bestätigungsseite
const { useState, useEffect, useMemo } = React;
const { Btn, navigate, PH, WFNote } = window.UI;
// Werte werden zur Render-Zeit aus window.SITE_DATA gelesen (vom Server geladen)
// Werte werden zur Render-Zeit aus window.SITE_DATA gelesen — Bootstrap
// (app.jsx) füllt SITE_DATA vor dem Render mit Server-Konfig.
const getSD = () => window.SITE_DATA || {};
const getANL = () => getSD().ANLAESSE || [];
const getBAU = () => getSD().BAUSTEINE || {};
const getTLBau = () => getSD().TIMELINE_BAUSTEINE || [];
const getTLVorlage = (anlassId) => {
const v = getSD().TIMELINE_VORLAGEN || {};
return (anlassId && v[anlassId]) || v.default || getSD().TIMELINE_VORLAGE || [];
};
const T = (k, fb='') => (getSD().TEXTE || {})[k] || fb;
// ============ State Hook (URL + localStorage persistent) ============
function useConfigState() {
const initial = () => {
// URL-Parameter (?anlass=hochzeit etc.) hat höchste Priorität — überschreibt localStorage
let urlAnlass = '';
try {
const sp = new URLSearchParams(window.location.search);
const v = (sp.get('anlass') || '').toLowerCase().trim();
const validIds = (getANL() || []).map(a => a.id);
if (v && validIds.includes(v)) urlAnlass = v;
} catch {}
try {
const saved = JSON.parse(localStorage.getItem('onkelfinke_config') || 'null');
if (saved) return urlAnlass ? { ...saved, anlass: urlAnlass } : saved;
} catch {}
return {
anlass: urlAnlass, location: '', personen: 50, datum: '', plz: '',
catering: '', getraenke: '', service: [], extras: [], beratung: 'unverbindlich',
timeline: [...getTLVorlage(urlAnlass)],
kontakt: { name: '', email: '', telefon: '', notiz: '' },
};
};
const [state, setState] = useState(initial);
useEffect(() => {
localStorage.setItem('onkelfinke_config', JSON.stringify(state));
}, [state]);
const update = (patch) => setState(s => ({ ...s, ...patch }));
const reset = () => { localStorage.removeItem('onkelfinke_config'); setState(initial); };
return [state, update, reset];
}
// ============ Preisberechnung ============
function calc(state) {
const p = Math.max(1, Number(state.personen) || 0);
let total = 0;
const lines = [];
const find = (key, id) => (getBAU()[key]?.options || []).find(o => o.id === id);
if (state.location) {
const o = find('location', state.location);
if (o) {
const sum = (o.unit === 'p.P.') ? o.price * p : o.price;
if (sum > 0) { total += sum; lines.push({ titel: 'Location · ' + o.label, sub: o.unit === 'p.P.' ? `${p} × ${o.price} €` : 'pauschal', sum }); }
else lines.push({ titel: 'Location · ' + o.label, sub: 'inkl.', sum: 0 });
}
}
if (state.catering) {
const o = find('catering', state.catering);
if (o) { const sum = o.price * p; total += sum; lines.push({ titel: 'Catering · ' + o.label, sub: `${p} × ${o.price} €`, sum }); }
}
if (state.getraenke) {
const o = find('getraenke', state.getraenke);
if (o && o.price > 0) { const sum = o.price * p; total += sum; lines.push({ titel: 'Getränke · ' + o.label, sub: `${p} × ${o.price} €`, sum }); }
else if (o) { lines.push({ titel: 'Getränke · ' + o.label, sub: 'auf Verbrauch / pauschal', sum: 0 }); }
}
state.service.forEach(id => {
const o = find('service', id);
if (o) { total += o.price; lines.push({ titel: 'Service · ' + o.label, sub: o.unit, sum: o.price }); }
});
state.extras.forEach(id => {
const o = find('extras', id);
if (o) {
const sum = o.unit === 'p.P.' ? o.price * p : o.price;
total += sum;
lines.push({ titel: 'Extra · ' + o.label, sub: o.unit === 'p.P.' ? `${p} × ${o.price} €` : 'pauschal', sum });
}
});
if (state.beratung && state.beratung !== 'unverbindlich') {
const o = find('beratung', state.beratung);
if (o && o.price > 0) { total += o.price; lines.push({ titel: 'Beratung · ' + o.label, sub: 'pauschal', sum: o.price }); }
}
return { total, lines };
}
// ============ Schritt-Header ============
const STEPS = [
{ n: 1, t: 'Anlass' },
{ n: 2, t: 'Location' },
{ n: 3, t: 'Catering' },
{ n: 4, t: 'Service' },
{ n: 5, t: 'Timeline' },
{ n: 6, t: 'Übersicht' },
];
function Stepper({ step, onJump }) {
const ref = React.useRef(null);
React.useEffect(() => {
const el = ref.current;
if (!el) return;
const active = el.querySelector('[data-active="true"]');
if (active && el.scrollWidth > el.clientWidth) {
const target = active.offsetLeft - 16;
el.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
}
}, [step]);
return (
{STEPS.map((s, i) => (
))}
);
}
// ============ Option Card ============
function OptionCard({ active, onClick, title, desc, price, unit, multi }) {
return (
);
}
// ============ Schritt 1: Anlass + Eckdaten ============
function Step1({ state, update }) {
return (
{T("step1_h", "Was feierst du?")}
Wir richten alles auf deinen Anlass aus — sag uns als erstes, worum's geht.
{getANL().map(a => (
))}
Eckdaten
);
}
// ============ Schritt 2: Location ============
function StepLocation({ state, update }) {
const cat = getBAU().location;
const options = (cat && cat.options) || [
{ id: 'eigene', label: 'Ich habe eine eigene Location', price: 0, unit: 'inkl.', desc: 'Du hast bereits einen Saal, Garten oder eine Halle.' },
{ id: 'suchen', label: 'Onkelfinke sucht eine Location', price: 180, unit: 'pauschal', desc: 'Wir finden die passende Location nach deinen Wünschen.' },
{ id: 'komplett', label: 'Komplettpaket mit Location', price: 0, unit: 'auf Anfrage', desc: 'Location + Catering + Service — alles aus einer Hand.' },
];
return (
{T("step_location_h", "Wo soll's stattfinden?")}
{T("step_location_p", "Hast du schon eine Location — oder sollen wir uns darum kümmern?")}
{options.map(o => (
update({ location: state.location === o.id ? '' : o.id })}
title={o.label} desc={o.desc} price={o.price} unit={o.unit} />
))}
);
}
// ============ Schritt 3: Catering + Getränke ============
function Step2({ state, update }) {
return (
{T("step2_h", "Was kommt auf den Tisch?")}
Catering und Getränke — wähle je eine Variante. Du kannst es später jederzeit ändern.
{['catering', 'getraenke'].map(key => {
const cat = getBAU()[key]; if (!cat) return null;
return (
{cat.title}
{cat.options.map(o => (
update({ [key]: state[key] === o.id ? '' : o.id })}
title={o.label} desc={o.desc} price={o.price} unit={o.unit} />
))}
);
})}
);
}
// ============ Schritt 3: Service + Extras ============
function Step3({ state, update }) {
const toggle = (key, id) => {
const list = state[key];
update({ [key]: list.includes(id) ? list.filter(x => x !== id) : [...list, id] });
};
return (
{T("step3_h", "Wer macht's?")}
Service und Extras — alles optional, alles dazubuchbar.
{['service', 'extras', 'beratung'].map(key => {
const cat = getBAU()[key]; if (!cat) return null;
return (
{cat.title}
{cat.options.map(o => {
const active = cat.multi ? state[key].includes(o.id) : state[key] === o.id;
return (
cat.multi ? toggle(key, o.id) : update({ [key]: state[key] === o.id ? '' : o.id })}
title={o.label} desc={o.desc} price={o.price} unit={o.unit} />
);
})}
);
})}
);
}
// ============ Schritt 4: Timeline (drag & drop) ============
function Step4({ state, update }) {
const [dragId, setDragId] = useState(null);
const onDragStart = (id) => (e) => {
setDragId(id);
e.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (idx) => (e) => { e.preventDefault(); };
const onDrop = (idx) => (e) => {
e.preventDefault();
if (!dragId) return;
const list = [...state.timeline];
const fromIdx = list.findIndex(x => x.id === dragId);
if (fromIdx < 0) {
// Aus Bausteinen ziehen
const tpl = getTLBau().find(x => x.id === dragId);
if (tpl) {
list.splice(idx, 0, { ...tpl, id: tpl.id + '-' + Date.now(), zeit: '' });
}
} else {
const [item] = list.splice(fromIdx, 1);
list.splice(idx, 0, item);
}
update({ timeline: list });
setDragId(null);
};
const removeItem = (id) => update({ timeline: state.timeline.filter(x => x.id !== id) });
const updateItem = (id, patch) => update({ timeline: state.timeline.map(x => x.id === id ? { ...x, ...patch } : x) });
return (
{T("step4_h", "Wie soll der Tag laufen?")}
Ein grober Plan — du kannst Bausteine reinziehen, Zeiten ändern, Reihenfolge anpassen. Wir machen daraus einen Ablauf.
{/* Timeline */}
Dein Ablauf
{state.timeline.map((item, idx) => (
⋮⋮
updateItem(item.id, { zeit: e.target.value })}
className="mono of-timeline-time" style={{ ...inputStyle, padding: '6px 8px', fontSize: 11, textAlign: 'center' }} />
{item.icon}
updateItem(item.id, { label: e.target.value })}
style={{ ...inputStyle, padding: '6px 8px', fontSize: 14 }} />
))}
Hier Baustein ablegen ↓
{/* Bausteine */}
Bausteine zum Ziehen
{getTLBau().map(b => (
{b.icon}
{b.label}
ziehen ⋮⋮
))}
);
}
// ============ Schritt 5: Übersicht + Kontakt ============
function Step5({ state, update }) {
const { total, lines } = calc(state);
return (
{T("step5_h", "Sieht das gut aus?")}
Prüf deine Auswahl, sag uns wie wir dich erreichen — wir melden uns innerhalb von 24h mit einem persönlichen Angebot.
{/* Kontaktformular */}
{/* Übersicht */}
Deine Konfiguration
Anlass
{getANL().find(a=>a.id===state.anlass)?.label || '—'}
Datum
{state.datum || '—'}
{lines.length === 0 &&
Noch nichts ausgewählt
}
{lines.map((l, i) => (
{l.sum > 0 ? l.sum.toLocaleString('de-DE') + ' €' : '—'}
))}
Geschätzte Summe
{total.toLocaleString('de-DE')} €
zzgl. MwSt · Anfahrt nach Aufwand · unverbindlich
);
}
// ============ Sticky Sidebar Summary (kompakte Live-Summe) ============
function StickySummary({ state }) {
const { total } = calc(state);
return (
Live-Vorschau
a.id===state.anlass)?.label || '—'} />
Geschätzt
{total.toLocaleString('de-DE')} €
zzgl. MwSt · unverbindlich
);
}
// ============ Konfigurator-Container ============
function ConfiguratorPage() {
const [state, update, reset] = useConfigState();
const [step, setStep] = useState(1);
// Anlass aus URL übernehmen
useEffect(() => {
const params = new URLSearchParams((window.location.hash.split('?')[1] || ''));
const a = params.get('anlass');
if (a && !state.anlass) update({ anlass: a });
}, []);
const next = () => { if (step < 6) setStep(step + 1); };
const prev = () => { if (step > 1) setStep(step - 1); else navigate('/'); };
const [submitting, setSubmitting] = useState(false);
const [submitErr, setSubmitErr] = useState('');
const submit = async () => {
const k = state.kontakt || {};
if (!k.name || !k.email) { setSubmitErr('Bitte Name und E-Mail angeben.'); return; }
const { total } = calc(state);
const payload = {
anlass: state.anlass,
personen: state.personen,
datum: state.datum,
plz: state.plz,
catering: state.catering,
getraenke: state.getraenke,
service: state.service,
extras: state.extras,
beratung: state.beratung,
timeline: state.timeline,
summe: total,
kontakt: k,
_hp: '', // Honeypot
};
setSubmitting(true);
setSubmitErr('');
try {
if (window.OF_API && window.OF_API.submit) {
await window.OF_API.submit(payload);
navigate('/konfigurator/danke');
return;
}
throw new Error('API nicht verfügbar');
} catch (e) {
setSubmitErr('Konnte Anfrage nicht senden: ' + (e.message || e) + ' — bitte direkt info@onkelfinke.de schreiben.');
} finally {
setSubmitting(false);
}
};
return (
<>
{step === 1 &&
}
{step === 2 &&
}
{step === 3 &&
}
{step === 4 &&
}
{step === 5 &&
}
{step === 6 &&
}
← Zurück
{step < 6 ? Weiter : {submitting ? 'Sende…' : 'Anfrage senden'}}
{submitErr &&
{submitErr}
}
>
);
}
// ============ Bestätigungsseite ============
function DankePage() {
const [state] = useConfigState();
const { total, lines } = calc(state);
return (
🥂
Danke,
{state.kontakt.name || 'du'}!
Wir haben deine Anfrage bekommen und melden uns innerhalb von 24 Stunden mit einem persönlichen Angebot per Mail oder Telefon.
Was passiert jetzt?
- Wir prüfen deine Konfiguration und schauen uns das Datum an.
- Du bekommst ein detailliertes Angebot per Mail.
- Wir telefonieren, justieren wo nötig — und du buchst.
📄 PDF herunterladen
navigate('/')}>Zur Startseite
{/* PDF-Vorschau */}
Vorschau · Anfrage-PDF
Onkelfinke
PARTYPLANER · CATERING
Anfrage
{new Date().toLocaleDateString('de-DE')}
Für
{state.kontakt.name || '—'}
{state.kontakt.email || '—'}
Anlass
{getANL().find(a=>a.id===state.anlass)?.label || '—'}
Datum
{state.datum || '—'}
Positionen
{lines.length === 0 &&
—
}
{lines.map((l, i) => (
{l.sum > 0 ? l.sum.toLocaleString('de-DE') + ' €' : '—'}
))}
Geschätzt
{total.toLocaleString('de-DE')} €
zzgl. MwSt · unverbindlich
Onkelfinke · Hauskoppelstieg 7 · 22111 Hamburg · info@onkelfinke.de · 0152 058 620 58
);
}
// Helpers
const inputStyle = { width: '100%', padding: '12px 14px', border: '1px solid var(--line-strong)', borderRadius: 4, background: 'var(--paper)', fontSize: 14, color: 'var(--navy)' };
const pillBtn = { padding: '8px 14px', border: '1px solid var(--line-strong)', borderRadius: 999, background: 'var(--paper)', cursor: 'pointer', fontFamily: '"Roboto Mono", monospace', fontSize: 11 };
function Field({ label, children }) {
return (
);
}
function Row({ k, v }) {
return (
{k}
{v}
);
}
window.CONFIGURATOR = { ConfiguratorPage, DankePage };