Deploy
This commit is contained in:
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user