From bf7d7b1ef746615b9ce3f0e5f2e519e786efa88c Mon Sep 17 00:00:00 2001 From: Vula Builder Date: Tue, 15 Sep 2026 18:03:00 +0000 Subject: [PATCH] Deploy --- src/app/booking/[productId]/page.tsx | 319 +++++++++++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 src/app/booking/[productId]/page.tsx diff --git a/src/app/booking/[productId]/page.tsx b/src/app/booking/[productId]/page.tsx new file mode 100644 index 0000000..6917593 --- /dev/null +++ b/src/app/booking/[productId]/page.tsx @@ -0,0 +1,319 @@ +'use client' +import { useState, useEffect } from 'react' +import { useParams } from 'next/navigation' +import { useProducts } from '@/hooks/useProducts' +import Image from 'next/image' +import Link from 'next/link' +import { ArrowLeft, CalendarCheck, CircleCheck, Loader2 } from 'lucide-react' + +const COMMERCE_URL = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL || '' +const PROJECT_ID = process.env.NEXT_PUBLIC_VULA_PROJECT_ID || '' +const TIME_SLOTS = Array.from({ length: 19 }, (_, i) => { + const h = 8 + Math.floor(i / 2) + const m = i % 2 === 0 ? '00' : '30' + return `${String(h).padStart(2,'0')}:${m}` +}) +const CURRENCY_SYMBOLS: Record = { zar: 'R', usd: '$', eur: '€', gbp: '£', kes: 'KSh', ngn: '₦', ghs: '₵' } +const formatPrice = (amount: number, code?: string) => { + const sym = CURRENCY_SYMBOLS[(code ?? '').toLowerCase()] ?? (code?.toUpperCase() ?? '') + return `${sym}${(amount / 100).toFixed(0)}` +} + +export default function BookingPage() { + const params = useParams() + const productId = params.productId as string + + const { products, loading: loadingSvc } = useProducts(50) + const service = products.find(p => p.id === productId) ?? null + const [form, setForm] = useState({ name: '', email: '', phone: '', date: '', time: '', notes: '' }) + // Two numbers is normal here, and the one people answer a call on is often + // not the one WhatsApp is on. Asked as a tickbox rather than a second + // required field: almost everyone has one number, and making everybody type + // it twice to serve the minority is how forms get abandoned. + const [waDifferent, setWaDifferent] = useState(false) + const [waNumber, setWaNumber] = useState('') + const [bookedSlots, setBookedSlots] = useState([]) + const [loadingSlots, setLoadingSlots] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [confirmationCode, setConfirmationCode] = useState('') + const [success, setSuccess] = useState(false) + const [error, setError] = useState('') + // The practice's cancellation policy, in its own words, and the notice it + // needs. Shown here because this is the moment the patient is agreeing to it + // — a policy linked from a footer is one nobody has agreed to. + const [policy, setPolicy] = useState(null) + const [noticeHours, setNoticeHours] = useState(null) + const [accepted, setAccepted] = useState(false) + + useEffect(() => { + fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/open`) + .then(r => r.ok ? r.json() : null) + .then(d => { + if (!d) return + setPolicy(d.cancellationPolicy ?? null) + setNoticeHours(d.cancellationNoticeHours ?? null) + }) + .catch(() => {}) + }, []) + + // "Free to cancel until Tuesday 08:30" — a moment, not a duration. Working + // out what three hours before an appointment means is arithmetic on a phone, + // at the point someone is least inclined to do any. + const cancelBy = (() => { + if (!noticeHours || !form.date || !form.time) return null + const at = new Date(`${form.date}T${form.time}:00`) + if (isNaN(at.getTime())) return null + const by = new Date(at.getTime() - noticeHours * 3600000) + return by.toLocaleString('en-ZA', { weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }) + })() + + const [cancelling, setCancelling] = useState(false) + const [cancelMessage, setCancelMessage] = useState('') + + async function cancelBooking() { + if (!confirmationCode) return + setCancelling(true) + try { + const res = await fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings/${confirmationCode}/cancel`, { method: 'POST' }) + const d = await res.json().catch(() => ({})) + // The server says whether this was inside the notice period, and that + // sentence goes straight through. Someone who cancels late and is not + // told will be surprised by the invoice. + setCancelMessage(d?.message || 'Your appointment is cancelled.') + } catch { + setCancelMessage('We could not cancel that just now — please phone us.') + } finally { + setCancelling(false) + } + } + + // Fetch booked slots whenever date changes + useEffect(() => { + if (!form.date || !productId) return + setLoadingSlots(true) + setForm(f => ({ ...f, time: '' })) + fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings/availability?type=appointment&date=${form.date}&time_slots=${TIME_SLOTS.join(',')}`) + .then(r => r.json()) + .then(d => { + const av: { slot: string; available: boolean }[] = d.availability ?? [] + setBookedSlots(av.filter(a => !a.available).map(a => a.slot)) + setLoadingSlots(false) + }) + .catch(() => setLoadingSlots(false)) + }, [form.date, productId]) + + const price = service?.variants?.[0]?.prices?.[0] + const today = new Date().toISOString().split('T')[0] + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!form.time) { setError('Please select a time slot.'); return } + setSubmitting(true) + setError('') + try { + const res = await fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + booking_type: 'appointment', + product_id: productId, + policy_accepted: policy ? accepted : undefined, + booking_date: form.date, + time_slot: form.time, + customer_name: form.name, + customer_phone: form.phone, + // Only when they said it differs. Left out, the server uses the + // contact number, which is right for almost everyone. + customer_whatsapp: waDifferent && waNumber.trim() ? waNumber.trim() : undefined, + customer_email: form.email || undefined, + special_requests: form.notes || undefined, + }), + }) + const data = await res.json() + if (!res.ok) throw new Error(data.error || 'Booking failed') + setConfirmationCode(data.booking?.confirmation_code ?? '') + setSuccess(true) + } catch (err: unknown) { + setError(err instanceof Error ? err.message : 'Something went wrong. Please try again or call us directly.') + } + setSubmitting(false) + } + + if (loadingSvc) return
+ if (!service) return

Service not found.

Back to Services
+ + if (success) return ( +
+
+
+ +

Booking received

+ {/* No automatic message goes out — the owner confirms by hand from + her own WhatsApp once she has seen it. So we do not name a + channel or a deadline here, and the heading does not say + "confirmed" above a line saying it is yet to be. */} +

Your appointment is in the diary. Keep the reference below — we'll be in touch to confirm.

+
+ {confirmationCode && ( +
+ Reference + {confirmationCode} +
+ )} +
Service{service.title}
+
Date{form.date}
+
Time{form.time}
+
+ {/* ── CANCELLING HAS TO BE EASIER THAN NOT TURNING UP ──────────── + A no-show is worth nothing to the practice; a cancellation at + seven in the morning is worth the slot, which can be refilled. + Every hour of friction between "I can't make it" and the practice + knowing turns one into the other — and "I couldn't get hold of + you" stops being sayable when this works at eleven at night. */} + {policy && cancelBy && ( +

+ Free to cancel until {cancelBy}. +

+ )} + {cancelling ? ( +

Cancelling…

+ ) : cancelMessage ? ( +

{cancelMessage}

+ ) : ( + + )} + Browse More Services +
+
+
+ ) + + return ( +
+
+ + Back to Services + +
+
+ {service.thumbnail && ( +
+ {service.title} +
+ )} +

{service.title}

+ {service.description &&

{service.description}

} + {price &&

{formatPrice(price.amount, price.currency_code)}

} +
+
+
+

+ Book an Appointment +

+
+
+ + setForm(f => ({...f, date: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+ {form.date && ( +
+
+ + {loadingSlots && } +
+
+ {TIME_SLOTS.map(slot => { + const booked = bookedSlots.includes(slot) + const selected = form.time === slot + return ( + + ) + })} +
+
+ )} +
+
+ + setForm(f => ({...f, name: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+
+ + setForm(f => ({...f, phone: e.target.value}))} + placeholder="+27 " className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> + + {waDifferent && ( + setWaNumber(e.target.value)} + placeholder="WhatsApp number" + className="w-full mt-2 border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> + )} +
+
+ + setForm(f => ({...f, email: e.target.value}))} + className="w-full border border-border rounded-lg px-3 py-2 bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary" /> +
+
+ +