Deploy
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
'use client'
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { ArrowLeft, Calendar, ChevronLeft, ChevronRight, CircleCheck, Clock, Loader2 } from 'lucide-react'
|
||||
|
||||
const COMMERCE_URL = process.env.NEXT_PUBLIC_VULA_COMMERCE_URL || ''
|
||||
const PROJECT_ID = process.env.NEXT_PUBLIC_VULA_PROJECT_ID || ''
|
||||
|
||||
// Business-hours appointment slots (30-min intervals, 09:00–17:00)
|
||||
const BUSINESS_SLOTS = Array.from({ length: 17 }, (_, i) => {
|
||||
const h = 9 + Math.floor(i / 2)
|
||||
const m = i % 2 === 0 ? '00' : '30'
|
||||
return `${String(h).padStart(2,'0')}:${m}`
|
||||
})
|
||||
|
||||
interface TimeSlot {
|
||||
time: string
|
||||
available: boolean
|
||||
}
|
||||
|
||||
type Step = 'datetime' | 'details' | 'confirm'
|
||||
|
||||
const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
|
||||
|
||||
function addMonths(date: Date, n: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setMonth(d.getMonth() + n)
|
||||
return d
|
||||
}
|
||||
|
||||
function toDateStr(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function buildCalendarWeeks(year: number, month: number): (Date | null)[][] {
|
||||
const first = new Date(year, month, 1)
|
||||
const last = new Date(year, month + 1, 0)
|
||||
const weeks: (Date | null)[][] = []
|
||||
let week: (Date | null)[] = Array(first.getDay()).fill(null)
|
||||
for (let d = 1; d <= last.getDate(); d++) {
|
||||
week.push(new Date(year, month, d))
|
||||
if (week.length === 7) { weeks.push(week); week = [] }
|
||||
}
|
||||
if (week.length > 0) weeks.push([...week, ...Array(7 - week.length).fill(null)])
|
||||
return weeks
|
||||
}
|
||||
|
||||
export default function AppointmentBookingPage() {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const [step, setStep] = useState<Step>('datetime')
|
||||
const [calMonth, setCalMonth] = useState(new Date(today.getFullYear(), today.getMonth(), 1))
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||
const [slots, setSlots] = useState<TimeSlot[]>([])
|
||||
const [slotsLoading, setSlotsLoading] = useState(false)
|
||||
const [selectedTime, setSelectedTime] = useState<string | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [confirmation, setConfirmation] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const weeks = buildCalendarWeeks(calMonth.getFullYear(), calMonth.getMonth())
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDate) return
|
||||
setSlotsLoading(true)
|
||||
setSlots([])
|
||||
setSelectedTime(null)
|
||||
const dateStr = toDateStr(selectedDate)
|
||||
fetch(`${COMMERCE_URL}/api/store/${PROJECT_ID}/bookings/availability?type=appointment&date=${dateStr}&time_slots=${BUSINESS_SLOTS.join(',')}`)
|
||||
.then(r => r.json())
|
||||
.then((data: { availability?: { slot: string; available: boolean }[] }) => {
|
||||
const av = data.availability ?? []
|
||||
if (av.length > 0) {
|
||||
setSlots(av.map(a => ({ time: a.slot, available: a.available })))
|
||||
} else {
|
||||
setSlots(BUSINESS_SLOTS.map(s => ({ time: s, available: true })))
|
||||
}
|
||||
})
|
||||
.catch(() => setSlots(BUSINESS_SLOTS.map(s => ({ time: s, available: true }))))
|
||||
.finally(() => setSlotsLoading(false))
|
||||
}, [selectedDate])
|
||||
|
||||
async function submitBooking() {
|
||||
if (!selectedDate || !selectedTime || !name || !phone) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
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',
|
||||
booking_date: toDateStr(selectedDate),
|
||||
time_slot: selectedTime,
|
||||
customer_name: name,
|
||||
customer_phone: phone,
|
||||
customer_email: email || undefined,
|
||||
special_requests: notes || undefined,
|
||||
}),
|
||||
})
|
||||
if (res.status === 409) {
|
||||
setError('That time slot was just taken. Please choose another.')
|
||||
setStep('datetime')
|
||||
return
|
||||
}
|
||||
if (!res.ok) throw new Error('Failed to book')
|
||||
const data = await res.json()
|
||||
setConfirmation(data.booking?.confirmation_code ?? 'Confirmed')
|
||||
} catch {
|
||||
setError('Booking failed. Please try again or call us directly.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (confirmation) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background flex items-center justify-center p-6">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="mx-auto w-20 h-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CircleCheck className="h-10 w-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Appointment Confirmed!</h1>
|
||||
<p className="text-muted-foreground mt-2">Your booking reference</p>
|
||||
<p className="text-3xl font-mono font-bold text-primary mt-1 tracking-widest">{confirmation}</p>
|
||||
</div>
|
||||
<div className="bg-muted rounded-xl p-4 text-sm space-y-1 text-left">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Date</span><span className="font-medium">{selectedDate ? selectedDate.toLocaleDateString('en-ZA', { weekday:'long', year:'numeric', month:'long', day:'numeric' }) : ''}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Time</span><span className="font-medium">{selectedTime}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Name</span><span className="font-medium">{name}</span></div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">We will contact you to confirm. Please keep your phone on.</p>
|
||||
<Link href="/" className="inline-flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
<ArrowLeft className="h-4 w-4" /> Back to home
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const morningSlots = slots.filter(s => parseInt(s.time) < 12)
|
||||
const afternoonSlots = slots.filter(s => { const h = parseInt(s.time); return h >= 12 && h < 17 })
|
||||
const eveningSlots = slots.filter(s => parseInt(s.time) >= 17)
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-10 max-w-3xl">
|
||||
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground mb-8 transition-colors">
|
||||
<ArrowLeft className="h-4 w-4" /> Back
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-2">Book a Consultation</h1>
|
||||
<p className="text-muted-foreground mb-8">Select a date and time that works for you</p>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
{(['datetime','details','confirm'] as Step[]).map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-colors ${step === s ? 'bg-primary text-primary-foreground' : i < (['datetime','details','confirm'] as Step[]).indexOf(step) ? 'bg-green-500 text-white' : 'bg-muted text-muted-foreground'}`}>{i + 1}</div>
|
||||
<span className={`text-xs font-medium hidden sm:block ${step === s ? 'text-foreground' : 'text-muted-foreground'}`}>{s === 'datetime' ? 'Date & Time' : s === 'details' ? 'Your Details' : 'Confirm'}</span>
|
||||
{i < 2 && <div className="h-px w-6 bg-border" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-xl bg-destructive/10 border border-destructive/20 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{step === 'datetime' && (
|
||||
<div className="space-y-6">
|
||||
{/* Calendar */}
|
||||
<div className="bg-card border border-border rounded-2xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button onClick={() => setCalMonth(m => addMonths(m, -1))} disabled={calMonth <= new Date(today.getFullYear(), today.getMonth(), 1)} className="p-2 rounded-lg hover:bg-muted disabled:opacity-30 transition-colors">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="font-semibold">{MONTHS[calMonth.getMonth()]} {calMonth.getFullYear()}</span>
|
||||
<button onClick={() => setCalMonth(m => addMonths(m, 1))} className="p-2 rounded-lg hover:bg-muted transition-colors">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-7 mb-2">
|
||||
{DAYS.map(d => <div key={d} className="text-center text-xs font-medium text-muted-foreground py-1">{d}</div>)}
|
||||
</div>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7">
|
||||
{week.map((day, di) => {
|
||||
if (!day) return <div key={di} />
|
||||
const isPast = day < today
|
||||
const isWeekend = day.getDay() === 0 || day.getDay() === 6
|
||||
const isSelected = selectedDate ? toDateStr(day) === toDateStr(selectedDate) : false
|
||||
const disabled = isPast || isWeekend
|
||||
return (
|
||||
<button
|
||||
key={di}
|
||||
disabled={disabled}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={`aspect-square flex items-center justify-center text-sm rounded-xl m-0.5 transition-colors ${isSelected ? 'bg-primary text-primary-foreground font-bold' : disabled ? 'text-muted-foreground/30 cursor-not-allowed' : 'hover:bg-muted font-medium'}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{selectedDate && (
|
||||
<div className="bg-card border border-border rounded-2xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-semibold">Available times — {selectedDate.toLocaleDateString('en-ZA', { weekday:'long', month:'long', day:'numeric' })}</span>
|
||||
</div>
|
||||
{slotsLoading ? (
|
||||
<div className="flex items-center justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
|
||||
) : slots.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm py-4">No slots available for this date.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{[{ label: 'Morning', items: morningSlots }, { label: 'Afternoon', items: afternoonSlots }, { label: 'Evening', items: eveningSlots }].filter(g => g.items.length > 0).map(group => (
|
||||
<div key={group.label}>
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">{group.label}</p>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
{group.items.map(slot => (
|
||||
<button
|
||||
key={slot.time}
|
||||
disabled={!slot.available}
|
||||
onClick={() => setSelectedTime(slot.time)}
|
||||
className={`py-2.5 px-3 rounded-xl text-sm font-medium transition-colors border ${selectedTime === slot.time ? 'bg-primary text-primary-foreground border-primary' : slot.available ? 'border-border hover:border-primary hover:bg-primary/5' : 'border-border bg-muted text-muted-foreground/40 cursor-not-allowed line-through'}`}
|
||||
>
|
||||
{slot.time}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
disabled={!selectedDate || !selectedTime}
|
||||
onClick={() => setStep('details')}
|
||||
className="px-8 py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-40 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'details' && (
|
||||
<div className="bg-card border border-border rounded-2xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-border">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<p className="font-semibold">{selectedDate?.toLocaleDateString('en-ZA', { weekday:'long', month:'long', day:'numeric', year:'numeric' })}</p>
|
||||
<p className="text-sm text-muted-foreground">{selectedTime}</p>
|
||||
</div>
|
||||
<button onClick={() => setStep('datetime')} className="ml-auto text-xs text-primary hover:underline">Change</button>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Full Name *</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder="Your full name" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Phone Number *</label>
|
||||
<input value={phone} onChange={e => setPhone(e.target.value)} placeholder="+27 82 000 0000" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Email Address (optional)</label>
|
||||
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="your@email.com" className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">Briefly describe what you need help with (optional)</label>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={3} placeholder="e.g. Residential property transfer, business registration, tax returns..." className="w-full px-4 py-2.5 rounded-xl border border-border bg-background focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm resize-none" />
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button onClick={() => setStep('datetime')} className="px-6 py-3 rounded-xl border border-border text-sm font-medium hover:bg-muted transition-colors">Back</button>
|
||||
<button disabled={!name || !phone} onClick={() => setStep('confirm')} className="px-8 py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-40 hover:opacity-90 transition-opacity">Review</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'confirm' && (
|
||||
<div className="bg-card border border-border rounded-2xl p-6 space-y-5">
|
||||
<h2 className="text-lg font-bold">Confirm your appointment</h2>
|
||||
<div className="space-y-3 text-sm">
|
||||
{[
|
||||
{ label: 'Date', value: selectedDate?.toLocaleDateString('en-ZA', { weekday:'long', year:'numeric', month:'long', day:'numeric' }) ?? '' },
|
||||
{ label: 'Time', value: selectedTime ?? '' },
|
||||
{ label: 'Name', value: name },
|
||||
{ label: 'Phone', value: phone },
|
||||
...(email ? [{ label: 'Email', value: email }] : []),
|
||||
...(notes ? [{ label: 'Notes', value: notes }] : []),
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex justify-between py-2 border-b border-border/50 last:border-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium max-w-[60%] text-right">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<button onClick={() => setStep('details')} className="px-6 py-3 rounded-xl border border-border text-sm font-medium hover:bg-muted transition-colors">Back</button>
|
||||
<button onClick={submitBooking} disabled={submitting} className="px-8 py-3 bg-primary text-primary-foreground rounded-xl font-semibold disabled:opacity-40 hover:opacity-90 transition-opacity flex items-center gap-2">
|
||||
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{submitting ? 'Booking...' : 'Confirm Booking'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user