add car reservation feature

This commit is contained in:
root
2026-05-09 21:10:38 -04:00
parent 09b0e3b55f
commit 6322b7d2a1
37 changed files with 1169 additions and 119 deletions
+30 -2
View File
@@ -15,6 +15,9 @@ interface VehicleDetail {
seats: number
transmission: string
photos: string[]
availability: boolean
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt: string | null
}
interface PromoOffer {
@@ -94,6 +97,11 @@ function inputClass(error?: string) {
].join(' ')
}
function formatAvailabilityDate(value?: string | null) {
if (!value) return null
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
}
function insuranceCharge(policy: InsurancePolicy, days: number, baseTotal: number) {
if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days
if (policy.chargeType === 'PERCENTAGE_OF_RENTAL') return Math.round(baseTotal * policy.chargeValue / 100)
@@ -238,6 +246,19 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
.finally(() => setVehicleLoading(false))
}, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug])
useEffect(() => {
if (!vehicle?.nextAvailableAt) return
const minStart = new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10)
const minEnd = new Date(new Date(minStart).getTime() + 86400000).toISOString().slice(0, 10)
setStep1((current) => {
const nextStart = !current.startDate || current.startDate < minStart ? minStart : current.startDate
const nextEnd = !current.endDate || current.endDate <= nextStart ? minEnd : current.endDate
if (nextStart === current.startDate && nextEnd === current.endDate) return current
return { startDate: nextStart, endDate: nextEnd }
})
}, [vehicle?.nextAvailableAt])
useEffect(() => {
fetch(`${API_BASE}/site/${slug}/booking-options`)
.then((r) => r.json())
@@ -261,8 +282,10 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
function validateStep1() {
const errs: Partial<Step1> = {}
const minStart = vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : ''
if (!step1.startDate) errs.startDate = dict.booking.validation.pickStartDate
if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate
if (minStart && step1.startDate && step1.startDate < minStart) errs.startDate = dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle?.nextAvailableAt) ?? minStart)
if (step1.startDate && step1.endDate && new Date(step1.endDate) <= new Date(step1.startDate)) errs.endDate = dict.booking.validation.endDateAfterStart
setStep1Errors(errs)
return Object.keys(errs).length === 0
@@ -346,7 +369,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
})
const json = await res.json()
if (!res.ok) {
setSubmitError(json?.message ?? dict.booking.submit.failed)
setSubmitError(json?.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(json.nextAvailableAt) ?? json.nextAvailableAt) : (json?.message ?? dict.booking.submit.failed))
return
}
setReservationId(json.data.id)
@@ -420,13 +443,18 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
</div>
</div>
)}
{vehicle && !vehicle.availability && (
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{vehicle.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle.nextAvailableAt) ?? vehicle.nextAvailableAt) : dict.booking.vehicleUnavailableNoDate}
</div>
)}
{step === 1 && (
<div className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.startDate}</label>
<input type="date" value={step1.startDate} min={new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, startDate: e.target.value }))} className={inputClass(step1Errors.startDate)} />
<input type="date" value={step1.startDate} min={vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, startDate: e.target.value, endDate: !s.endDate || s.endDate <= e.target.value ? new Date(new Date(e.target.value).getTime() + 86400000).toISOString().slice(0, 10) : s.endDate }))} className={inputClass(step1Errors.startDate)} />
{step1Errors.startDate && <p className="mt-1 text-xs text-red-600">{step1Errors.startDate}</p>}
</div>
<div>
@@ -18,6 +18,9 @@ interface VehicleDetail {
fuelType: string
features: string[]
photos: string[]
availability: boolean
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt: string | null
}
interface BrandResponse {
@@ -53,6 +56,10 @@ export default async function VehiclePage({ params }: { params: { id: string } }
)
}
const availabilityDate = vehicle.nextAvailableAt
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
: null
return (
<main className="min-h-screen bg-slate-50 py-10">
<div className={`shell grid gap-8 ${pageSections.vehicleDetail.gallery && pageSections.vehicleDetail.summary ? 'lg:grid-cols-[1.2fr_0.8fr]' : ''}`}>
@@ -68,6 +75,11 @@ export default async function VehiclePage({ params }: { params: { id: string } }
<h1 className="text-4xl font-black text-slate-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-slate-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-slate-500"> {dict.vehicles.detail.perDay}</span></p>
{!vehicle.availability && (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{availabilityDate ? `${dict.vehicles.detail.availableFrom} ${availabilityDate}` : dict.vehicles.detail.unavailableNoDate}
</div>
)}
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{feature}</span>
+16
View File
@@ -129,6 +129,8 @@ export type PublicSiteDictionary = {
perDay: string
bookNow: string
back: string
availableFrom: string
unavailableNoDate: string
}
}
booking: {
@@ -144,6 +146,8 @@ export type PublicSiteDictionary = {
loadingVehicle: string
vehicleNotFound: string
vehicleLoadError: string
vehicleAvailableFrom: (date: string) => string
vehicleUnavailableNoDate: string
bookingOptionsLoadError: string
startDate: string
endDate: string
@@ -378,6 +382,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
perDay: '/ day',
bookNow: 'Book now',
back: 'Back',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
},
},
booking: {
@@ -393,6 +399,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
loadingVehicle: 'Loading vehicle...',
vehicleNotFound: 'Vehicle not found.',
vehicleLoadError: 'Could not load vehicle details.',
vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`,
vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
bookingOptionsLoadError: 'Could not load booking options.',
startDate: 'Start date',
endDate: 'End date',
@@ -625,6 +633,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
perDay: '/ jour',
bookNow: 'Réserver maintenant',
back: 'Retour',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
},
},
booking: {
@@ -640,6 +650,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
loadingVehicle: 'Chargement du véhicule...',
vehicleNotFound: 'Véhicule introuvable.',
vehicleLoadError: 'Impossible de charger les détails du véhicule.',
vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`,
vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
startDate: 'Date de début',
endDate: 'Date de fin',
@@ -872,6 +884,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
perDay: '/ يوم',
bookNow: 'احجز الآن',
back: 'رجوع',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
},
},
booking: {
@@ -887,6 +901,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
loadingVehicle: 'جاري تحميل المركبة...',
vehicleNotFound: 'المركبة غير موجودة.',
vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.',
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
startDate: 'تاريخ البداية',
endDate: 'تاريخ النهاية',