This commit is contained in:
Vula Builder
2026-09-15 18:03:00 +00:00
parent 7191dee67d
commit bf7d7b1ef7
+319
View File
@@ -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<string, string> = { 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<string[]>([])
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<string | null>(null)
const [noticeHours, setNoticeHours] = useState<number | null>(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 <div className="min-h-screen flex items-center justify-center bg-background"><Loader2 className="h-10 w-10 animate-spin text-primary" /></div>
if (!service) return <div className="min-h-screen flex items-center justify-center bg-background"><p className="text-muted-foreground mr-4">Service not found.</p><Link href="/services" className="text-primary hover:underline">Back to Services</Link></div>
if (success) return (
<main className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<div className="bg-card border border-border rounded-2xl p-10">
<CircleCheck className="h-16 w-16 text-green-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold mb-1">Booking received</h1>
{/* 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. */}
<p className="text-muted-foreground mb-5">Your appointment is in the diary. Keep the reference below — we&apos;ll be in touch to confirm.</p>
<div className="bg-muted rounded-xl p-4 text-left space-y-2 mb-6">
{confirmationCode && (
<div className="flex justify-between items-center pb-2 border-b border-border">
<span className="text-xs text-muted-foreground">Reference</span>
<span className="font-mono font-bold text-xl tracking-widest text-primary">{confirmationCode}</span>
</div>
)}
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Service</span><span className="font-medium">{service.title}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Date</span><span className="font-medium">{form.date}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">Time</span><span className="font-medium">{form.time}</span></div>
</div>
{/* ── 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 && (
<p className="text-xs text-muted-foreground mb-5">
Free to cancel until <span className="font-medium text-foreground">{cancelBy}</span>.
</p>
)}
{cancelling ? (
<p className="text-sm text-muted-foreground mb-5">Cancelling…</p>
) : cancelMessage ? (
<p className="text-sm mb-5">{cancelMessage}</p>
) : (
<button
onClick={cancelBooking}
className="text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground transition-colors block mx-auto mb-5"
>
Cancel this appointment
</button>
)}
<Link href="/services" className="bg-primary text-primary-foreground px-8 py-3 rounded-lg hover:bg-primary/90 transition-colors font-medium inline-block">Browse More Services</Link>
</div>
</div>
</main>
)
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-10 max-w-4xl">
<Link href="/services" className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-8 transition-colors text-sm">
<ArrowLeft className="h-4 w-4" />Back to Services
</Link>
<div className="grid lg:grid-cols-2 gap-10">
<div>
{service.thumbnail && (
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden bg-muted mb-5">
<Image src={service.thumbnail} alt={service.title} fill sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover" />
</div>
)}
<h1 className="text-2xl font-bold mb-3">{service.title}</h1>
{service.description && <p className="text-muted-foreground leading-relaxed mb-4">{service.description}</p>}
{price && <p className="text-3xl font-bold text-primary">{formatPrice(price.amount, price.currency_code)}</p>}
</div>
<div>
<div className="bg-card border border-border rounded-2xl p-6 sticky top-6">
<h2 className="text-xl font-semibold mb-5 flex items-center gap-2">
<CalendarCheck className="h-5 w-5 text-primary" />Book an Appointment
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Date *</label>
<input required type="date" min={today} value={form.date}
onChange={e => 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" />
</div>
{form.date && (
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-muted-foreground uppercase tracking-wide">Time *</label>
{loadingSlots && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
<div className="grid grid-cols-3 gap-2">
{TIME_SLOTS.map(slot => {
const booked = bookedSlots.includes(slot)
const selected = form.time === slot
return (
<button key={slot} type="button" disabled={booked}
onClick={() => !booked && setForm(f => ({...f, time: slot}))}
className={`py-2 rounded-lg text-sm font-medium border transition-all ${
booked ? 'bg-muted text-muted-foreground/40 border-border cursor-not-allowed line-through'
: selected ? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:border-primary hover:text-primary'
}`}>
{slot}
</button>
)
})}
</div>
</div>
)}
<div className="border-t border-border pt-4 space-y-3">
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Full Name *</label>
<input required type="text" value={form.name} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Phone *</label>
<input required type="tel" value={form.phone} onChange={e => 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" />
<label className="flex items-center gap-2 mt-2 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={waDifferent} onChange={e => setWaDifferent(e.target.checked)}
className="rounded border-border accent-primary" />
<span>My WhatsApp is a different number</span>
</label>
{waDifferent && (
<input required type="tel" value={waNumber} onChange={e => 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" />
)}
</div>
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Email</label>
<input type="email" value={form.email} onChange={e => 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" />
</div>
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Notes</label>
<textarea rows={2} value={form.notes} onChange={e => setForm(f => ({...f, notes: 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 resize-none" />
</div>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
{/* The policy, where the agreement actually happens. Unticked
by default and required — a pre-ticked box is not consent,
and a link to terms is not something anyone read. */}
{policy && (
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-3">
<p className="text-sm font-medium">Cancellation policy</p>
<p className="text-sm text-muted-foreground whitespace-pre-line">{policy}</p>
{cancelBy && (
<p className="text-sm">
<span className="text-muted-foreground">Free to cancel until </span>
<span className="font-medium">{cancelBy}</span>
</p>
)}
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={accepted}
onChange={e => setAccepted(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<span className="text-sm">I have read and accept the cancellation policy</span>
</label>
</div>
)}
<button type="submit" disabled={submitting || !form.time || (!!policy && !accepted)}
className="w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold disabled:opacity-50">
{submitting ? 'Booking…' : 'Confirm Appointment'}
</button>
</form>
</div>
</div>
</div>
</div>
</main>
)
}