Files
vula-313458ba/src/app/booking/[productId]/page.tsx
T
Vula Builder de29acfe6d Deploy
2026-07-26 17:04:59 +00:00

287 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, Suspense } from 'react'
import { useParams, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { ArrowLeft, CalendarCheck, CircleCheck, Loader2 } from 'lucide-react'
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)}`
}
interface Room {
id: string
title: string
description: string
thumbnail: string
images: { url: string }[]
variants: { id: string; prices: { amount: number; currency_code: string }[] }[]
}
function calcNights(checkIn: string, checkOut: string) {
if (!checkIn || !checkOut) return 0
return Math.max(0, Math.round((new Date(checkOut).getTime() - new Date(checkIn).getTime()) / 86400000))
}
function BookingPageContent() {
const params = useParams()
const searchParams = useSearchParams()
const productId = params.productId as string
const today = new Date().toISOString().split('T')[0]
const [room, setRoom] = useState<Room | null>(null)
const [loadingRoom, setLoadingRoom] = useState(true)
const [activeImg, setActiveImg] = useState(0)
const [checkIn, setCheckIn] = useState(searchParams.get('checkIn') || '')
const [checkOut, setCheckOut] = useState(searchParams.get('checkOut') || '')
const [availability, setAvailability] = useState<{ available: boolean } | null>(null)
const [checkingAvail, setCheckingAvail] = useState(false)
const [form, setForm] = useState({ name: '', email: '', phone: '', notes: '' })
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState(false)
const [confirmNo, setConfirmNo] = useState('')
const [error, setError] = useState('')
useEffect(() => {
if (!productId) return
fetch(`/api/medusa/store/products/${productId}?fields=*variants,*images`)
.then(r => r.json())
.then(d => { setRoom(d.product ?? null); setLoadingRoom(false) })
.catch(() => setLoadingRoom(false))
}, [productId])
useEffect(() => {
if (!checkIn || !checkOut || !room) return
if (new Date(checkOut) <= new Date(checkIn)) return
setCheckingAvail(true)
setAvailability(null)
const qs = new URLSearchParams({ room_type: room.id, check_in: checkIn, check_out: checkOut }).toString()
fetch(`/api/medusa/store/hotel/bookings?${qs}`)
.then(r => r.json())
.then(d => { setAvailability(d.available != null ? { available: d.available } : null); setCheckingAvail(false) })
.catch(() => setCheckingAvail(false))
}, [checkIn, checkOut, room])
const nights = calcNights(checkIn, checkOut)
const variant = room?.variants?.[0]
const price = variant?.prices?.[0]
const pricePerNight = price?.amount ?? 0
const currency = price?.currency_code
const totalPrice = pricePerNight * nights
const allImages = room ? [room.thumbnail, ...(room.images?.map(i => i.url) ?? [])].filter(Boolean) : []
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!room || !availability?.available || nights === 0) return
setSubmitting(true)
setError('')
try {
const res = await fetch('/api/medusa/store/hotel/bookings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
room_type: room.id,
check_in: checkIn,
check_out: checkOut,
customer_name: form.name,
customer_email: form.email || null,
customer_phone: form.phone,
notes: form.notes || null,
}),
signal: AbortSignal.timeout(15000),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Booking failed')
setConfirmNo(data.confirmation_number ?? '')
setSuccess(true)
} catch (err: any) {
setError(err.message === 'Room is not available for the selected dates'
? 'Sorry, those dates are no longer available. Please choose different dates.'
: 'Something went wrong. Please try again or call us directly.')
}
setSubmitting(false)
}
if (loadingRoom) 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 (!room) return (
<div className="min-h-screen flex items-center justify-center bg-background">
<p className="text-muted-foreground mr-4">Room not found.</p>
<Link href="/rooms" className="text-primary hover:underline">Back to Rooms</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 Confirmed!</h1>
{confirmNo && <p className="text-xs text-muted-foreground mb-4">Ref: {confirmNo}</p>}
<p className="font-semibold mb-1">{room.title}</p>
<p className="text-muted-foreground text-sm mb-1">Check-in: {checkIn}</p>
<p className="text-muted-foreground text-sm mb-3">Check-out: {checkOut}</p>
{nights > 0 && totalPrice > 0 && (
<p className="text-lg font-bold text-primary mb-4">{formatPrice(totalPrice, currency)} total · {nights} night{nights !== 1 ? 's' : ''}</p>
)}
<p className="text-sm text-muted-foreground mb-8">
{form.email ? `A confirmation will be sent to ${form.email}.` : `We will call ${form.phone} to confirm your reservation.`}
</p>
<Link href="/rooms" className="bg-primary text-primary-foreground px-8 py-3 rounded-lg hover:bg-primary/90 transition-colors font-medium inline-block">
Browse More Rooms
</Link>
</div>
</div>
</main>
)
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-10 max-w-5xl">
<Link href="/rooms" 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 Rooms
</Link>
<div className="grid lg:grid-cols-2 gap-10">
{/* Left — Room details */}
<div>
{allImages.length > 0 && (
<div className="mb-5">
<div className="relative aspect-[4/3] rounded-2xl overflow-hidden bg-muted mb-3">
<Image src={allImages[activeImg]} alt={room.title} fill sizes="(max-width: 1024px) 100vw, 50vw" className="object-cover" />
</div>
{allImages.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{allImages.map((img, i) => (
<button key={i} onClick={() => setActiveImg(i)}
className={"relative flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden border-2 transition-all " + (activeImg === i ? "border-primary" : "border-transparent opacity-60 hover:opacity-100")}>
<Image src={img} alt="" fill sizes="64px" className="object-cover" />
</button>
))}
</div>
)}
</div>
)}
<h1 className="text-2xl font-bold mb-3">{room.title}</h1>
{room.description && <p className="text-muted-foreground leading-relaxed mb-5">{room.description}</p>}
{pricePerNight > 0 && (
<p className="text-3xl font-bold text-primary">
{formatPrice(pricePerNight, currency)}
<span className="text-base font-normal text-muted-foreground"> / night</span>
</p>
)}
</div>
{/* Right — Booking form */}
<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" />Reserve This Room
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Dates */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Check-in</label>
<input required type="date" min={today} value={checkIn}
onChange={e => { setCheckIn(e.target.value); setAvailability(null) }}
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">Check-out</label>
<input required type="date" min={checkIn || today} value={checkOut}
onChange={e => { setCheckOut(e.target.value); setAvailability(null) }}
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>
{/* Availability indicator */}
{checkingAvail && <p className="text-xs text-muted-foreground animate-pulse">Checking availability</p>}
{availability && (
<div className={"text-xs font-medium px-3 py-2 rounded-lg border " + (availability.available
? "bg-green-50 text-green-700 border-green-200"
: "bg-red-50 text-red-700 border-red-200")}>
{availability.available ? "✓ Available for selected dates" : "✗ Not available for selected dates — please choose different dates"}
</div>
)}
{/* Price breakdown */}
{nights > 0 && pricePerNight > 0 && (
<div className="bg-muted/50 rounded-lg p-3 text-sm">
<div className="flex justify-between text-muted-foreground mb-1">
<span>{formatPrice(pricePerNight, currency)} × {nights} night{nights !== 1 ? 's' : ''}</span>
<span>{formatPrice(totalPrice, currency)}</span>
</div>
<div className="flex justify-between font-semibold border-t border-border pt-2 mt-1">
<span>Total</span>
<span className="text-primary">{formatPrice(totalPrice, currency)}</span>
</div>
</div>
)}
{/* Guest details */}
<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">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">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" />
</div>
<div>
<label className="block text-xs font-medium mb-1 text-muted-foreground uppercase tracking-wide">Special Requests</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>}
<button type="submit"
disabled={submitting || !availability?.available || nights === 0}
className="w-full bg-primary text-primary-foreground py-3 rounded-lg hover:bg-primary/90 transition-colors font-semibold disabled:opacity-50 disabled:cursor-not-allowed">
{submitting ? 'Confirming…' : nights > 0 && totalPrice > 0
? `Confirm — ${formatPrice(totalPrice, currency)}`
: 'Confirm Reservation'}
</button>
<p className="text-xs text-center text-muted-foreground">Payment due at check-in · No charge now</p>
</form>
</div>
</div>
</div>
</div>
</main>
)
}
export default function BookingPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><Loader2 className="h-10 w-10 animate-spin text-primary" /></div>}>
<BookingPageContent />
</Suspense>
)
}