// Boekingswizard — 4-stap, gewired aan API
function Boeking({ onNav }) {
const [step, setStep] = React.useState(1);
const [service, setService] = React.useState(null);
// Pre-vul kenteken + voertuig vanuit home (sessionStorage)
const [plate, setPlate] = React.useState(() => {
try { return sessionStorage.getItem('gd_plate') || ""; } catch (_) { return ""; }
});
const [vehicle, setVehicle] = React.useState(() => {
try {
const v = sessionStorage.getItem('gd_vehicle');
return v ? JSON.parse(v) : null;
} catch (_) { return null; }
});
const [mileage, setMileage] = React.useState("");
const [userVehicles, setUserVehicles] = React.useState([]); // opgeslagen voertuigen van ingelogde klant
const [selectedVehicleId, setSelectedVehicleId] = React.useState(null); // gekozen opgeslagen voertuig
const [selDay, setSelDay] = React.useState(null);
const [selTime, setSelTime] = React.useState(null);
const [form, setForm] = React.useState({
name: "", email: "", phone: "", address: "", notes: "",
haalbreng: false, vervangend: false, terms: false,
});
const [confirmed, setConfirmed] = React.useState(false);
const [submitError, setSubmitError] = React.useState(null);
const [submitting, setSubmitting] = React.useState(false);
const [services, setServices] = React.useState([]);
const [slots, setSlots] = React.useState([]);
// Fallback iconen per service slug
const ICON_BY_SLUG = {
apk: I.Apk, "apk-combi": I.Apk, herkeuring: I.Apk,
"beurt-klein": I.Wrench, "beurt-groot": I.Cog,
"remmen-voor": I.Cog, "remmen-set": I.Cog, distributie: I.Cog,
diagnose: I.Diag, "diag-oem": I.Diag, "diag-mech": I.Diag,
reparatie: I.Cog,
"banden-wis": I.Tire, "banden-mont": I.Tire, "banden-opsl": I.Tire,
airco: I.Snow, "airco-lek": I.Snow,
uitlaat: I.Cog, accu: I.Battery, ruit: I.ShieldCheck,
};
// Diensten ophalen bij mount
React.useEffect(() => {
if (!window.api) return;
window.api.services()
.then(d => {
const top = d.services.filter(s => s.duration_min > 0).slice(0, 8).map(s => ({
id: s.id, slug: s.slug, name: s.name, duration: s.duration_min,
price: s.price, desc: s.description,
icon: ICON_BY_SLUG[s.slug] || I.Wrench,
}));
setServices(top);
})
.catch(() => {/* fallback to mock below */});
// Opgeslagen voertuigen van ingelogde klant ophalen (faalt stil als niet ingelogd)
window.api.auth.me()
.then(d => {
const vs = d.vehicles || [];
setUserVehicles(vs);
if (vs.length && !plate) {
// Auto-select het primaire (eerste) voertuig
setSelectedVehicleId(vs[0].id);
setPlate(vs[0].license_plate);
setVehicle({
make: vs[0].make, model: vs[0].model, year: vs[0].year,
fuel_type: vs[0].fuel_type, apk_expiry: vs[0].apk_expiry,
});
// Naam/email/telefoon ook prefillen voor stap 4
setForm(f => ({ ...f, name: d.user.name || f.name, email: d.user.email || f.email, phone: d.user.phone || f.phone }));
}
})
.catch(() => {/* niet ingelogd, dat mag */});
}, []);
// Mock fallback als API niet werkt
const fallbackServices = [
{ id: 1, slug: "apk", icon: I.Apk, name: "APK keuring", duration: 45, price: 55, desc: "RDW erkend. Inclusief uitgebreid rapport." },
{ id: 4, slug: "beurt-klein", icon: I.Wrench, name: "Kleine beurt", duration: 60, price: 89, desc: "Olie + filter + 30-punts inspectie." },
{ id: 5, slug: "beurt-groot", icon: I.Cog, name: "Grote beurt", duration: 180, price: 249, desc: "Volledig onderhoud." },
{ id: 16, slug: "airco", icon: I.Snow, name: "Airco service", duration: 30, price: 69, desc: "F-gassen — R134a + R1234yf." },
{ id: 13, slug: "banden-wis", icon: I.Tire, name: "Banden wisselen (4)", duration: 30, price: 40, desc: "Op velg." },
{ id: 9, slug: "diagnose", icon: I.Diag, name: "Diagnose / uitleescode", duration: 30, price: 45, desc: "Universeel." },
{ id: 12, slug: "reparatie", icon: I.Cog, name: "Reparatie / anders", duration: null, price: null, desc: "Op maat." },
];
const displayServices = services.length ? services : fallbackServices;
// Vehicle lookup wanneer plate >= 6
React.useEffect(() => {
const clean = plate.replace(/[^A-Z0-9]/gi, "");
if (clean.length < 6 || !window.api) { return; }
const t = setTimeout(async () => {
try { const v = await window.api.kenteken.lookup(plate); setVehicle(v); }
catch (e) { setVehicle(null); }
}, 400);
return () => clearTimeout(t);
}, [plate]);
// Vrije slots ophalen wanneer dag + service gekozen
React.useEffect(() => {
if (!service || !selDay || !window.api) { setSlots([]); return; }
const dateStr = selDay instanceof Date
? selDay.toISOString().slice(0, 10)
: selDay;
window.api.availability(service.id, dateStr)
.then(d => setSlots(d.slots || []))
.catch(() => setSlots([]));
}, [service, selDay]);
const goNext = () => setStep(s => Math.min(s + 1, 4));
const goBack = () => setStep(s => Math.max(s - 1, 1));
// Validation per step
const canNext = (
(step === 1 && service) ||
(step === 2 && plate.length >= 6) ||
(step === 3 && selDay && selTime) ||
(step === 4 && form.name && form.email && form.phone && form.terms)
);
return (
{/* Heading band */}
onNav("home")} className="btn btn-ghost btn-sm" style={{ marginBottom: 24 }}>
Terug naar home
Afspraak maken
{confirmed ? "Bevestigd, tot ziens." : "Plan uw bezoek in 4 stappen."}
{!confirmed && (
Duurt < 60 seconden
)}
{/* Stepper */}
{!confirmed &&
}
{confirmed ? (
) : (
{/* Left: step content */}
{step === 1 && (
)}
{step === 2 && (
)}
{step === 3 && (
)}
{step === 4 && (
)}
{/* Nav buttons */}
Vorige
{
if (step !== 4) return goNext();
if (!window.api) { setConfirmed(true); return; }
setSubmitError(null);
setSubmitting(true);
try {
// Probeer eerst registreren als nog geen account, fallback op direct boeken
try {
await window.api.auth.register({
email: form.email,
password: form.phone || `Garage${Date.now()}`,
name: form.name,
phone: form.phone,
});
} catch (_) { /* bestaat al — proberen we te boeken */ }
const startDate = selDay instanceof Date ? selDay.toISOString().slice(0, 10) : selDay;
const startAt = `${startDate}T${selTime}:00`;
const apptPayload = {
service_id: service.id,
start_at: startAt,
notes: form.notes || null,
};
if (selectedVehicleId) {
apptPayload.vehicle_id = selectedVehicleId;
} else {
apptPayload.vehicle = {
license_plate: plate,
make: vehicle?.make,
model: vehicle?.model,
year: vehicle?.year,
fuel_type: vehicle?.fuel_type,
mileage: mileage ? Number(mileage) : null,
apk_expiry: vehicle?.apk_expiry,
};
}
await window.api.appointments.create(apptPayload);
try { sessionStorage.removeItem('gd_plate'); sessionStorage.removeItem('gd_vehicle'); } catch (_) {}
setConfirmed(true);
} catch (err) {
setSubmitError(err.message || "Er ging iets mis bij het boeken");
} finally {
setSubmitting(false);
}
}}
disabled={!canNext || submitting}
style={{ opacity: (canNext && !submitting) ? 1 : 0.4, pointerEvents: (canNext && !submitting) ? "auto" : "none" }}
>
{submitting ? "Bezig..." : (step === 4 ? "Bevestig afspraak" : "Volgende")}
{submitError && (
{submitError}
)}
{/* Right: live summary */}
)}
);
}
function Stepper({ step }) {
const labels = ["Dienst", "Voertuig", "Tijdstip", "Gegevens"];
return (
{labels.map((l, i) => {
const n = i + 1;
const done = n < step;
const cur = n === step;
return (
{done ? : n}
{n < 4 && (
)}
);
})}
);
}
// ─── STEP 1 ─────────────────────────────────────────────
function Step1({ services, value, onChange }) {
return (
Welke service heeft u nodig?
Niet zeker? Kies "Reparatie / anders" en beschrijf het probleem.
{services.map(s => {
const sel = value?.id === s.id;
return (
onChange(s)}
className="card lift"
style={{
padding: 22, textAlign: "left", cursor: "pointer",
background: sel ? "color-mix(in oklab, var(--accent) 8%, var(--surface))" : "var(--surface)",
border: sel ? "1.5px solid var(--accent)" : "1px solid var(--border)",
color: "var(--text)",
position: "relative",
}}
>
{s.duration ? `${s.duration} min` : "Variabel"}
{s.price ? `vanaf €${s.price}` : "op maat"}
);
})}
);
}
// ─── STEP 2 ─────────────────────────────────────────────
function Step2({ plate, setPlate, vehicle, setVehicle, mileage, setMileage,
userVehicles = [], selectedVehicleId, setSelectedVehicleId }) {
// "Ander voertuig" mode — toont kenteken-input ook al heeft de klant opgeslagen voertuigen
const [otherMode, setOtherMode] = React.useState(false);
const showSavedPicker = userVehicles.length > 0 && !otherMode;
// Een opgeslagen voertuig kiezen
const pickSaved = (v) => {
setSelectedVehicleId(v.id);
setPlate(v.license_plate);
setVehicle({ make: v.make, model: v.model, year: v.year, fuel_type: v.fuel_type, apk_expiry: v.apk_expiry });
setMileage(v.mileage ? String(v.mileage) : "");
};
// Schakelen naar "ander voertuig" — wis selectie zodat kenteken-input opent
const switchToOther = () => {
setOtherMode(true);
setSelectedVehicleId(null);
setPlate("");
setVehicle(null);
setMileage("");
};
// RDW lookup wanneer in "ander voertuig" mode of niet ingelogd
React.useEffect(() => {
if (selectedVehicleId) return; // niet looken-up als opgeslagen voertuig actief is
const clean = plate.replace(/[^A-Z0-9]/gi, "");
if (clean.length < 6) { setVehicle(null); return; }
if (!window.api) return;
const t = setTimeout(async () => {
try {
const v = await window.api.kenteken.lookup(plate);
setVehicle(v);
} catch (e) {
setVehicle(null);
}
}, 400);
return () => clearTimeout(t);
}, [plate, selectedVehicleId]);
return (
Welk voertuig?
{showSavedPicker
? "Kies een opgeslagen voertuig of voeg er een toe via kenteken."
: "We halen de auto-gegevens automatisch op via de RDW."}
{showSavedPicker && (
{userVehicles.map(v => {
const sel = selectedVehicleId === v.id;
const apkDate = v.apk_expiry ? new Date(v.apk_expiry) : null;
return (
pickSaved(v)}
className="card lift"
style={{
padding: 18, textAlign: "left", cursor: "pointer",
background: sel ? "color-mix(in oklab, var(--accent) 8%, var(--surface))" : "var(--surface)",
border: sel ? "1.5px solid var(--accent)" : "1px solid var(--border)",
display: "flex", justifyContent: "space-between", alignItems: "center", gap: 16,
}}>
{[v.make, v.model].filter(Boolean).join(" ") || "Onbekend"}
{v.year ? `Bouwjaar ${v.year}` : ""}
{v.fuel_type ? ` · ${v.fuel_type}` : ""}
{apkDate ? ` · APK tot ${apkDate.toLocaleDateString("nl-NL")}` : ""}
{sel && Gekozen }
);
})}
Ander voertuig (kenteken invoeren)
)}
{!showSavedPicker && userVehicles.length > 0 && (
{ setOtherMode(false); }}
className="btn btn-ghost btn-sm" style={{ marginBottom: 14 }}>
Terug naar mijn voertuigen
)}
Kenteken
{/* RDW result */}
{plate.length < 6 ? (
voer kenteken in voor auto-detectie
) : !vehicle ? (
RDW raadplegen…
) : (
{[vehicle.make || vehicle.merk, vehicle.model].filter(Boolean).join(' ')}
{vehicle.color || vehicle.kleur || ''}
Gevonden
)}
{vehicle && (
)}
);
}
function Spec({ label, value, accent }) {
return (
);
}
// ─── STEP 3 ─────────────────────────────────────────────
function Step3({ service, selDay, setSelDay, selTime, setSelTime }) {
const today = new Date(2026, 4, 4); // 4 mei 2026 (maandag)
const [monthOffset, setMonthOffset] = React.useState(0);
// Build month grid
const month = new Date(today.getFullYear(), today.getMonth() + monthOffset, 1);
const monthName = month.toLocaleDateString("nl-NL", { month: "long", year: "numeric" });
const firstDow = (month.getDay() + 6) % 7; // Mon=0
const daysInMonth = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate();
// Random availability stable per date
const availabilityFor = (d) => {
if (!d) return "none";
const date = new Date(month.getFullYear(), month.getMonth(), d);
if (date < today) return "past";
const dow = date.getDay();
if (dow === 0) return "closed"; // Sunday
if (date.toDateString() === new Date(2026, 4, 5).toDateString()) return "closed"; // Bevrijdingsdag
const seed = (d * 7 + month.getMonth() * 13) % 10;
if (seed < 2) return "full";
if (seed < 5) return "limited";
return "free";
};
// Slots for selected day
const slotsFor = (date) => {
if (!date) return [];
const all = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30", "11:00", "11:30",
"13:00", "13:30", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00"];
const seed = (date * 31) % 10;
return all.map((s, i) => ({
time: s,
free: ((i + seed) % 4) !== 0,
}));
};
const cells = [];
for (let i = 0; i < firstDow; i++) cells.push(null);
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
while (cells.length < 42) cells.push(null);
return (
Wanneer komt het uit?
Geblokte tijden zijn al volgeboekt. Slot duurt {service?.duration || 60} min — buffer inclusief.
{/* Calendar */}
setMonthOffset(o => o - 1)} className="btn btn-ghost btn-sm">
{monthName}
setMonthOffset(o => o + 1)} className="btn btn-ghost btn-sm">
{["Ma","Di","Wo","Do","Vr","Za","Zo"].map(d => (
{d}
))}
{cells.map((d, i) => {
if (!d) return
;
const av = availabilityFor(d);
const isSel = selDay === d && monthOffset === 0;
const disabled = av === "past" || av === "full" || av === "closed" || av === "none";
const colorMap = {
free: "var(--success)",
limited: "var(--warning)",
full: "var(--text-muted)",
closed: "var(--text-muted)",
past: "var(--text-muted)",
};
return (
{ if (!disabled) { setSelDay(d); setSelTime(null); } }}
disabled={disabled}
style={{
height: 44, borderRadius: 8,
background: isSel ? "var(--accent)" : "transparent",
border: isSel ? "1.5px solid var(--accent)" : "1px solid var(--border)",
color: isSel ? "#0B0B0C" : disabled ? "var(--text-muted)" : "var(--text)",
fontWeight: 600, fontSize: 14, fontFamily: "var(--font-mono)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: av === "past" || av === "closed" ? 0.3 : 1,
position: "relative",
transition: "all .15s ease",
}}
>
{d}
{!isSel && !disabled && (
)}
);
})}
{/* Time slots */}
{selDay ? `${selDay} ${monthName}` : "Kies eerst een dag"}
{selDay ? (
{slotsFor(selDay).map((s, i) => (
s.free && setSelTime(s.time)}
disabled={!s.free}
style={{
padding: "12px 14px",
borderRadius: 8,
background: selTime === s.time ? "var(--accent)" : "var(--surface-2)",
border: "1px solid " + (selTime === s.time ? "var(--accent)" : "var(--border)"),
color: selTime === s.time ? "#0B0B0C" : s.free ? "var(--text)" : "var(--text-muted)",
cursor: s.free ? "pointer" : "not-allowed",
opacity: s.free ? 1 : 0.4,
fontFamily: "var(--font-mono)", fontWeight: 600, fontSize: 13,
textDecoration: s.free ? "none" : "line-through",
}}>
{s.time}
))}
) : (
kies een dag
)}
);
}
function Legend({ color, border, label }) {
return (
{label}
);
}
// ─── STEP 4 ─────────────────────────────────────────────
function Step4({ form, setForm }) {
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
return (
Uw gegevens
We sturen een bevestiging per e-mail en SMS-reminder 24u vooraf.
set("haalbreng", v)}
label="Haal- en brengservice"
sub="We halen uw auto thuis op (binnen 15 km, +€19)"/>
set("vervangend", v)}
label="Vervangend vervoer"
sub="Leenauto beschikbaar (op aanvraag, gratis bij > 4u werkplaatstijd)"/>
set("terms", v)}
label={Ik ga akkoord met de algemene voorwaarden en het privacybeleid. }
required/>
);
}
function Toggle({ checked, onChange, label, sub, required }) {
return (
onChange(!checked)} style={{
background: "none", border: 0, padding: 0, textAlign: "left", cursor: "pointer",
display: "flex", alignItems: "flex-start", gap: 14, color: "var(--text)",
}}>
{checked && }
{label} {required && * }
{sub &&
}
);
}
// ─── SUMMARY PANEL ─────────────────────────────────────
function SummaryPanel({ service, vehicle, plate, day, time, form }) {
return (
Uw boeking
{service ? (
{service.name}
{service.duration ? `${service.duration} min` : "variabel"} · {service.price ? `vanaf €${service.price}` : "op maat"}
) : — }
{plate ? (
{vehicle &&
{[vehicle.make || vehicle.merk, vehicle.model].filter(Boolean).join(' ')}
{(vehicle.year || vehicle.jaar) ? ` (${vehicle.year || vehicle.jaar})` : ''}
}
) : — }
{day && time ? (
{day} mei 2026
{time} uur
) : — }
{form.name ? (
) : — }
Verwachte kosten
{service?.price ? `vanaf €${service.price}` : "op maat"}
Eindbedrag wordt na inspectie definitief. Geen verborgen kosten —
bij meerwerk bellen we eerst.
);
}
function Row({ label, children, last }) {
return (
);
}
// ─── CONFIRMATION ──────────────────────────────────────
function ConfirmationCard({ service, vehicle, plate, day, time, form, onNav }) {
return (
Tot {form.name?.split(" ")[0] || "snel"}!
Bevestiging is verzonden naar {form.email} .
24 uur voor uw afspraak ontvangt u een herinnering.
{service?.name}
{vehicle &&
{vehicle.merk} {vehicle.model}
}
{day} mei 2026 — {time}
Vestiging Vijfhuizen — Spieringweg 1234
Toevoegen aan agenda
onNav("home")}>
Terug naar home
);
}
// expose
window.Boeking = Boeking;