add car reservation feature
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: 'تاريخ النهاية',
|
||||
|
||||
Reference in New Issue
Block a user