Deploy
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
'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, CheckCircle, 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">
|
||||
<CheckCircle 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user