'use client' import { useRouter, useSearchParams } from 'next/navigation' import { useEffect, useState } from 'react' import { DEFAULT_COMPANY_SLUG } from '@/lib/api' import { getPublicSiteDictionary, type PublicSiteLanguage } from '@/lib/i18n' interface VehicleDetail { id: string make: string model: string year: number category: string dailyRate: number seats: number transmission: string photos: string[] } interface PromoOffer { id: string title: string discountValue: number } interface InsurancePolicy { id: string name: string type: string chargeType: 'PER_DAY' | 'PER_RENTAL' | 'PERCENTAGE_OF_RENTAL' chargeValue: number isRequired: boolean } interface ContractSettings { fuelPolicyType: string fuelPolicyNote: string | null additionalDriverCharge: 'FREE' | 'PER_DAY' | 'FLAT' additionalDriverDailyRate: number additionalDriverFlatRate: number } interface BookingOptions { insurancePolicies: InsurancePolicy[] contractSettings: ContractSettings | null } interface Step1 { startDate: string endDate: string } interface Step2 { firstName: string lastName: string email: string phone: string driverLicense: string dateOfBirth: string licenseExpiry: string licenseIssuedAt: string nationality: string promoCode: string } interface AdditionalDriverForm { firstName: string lastName: string email: string phone: string driverLicense: string dateOfBirth: string licenseExpiry: string licenseIssuedAt: string nationality: string } const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' function diffDays(start: string, end: string): number { if (!start || !end) return 0 const ms = new Date(end).getTime() - new Date(start).getTime() return Math.max(1, Math.ceil(ms / 86400000)) } function fmt(n: number) { return n.toLocaleString('fr-MA', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 }) } function inputClass(error?: string) { return [ 'w-full rounded-xl border px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500', error ? 'border-red-400 bg-red-50' : 'border-slate-300 bg-white', ].join(' ') } 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) return policy.chargeValue } function additionalDriverCharge(settings: ContractSettings | null, count: number, days: number) { if (!settings || count === 0) return 0 if (settings.additionalDriverCharge === 'PER_DAY') return settings.additionalDriverDailyRate * count * days if (settings.additionalDriverCharge === 'FLAT') return settings.additionalDriverFlatRate * count return 0 } function getDayLabel(language: PublicSiteLanguage, days: number) { if (language === 'fr') return days === 1 ? 'jour' : 'jours' if (language === 'ar') return days === 1 ? 'يوم' : 'أيام' return days === 1 ? 'day' : 'days' } function StepDots({ step, steps, }: { step: number steps: { selectDates: string driverDetails: string reviewBooking: string payment: string } }) { return (
{[1, 2, 3, 4].map((n) => (
{n < step ? '✓' : n}
{n < 4 &&
}
))} {step === 1 && steps.selectDates} {step === 2 && steps.driverDetails} {step === 3 && steps.reviewBooking} {step === 4 && steps.payment}
) } function SummaryRow({ label, value, valueClass = 'text-slate-900', }: { label: string value: string valueClass?: string }) { return (
{label} {value}
) } export default function BookClient({ language }: { language: PublicSiteLanguage }) { const dict = getPublicSiteDictionary(language) const params = useSearchParams() const router = useRouter() const vehicleId = params.get('vehicleId') ?? '' const offerId = params.get('offerId') ?? undefined const ref = params.get('ref') ?? 'direct' const slug = params.get('slug') ?? DEFAULT_COMPANY_SLUG const [step, setStep] = useState(1) const [vehicle, setVehicle] = useState(null) const [vehicleLoading, setVehicleLoading] = useState(!!vehicleId) const [vehicleError, setVehicleError] = useState('') const [bookingOptions, setBookingOptions] = useState(null) const [bookingOptionsError, setBookingOptionsError] = useState('') const [step1, setStep1] = useState({ startDate: '', endDate: '' }) const [step1Errors, setStep1Errors] = useState>({}) const [step2, setStep2] = useState({ firstName: '', lastName: '', email: '', phone: '', driverLicense: '', dateOfBirth: '', licenseExpiry: '', licenseIssuedAt: '', nationality: '', promoCode: '', }) const [step2Errors, setStep2Errors] = useState>({}) const [additionalDriverEnabled, setAdditionalDriverEnabled] = useState(false) const [additionalDriver, setAdditionalDriver] = useState({ firstName: '', lastName: '', email: '', phone: '', driverLicense: '', dateOfBirth: '', licenseExpiry: '', licenseIssuedAt: '', nationality: '', }) const [selectedInsurancePolicyIds, setSelectedInsurancePolicyIds] = useState([]) const [promoApplied, setPromoApplied] = useState(null) const [promoLoading, setPromoLoading] = useState(false) const [promoError, setPromoError] = useState('') const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState('') const [reservationId, setReservationId] = useState(null) const [requiresManualApproval, setRequiresManualApproval] = useState(false) const [paymentProvider, setPaymentProvider] = useState<'AMANPAY' | 'PAYPAL' | 'CASH'>('AMANPAY') const [paymentCurrency, setPaymentCurrency] = useState<'MAD' | 'USD' | 'EUR'>('MAD') const [paymentLoading, setPaymentLoading] = useState(false) const [paymentError, setPaymentError] = useState('') useEffect(() => { if (!vehicleId) return setVehicleLoading(true) fetch(`${API_BASE}/site/${slug}/vehicles/${vehicleId}`) .then((r) => r.json()) .then((j) => { if (j?.data) setVehicle(j.data as VehicleDetail) else setVehicleError(dict.booking.vehicleNotFound) }) .catch(() => setVehicleError(dict.booking.vehicleLoadError)) .finally(() => setVehicleLoading(false)) }, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug]) useEffect(() => { fetch(`${API_BASE}/site/${slug}/booking-options`) .then((r) => r.json()) .then((j) => setBookingOptions(j.data as BookingOptions)) .catch(() => setBookingOptionsError(dict.booking.bookingOptionsLoadError)) }, [dict.booking.bookingOptionsLoadError, slug]) const days = diffDays(step1.startDate, step1.endDate) const baseTotal = vehicle ? vehicle.dailyRate * days : 0 const discount = promoApplied ? Math.round(baseTotal * (promoApplied.discountValue / 100)) : 0 const requiredPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => policy.isRequired) const optionalPolicies = (bookingOptions?.insurancePolicies ?? []).filter((policy) => !policy.isRequired) const selectedPolicies = [ ...requiredPolicies, ...optionalPolicies.filter((policy) => selectedInsurancePolicyIds.includes(policy.id)), ] const insuranceTotal = selectedPolicies.reduce((sum, policy) => sum + insuranceCharge(policy, days, baseTotal), 0) const additionalDriverTotal = additionalDriverCharge(bookingOptions?.contractSettings ?? null, additionalDriverEnabled ? 1 : 0, days) const grandTotal = baseTotal - discount + insuranceTotal + additionalDriverTotal const dayLabel = getDayLabel(language, days) function validateStep1() { const errs: Partial = {} if (!step1.startDate) errs.startDate = dict.booking.validation.pickStartDate if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate 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 } function validateStep2() { const errs: Record = {} if (!step2.firstName.trim()) errs.firstName = dict.booking.validation.firstNameRequired if (!step2.lastName.trim()) errs.lastName = dict.booking.validation.lastNameRequired if (!step2.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(step2.email)) errs.email = dict.booking.validation.validEmailRequired if (!step2.driverLicense.trim()) errs.driverLicense = dict.booking.validation.driverLicenseRequired if (!step2.dateOfBirth) errs.dateOfBirth = dict.booking.validation.dateOfBirthRequired if (!step2.licenseExpiry) errs.licenseExpiry = dict.booking.validation.licenseExpiryRequired if (!step2.licenseIssuedAt) errs.licenseIssuedAt = dict.booking.validation.licenseIssueDateRequired if (additionalDriverEnabled) { if (!additionalDriver.firstName.trim()) errs.additionalDriverFirstName = dict.booking.validation.additionalDriverFirstNameRequired if (!additionalDriver.lastName.trim()) errs.additionalDriverLastName = dict.booking.validation.additionalDriverLastNameRequired if (!additionalDriver.driverLicense.trim()) errs.additionalDriverLicense = dict.booking.validation.additionalDriverLicenseRequired } setStep2Errors(errs) return Object.keys(errs).length === 0 } async function applyPromo() { if (!step2.promoCode.trim()) return setPromoLoading(true) setPromoError('') setPromoApplied(null) try { const res = await fetch(`${API_BASE}/site/${slug}/book/validate-code`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: step2.promoCode.trim() }), }) const json = await res.json() if (!res.ok) setPromoError(json?.message ?? dict.booking.promo.invalid) else setPromoApplied(json.data as PromoOffer) } catch { setPromoError(dict.booking.promo.unavailable) } finally { setPromoLoading(false) } } async function handleSubmit() { setSubmitting(true) setSubmitError('') try { const res = await fetch(`${API_BASE}/site/${slug}/book`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vehicleId, offerId: promoApplied?.id ?? offerId, promoCodeUsed: promoApplied ? step2.promoCode.trim() : undefined, source: ref === 'marketplace' ? 'MARKETPLACE' : 'PUBLIC_SITE', startDate: new Date(step1.startDate).toISOString(), endDate: new Date(step1.endDate).toISOString(), firstName: step2.firstName.trim(), lastName: step2.lastName.trim(), email: step2.email.trim(), phone: step2.phone.trim() || undefined, driverLicense: step2.driverLicense.trim(), dateOfBirth: new Date(step2.dateOfBirth).toISOString(), licenseExpiry: new Date(step2.licenseExpiry).toISOString(), licenseIssuedAt: new Date(step2.licenseIssuedAt).toISOString(), nationality: step2.nationality.trim() || undefined, selectedInsurancePolicyIds, additionalDrivers: additionalDriverEnabled ? [{ firstName: additionalDriver.firstName.trim(), lastName: additionalDriver.lastName.trim(), email: additionalDriver.email.trim() || undefined, phone: additionalDriver.phone.trim() || undefined, driverLicense: additionalDriver.driverLicense.trim(), dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined, licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined, licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined, nationality: additionalDriver.nationality.trim() || undefined, }] : [], }), }) const json = await res.json() if (!res.ok) { setSubmitError(json?.message ?? dict.booking.submit.failed) return } setReservationId(json.data.id) setRequiresManualApproval(Boolean(json.data.requiresManualApproval)) setStep(4) if (json.data.requiresManualApproval) setPaymentProvider('CASH') } catch { setSubmitError(dict.booking.submit.network) } finally { setSubmitting(false) } } async function handlePayOnline() { if (!reservationId) return setPaymentLoading(true) setPaymentError('') try { const base = window.location.origin const res = await fetch(`${API_BASE}/site/${slug}/booking/${reservationId}/pay`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider: paymentProvider, currency: paymentCurrency, successUrl: `${base}/book/confirmation?id=${reservationId}&payment=success`, failureUrl: `${base}/book?vehicleId=${vehicleId}&payment=failed`, }), }) const json = await res.json() if (!res.ok) { setPaymentError(json?.message ?? dict.booking.payment.initiateFailed) return } window.location.href = json.data.checkoutUrl } catch { setPaymentError(dict.booking.payment.network) } finally { setPaymentLoading(false) } } function handlePayAtPickup() { if (!reservationId) return router.push(`/book/confirmation?id=${reservationId}`) } return (

{dict.booking.eyebrow}

{dict.booking.title}

{vehicleLoading &&
{dict.booking.loadingVehicle}
} {vehicleError &&
{vehicleError}
} {vehicle && (
{vehicle.photos[0] && ( // eslint-disable-next-line @next/next/no-img-element {`${vehicle.make} )}

{vehicle.make} {vehicle.model} ({vehicle.year})

{vehicle.category} · {vehicle.transmission} · {dict.vehicles.seats(vehicle.seats)}

{fmt(vehicle.dailyRate)} {dict.vehicles.detail.perDay}

)} {step === 1 && (
setStep1((s) => ({ ...s, startDate: e.target.value }))} className={inputClass(step1Errors.startDate)} /> {step1Errors.startDate &&

{step1Errors.startDate}

}
setStep1((s) => ({ ...s, endDate: e.target.value }))} className={inputClass(step1Errors.endDate)} /> {step1Errors.endDate &&

{step1Errors.endDate}

}
{days > 0 && vehicle && (
{days} {dayLabel} × {fmt(vehicle.dailyRate)} = {fmt(baseTotal)}
)}
)} {step === 2 && (
setStep2((s) => ({ ...s, firstName: e.target.value }))} placeholder={dict.booking.firstName} className={inputClass(step2Errors.firstName)} /> {step2Errors.firstName &&

{step2Errors.firstName}

}
setStep2((s) => ({ ...s, lastName: e.target.value }))} placeholder={dict.booking.lastName} className={inputClass(step2Errors.lastName)} /> {step2Errors.lastName &&

{step2Errors.lastName}

}
setStep2((s) => ({ ...s, email: e.target.value }))} placeholder="you@example.com" className={inputClass(step2Errors.email)} /> {step2Errors.email &&

{step2Errors.email}

}
setStep2((s) => ({ ...s, phone: e.target.value }))} placeholder="+212 6xx xxx xxx" className={inputClass()} />
setStep2((s) => ({ ...s, driverLicense: e.target.value }))} placeholder={dict.booking.driverLicense} className={inputClass(step2Errors.driverLicense)} /> {step2Errors.driverLicense &&

{step2Errors.driverLicense}

}
setStep2((s) => ({ ...s, nationality: e.target.value }))} placeholder={dict.booking.nationality} className={inputClass()} />
setStep2((s) => ({ ...s, dateOfBirth: e.target.value }))} className={inputClass(step2Errors.dateOfBirth)} /> {step2Errors.dateOfBirth &&

{step2Errors.dateOfBirth}

}
setStep2((s) => ({ ...s, licenseIssuedAt: e.target.value }))} className={inputClass(step2Errors.licenseIssuedAt)} /> {step2Errors.licenseIssuedAt &&

{step2Errors.licenseIssuedAt}

}
setStep2((s) => ({ ...s, licenseExpiry: e.target.value }))} className={inputClass(step2Errors.licenseExpiry)} /> {step2Errors.licenseExpiry &&

{step2Errors.licenseExpiry}

}
{bookingOptionsError &&
{bookingOptionsError}
} {bookingOptions && (

{dict.booking.insuranceAndPickupPolicies}

{bookingOptions.contractSettings?.fuelPolicyNote || dict.booking.insuranceHint}

{selectedPolicies.length > 0 && (
{dict.booking.requiredCoverage}: {requiredPolicies.map((policy) => policy.name).join(', ') || dict.booking.none}
)} {optionalPolicies.length > 0 ? (
{optionalPolicies.map((policy) => { const selected = selectedInsurancePolicyIds.includes(policy.id) return ( ) })}
) : (

{dict.booking.noOptionalInsurance}

)}
{additionalDriverEnabled && (
setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.firstName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverFirstName)} /> {step2Errors.additionalDriverFirstName &&

{step2Errors.additionalDriverFirstName}

}
setAdditionalDriver((current) => ({ ...current, lastName: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.lastName.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLastName)} /> {step2Errors.additionalDriverLastName &&

{step2Errors.additionalDriverLastName}

}
setAdditionalDriver((current) => ({ ...current, email: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.email.toLowerCase()}`} className={inputClass()} /> setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.phone.toLowerCase()}`} className={inputClass()} />
setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.driverLicense.toLowerCase()}`} className={inputClass(step2Errors.additionalDriverLicense)} /> {step2Errors.additionalDriverLicense &&

{step2Errors.additionalDriverLicense}

}
setAdditionalDriver((current) => ({ ...current, nationality: e.target.value }))} placeholder={`${dict.booking.additionalDriver} ${dict.booking.nationality.toLowerCase()}`} className={inputClass()} /> setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))} className={inputClass()} /> setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))} className={inputClass()} /> setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))} className={inputClass()} />
)}
)}
{ setStep2((s) => ({ ...s, promoCode: e.target.value })) setPromoApplied(null) setPromoError('') }} placeholder={dict.booking.enterCode} className={`flex-1 ${inputClass(promoError || undefined)}`} />
{promoError &&

{promoError}

} {promoApplied &&

{dict.booking.appliedPromo(promoApplied.title, promoApplied.discountValue)}

}
)} {step === 3 && (
${step1.endDate} (${days} ${dayLabel})`} /> {additionalDriverEnabled && } {promoApplied && }
{days} {dayLabel} × {fmt(vehicle?.dailyRate ?? 0)} {fmt(baseTotal)}
{discount > 0 && (
{dict.booking.summary.discount(promoApplied?.discountValue ?? 0)} -{fmt(discount)}
)} {selectedPolicies.map((policy) => (
{policy.name} {fmt(insuranceCharge(policy, days, baseTotal))}
))} {additionalDriverEnabled && (
{dict.booking.additionalDriver} {fmt(additionalDriverTotal)}
)}
{dict.booking.summary.total} {fmt(grandTotal)}
{submitError &&
{submitError}
}
)} {step === 4 && (
{dict.booking.reservationCreated}
{requiresManualApproval && (
{dict.booking.manualApprovalRequired}
)}
{dict.booking.totalDue} {fmt(grandTotal)}

{dict.booking.currency}

{(['MAD', 'USD', 'EUR'] as const).map((currency) => ( ))}

{dict.booking.paymentMethod}

{(['AMANPAY', 'PAYPAL', 'CASH'] as const).map((provider) => { const disabled = requiresManualApproval && provider !== 'CASH' return ( ) })}
{paymentError &&
{paymentError}
}
)}
) }