/* Fietsverhuur – Reserveringswidget (React, single file) ----------------------------------------------------- Wat is dit? - Een complete, kant-en-klare front-end widget om fietsen te reserveren: datum, starttijd, duur, type fiets, aantal; toont realtime beschikbaarheid en voorkomt dubbele boekingen. - Voor demo/doeleinden slaat het lokaal op (localStorage). Je kunt het eenvoudig koppelen aan een echte database/API (bijv. Supabase of een eigen Node/Express/Symfony/Laravel backend). Hoe integreren op productie? 1) Database (voorbeeld met SQL – pas velden aan waar nodig): CREATE TABLE bike_types ( id SERIAL PRIMARY KEY, slug TEXT UNIQUE NOT NULL, -- 'city', 'ebike', 'cargo', 'kids' name TEXT NOT NULL, -- 'Stadsfiets', 'E‑bike', etc. base_stock INT NOT NULL DEFAULT 0 -- aantal per type ); CREATE TABLE bookings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), full_name TEXT NOT NULL, email TEXT NOT NULL, phone TEXT, bike_type TEXT NOT NULL REFERENCES bike_types(slug), quantity INT NOT NULL CHECK (quantity > 0), start_ts TIMESTAMPTZ NOT NULL, end_ts TIMESTAMPTZ NOT NULL, location TEXT DEFAULT 'main', -- optioneel: multi‑locatie notes TEXT, status TEXT DEFAULT 'confirmed' -- 'confirmed' | 'cancelled' | 'pending' ); -- Indexen voor snelheid & overlap checks CREATE INDEX ON bookings (bike_type); CREATE INDEX ON bookings (start_ts); CREATE INDEX ON bookings (end_ts); CREATE INDEX ON bookings (status); -- Voorraad per dag (optioneel, als je afwijkende dag-voorraden wil) CREATE TABLE stock_overrides ( id SERIAL PRIMARY KEY, bike_type TEXT NOT NULL REFERENCES bike_types(slug), date DATE NOT NULL, -- lokale datum stock INT NOT NULL -- totale voorraad die dag ); 2) Endpoints (voorbeeld): GET /api/availability?date=YYYY-MM-DD -> returns { byType: { city: 18, ebike: 7, ... } } POST /api/bookings body {full_name,email,phone,bike_type,quantity,start_ts,end_ts,notes} GET /api/bookings?from=ISO&to=ISO&status=confirmed 3) Betalingen (optioneel): - Stripe Payment/PaymentLink of Pay. (iDEAL) na POST /bookings -> response bevat checkoutUrl. - Of: betaal bij afhalen (status 'pending' -> 'confirmed' bij balie). 4) Bevestigingen: - E‑mail via Resend/Mailgun/Sendgrid o.b.v. POST /bookings. ----------------------------------------------------- */ import { useEffect, useMemo, useState } from 'react' // Mock-config – pas naar wens aan const BIKE_TYPES = [ { slug: 'city', name: 'Stadsfiets', baseStock: 25 }, { slug: 'ebike', name: 'E‑bike', baseStock: 12 }, { slug: 'cargo', name: 'Bakfiets', baseStock: 6 }, { slug: 'kids', name: 'Kinderfiets', baseStock: 8 } ] const OPEN_HOUR = 9 // 09:00 const CLOSE_HOUR = 18 // 18:00 (laatste startslot is eerder afhankelijk van duur) const SLOT_MINUTES = 30 // LocalStorage helper const LS_KEY = 'bikeBookingsV1' const loadBookings = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || '[]') } catch { return [] } } const saveBookings = (arr) => localStorage.setItem(LS_KEY, JSON.stringify(arr)) // Tijdshelpers const pad = (n) => String(n).padStart(2, '0') const toLocalISO = (d) => { // locale ISO zonder Z – geschikt om als string te bewaren/tonen const y = d.getFullYear() const m = pad(d.getMonth() + 1) const day = pad(d.getDate()) const hh = pad(d.getHours()) const mm = pad(d.getMinutes()) return `${y}-${m}-${day}T${hh}:${mm}` } const addMinutes = (d, min) => new Date(d.getTime() + min * 60000) const overlap = (aStart, aEnd, bStart, bEnd) => (aStart < bEnd && bStart < aEnd) function makeSlots(dateStr) { const [y, m, d] = dateStr.split('-').map(Number) const base = new Date(y, m - 1, d) const slots = [] for (let h = OPEN_HOUR; h < CLOSE_HOUR; h++) { for (let i = 0; i < 60; i += SLOT_MINUTES) { const s = new Date(base) s.setHours(h, i, 0, 0) slots.push({ label: `${pad(h)}:${pad(i)}`, value: toLocalISO(s) }) } } return slots } function useBookings() { const [bookings, setBookings] = useState([]) useEffect(() => { setBookings(loadBookings()) }, []) useEffect(() => { saveBookings(bookings) }, [bookings]) return { bookings, setBookings } } function availableFor(bikeType, quantity, startISO, durationHrs, bookings, baseStock) { if (!startISO || !durationHrs) return baseStock const start = new Date(startISO) const end = addMinutes(start, durationHrs * 60) const sameType = bookings.filter(b => b.bike_type === bikeType && b.status !== 'cancelled') // Som van reeds gereserveerde aantallen die overlappen let reserved = 0 for (const b of sameType) { const bStart = new Date(b.start_ts) const bEnd = new Date(b.end_ts) if (overlap(start, end, bStart, bEnd)) reserved += b.quantity } return Math.max(0, baseStock - reserved) } export default function BikeRentalWidget() { const todayStr = useMemo(() => new Date().toISOString().slice(0, 10), []) const [date, setDate] = useState(todayStr) const [bikeType, setBikeType] = useState('city') const [qty, setQty] = useState(1) const [duration, setDuration] = useState(4) // uren const [startISO, setStartISO] = useState('') const [name, setName] = useState('') const [email, setEmail] = useState('') const [phone, setPhone] = useState('') const [notes, setNotes] = useState('') const [showAdmin, setShowAdmin] = useState(false) const { bookings, setBookings } = useBookings() const slots = useMemo(() => makeSlots(date), [date]) // Limiteer startslot zodat einde binnen openingstijden valt const filteredSlots = useMemo(() => { return slots.filter(s => { if (!duration) return true const sDate = new Date(s.value) const end = addMinutes(sDate, duration * 60) return end.getHours() <= CLOSE_HOUR && !(end.getHours() === CLOSE_HOUR && end.getMinutes() > 0) }) }, [slots, duration]) const baseStock = useMemo(() => BIKE_TYPES.find(t => t.slug === bikeType)?.baseStock || 0, [bikeType]) const available = useMemo(() => availableFor(bikeType, qty, startISO, duration, bookings, baseStock), [bikeType, qty, startISO, duration, bookings, baseStock]) const canSubmit = name && email && startISO && duration > 0 && qty > 0 && available >= qty const handleSubmit = (e) => { e.preventDefault() if (!canSubmit) return const start = new Date(startISO) const end = addMinutes(start, duration * 60) const newBooking = { id: crypto.randomUUID(), full_name: name, email, phone, bike_type: bikeType, quantity: Number(qty), start_ts: start.toISOString(), end_ts: end.toISOString(), notes, status: 'confirmed' } // Overlap-check (defensief) const stillAvail = availableFor(bikeType, qty, startISO, duration, bookings, baseStock) if (stillAvail < qty) { alert('Dit tijdslot is zojuist volgelopen. Kies een ander slot of verlaag het aantal.') return } setBookings(prev => [...prev, newBooking]) // Reset en bevestiging setQty(1); setNotes(''); alert('Bedankt! Je reservering is vastgelegd. We hebben je een bevestiging gestuurd (demo: geen echte e‑mail).') } const niceDate = new Date(startISO || `${date}T${pad(OPEN_HOUR)}:00`) return (

Fiets huren – reserveer direct

Kies datum, tijd, duur en type. Het systeem checkt realtime beschikbaarheid per fietstype.

{ setDate(e.target.value); setStartISO('') }} className="w-full border rounded-xl px-3 py-2" min={todayStr} />
setDuration(Number(e.target.value))} className="w-full border rounded-xl px-3 py-2" />
setQty(Number(e.target.value))} className="w-full border rounded-xl px-3 py-2" />
setName(e.target.value)} className="w-full border rounded-xl px-3 py-2" placeholder="Voor- en achternaam" />
setEmail(e.target.value)} className="w-full border rounded-xl px-3 py-2" placeholder="[email protected]" />
setPhone(e.target.value)} className="w-full border rounded-xl px-3 py-2" placeholder="06‑…" />