722 lines
31 KiB
TypeScript
722 lines
31 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { formatCurrency } from '@rentaldrivego/types'
|
||
import { MarketplaceApiError, marketplaceFetch, marketplacePost } from '@/lib/api'
|
||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||
|
||
type Props = {
|
||
vehicleId: string
|
||
companySlug: string
|
||
dailyRate: number
|
||
pickupLocations: string[]
|
||
allowDifferentDropoff: boolean
|
||
dropoffLocations: string[]
|
||
}
|
||
|
||
type DropoffMode = 'same' | 'different'
|
||
|
||
type BookingFields = {
|
||
firstName: string
|
||
lastName: string
|
||
email: string
|
||
phone: string
|
||
pickupLocation: string
|
||
dropoffMode: DropoffMode
|
||
returnLocation: string
|
||
startDate: string
|
||
endDate: string
|
||
notes: string
|
||
dateOfBirth: string
|
||
nationality: string
|
||
identityDocumentNumber: string
|
||
fullAddress: string
|
||
driverLicense: string
|
||
licenseIssuedAt: string
|
||
licenseExpiry: string
|
||
licenseCountry: string
|
||
licenseCategory: string
|
||
internationalLicenseNumber: string
|
||
}
|
||
|
||
type AvailabilityState = {
|
||
status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error'
|
||
nextAvailableAt: string | null
|
||
}
|
||
|
||
type AvailabilityResponse = {
|
||
available: boolean
|
||
availabilityStatus: string
|
||
nextAvailableAt: string | null
|
||
blockingReason: string | null
|
||
}
|
||
|
||
type BookingResponse = {
|
||
reservationId: string
|
||
bookingReference?: string | null
|
||
companyName?: string
|
||
vehicleName?: string
|
||
startDate?: string
|
||
endDate?: string
|
||
status?: string
|
||
message?: string
|
||
}
|
||
|
||
type FunnelEventName =
|
||
| 'booking_form_viewed'
|
||
| 'trip_dates_selected'
|
||
| 'contact_details_started'
|
||
| 'driver_details_expanded'
|
||
| 'booking_request_submitted'
|
||
| 'booking_request_failed'
|
||
| 'booking_request_success'
|
||
|
||
const copy = {
|
||
en: {
|
||
title: 'Request this car in 1 minute',
|
||
subtitle: 'Send the essentials now. Driver documents can be added later if the company needs them.',
|
||
tripDetails: 'Trip details',
|
||
contactDetails: 'Contact details',
|
||
optionalDetails: 'Driver details, optional now',
|
||
optionalDetailsHint: 'Skip this unless you want to speed up final approval. The company can collect it after confirming availability.',
|
||
showDriverDetails: 'Add driver details now',
|
||
hideDriverDetails: 'Hide driver details',
|
||
firstName: 'First name',
|
||
lastName: 'Last name',
|
||
email: 'Email',
|
||
phone: 'Phone',
|
||
dateOfBirth: 'Date of birth',
|
||
nationality: 'Nationality',
|
||
identityDocumentNumber: 'CIN / Passport number',
|
||
fullAddress: 'Full address',
|
||
driverLicense: 'License number',
|
||
licenseIssuedAt: 'Issue date',
|
||
licenseExpiry: 'Expiry date',
|
||
licenseCountry: 'Country of issue',
|
||
licenseCategory: 'License category',
|
||
internationalLicenseNumber: 'International permit number',
|
||
pickupLocation: 'Pick-up location',
|
||
returnLocation: 'Drop-off location',
|
||
sameDropoff: 'Same drop-off',
|
||
differentDropoff: 'Different drop-off',
|
||
sameAsPickup: 'Return to the same location',
|
||
startDate: 'Pick-up date',
|
||
endDate: 'Return date',
|
||
notes: 'Anything the company should know? Optional.',
|
||
submit: 'Send booking request',
|
||
submitting: 'Sending…',
|
||
successTitle: 'Request sent.',
|
||
successBody: 'This first step is a request, not an instant booking. Here is what happens next.',
|
||
bookingReference: 'Booking reference',
|
||
pendingStatus: 'Pending',
|
||
invalidDates: 'Return date must be after pick-up date.',
|
||
required: 'Add your trip dates, name, email, and phone.',
|
||
perDay: '/ day',
|
||
estimatedTotal: 'Estimated total',
|
||
days: 'day(s)',
|
||
genericError: 'Something went wrong. Please try again.',
|
||
locationRequired: 'Select the required pick-up and drop-off locations.',
|
||
checkingAvailability: 'Checking availability for these dates…',
|
||
likelyAvailable: 'Likely available for these dates. Final confirmation still depends on the rental company.',
|
||
unavailableWithDate: 'Unavailable for these dates. Next likely availability: {date}.',
|
||
unavailableWithoutDate: 'Unavailable for these dates. Choose different trip dates to continue.',
|
||
availabilityError: 'Availability could not be checked right now. You can still try again in a moment.',
|
||
nextStepRequestSent: 'Request sent',
|
||
nextStepRequestSentBody: 'The company receives your dates and contact details first.',
|
||
nextStepAvailability: 'Company confirms availability',
|
||
nextStepAvailabilityBody: 'They review the request and accept it if the car is still open.',
|
||
nextStepDocuments: 'Driver documents if needed',
|
||
nextStepDocumentsBody: 'License and identity details can be collected after the company accepts the request.',
|
||
nextStepPayment: 'Payment or deposit',
|
||
nextStepPaymentBody: 'Any required payment is handled only after availability is confirmed.',
|
||
nextStepConfirmed: 'Booking confirmed',
|
||
nextStepConfirmedBody: 'The booking is final once availability, documents, and payment are all cleared.',
|
||
},
|
||
fr: {
|
||
title: 'Demander cette voiture en 1 minute',
|
||
subtitle: 'Envoyez l’essentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si l’agence les demande.',
|
||
tripDetails: 'Détails du trajet',
|
||
contactDetails: 'Coordonnées',
|
||
optionalDetails: 'Détails conducteur, optionnels maintenant',
|
||
optionalDetailsHint: 'Ignorez cette partie sauf si vous voulez accélérer la validation finale. L’agence pourra les demander après confirmation de disponibilité.',
|
||
showDriverDetails: 'Ajouter les détails conducteur',
|
||
hideDriverDetails: 'Masquer les détails conducteur',
|
||
firstName: 'Prénom',
|
||
lastName: 'Nom',
|
||
email: 'E-mail',
|
||
phone: 'Téléphone',
|
||
dateOfBirth: 'Date de naissance',
|
||
nationality: 'Nationalité',
|
||
identityDocumentNumber: 'N° CIN / passeport',
|
||
fullAddress: 'Adresse complète',
|
||
driverLicense: 'Numéro du permis',
|
||
licenseIssuedAt: 'Date de délivrance',
|
||
licenseExpiry: 'Date d’expiration',
|
||
licenseCountry: 'Pays de délivrance',
|
||
licenseCategory: 'Catégorie du permis',
|
||
internationalLicenseNumber: 'N° de permis international',
|
||
pickupLocation: 'Lieu de départ',
|
||
returnLocation: 'Lieu de retour',
|
||
sameDropoff: 'Même retour',
|
||
differentDropoff: 'Retour différent',
|
||
sameAsPickup: 'Retour au même lieu',
|
||
startDate: 'Date de départ',
|
||
endDate: 'Date de retour',
|
||
notes: 'Une information utile pour l’agence ? Optionnel.',
|
||
submit: 'Envoyer la demande',
|
||
submitting: 'Envoi…',
|
||
successTitle: 'Demande envoyée.',
|
||
successBody: 'Cette première étape reste une demande, pas une réservation instantanée. Voici la suite.',
|
||
bookingReference: 'Référence de réservation',
|
||
pendingStatus: 'En attente',
|
||
invalidDates: 'La date de retour doit être après la date de départ.',
|
||
required: 'Ajoutez vos dates, votre nom, votre e-mail et votre téléphone.',
|
||
perDay: '/ jour',
|
||
estimatedTotal: 'Total estimé',
|
||
days: 'jour(s)',
|
||
genericError: 'Une erreur est survenue. Veuillez réessayer.',
|
||
locationRequired: 'Sélectionnez les lieux de départ et de retour requis.',
|
||
checkingAvailability: 'Vérification de la disponibilité pour ces dates…',
|
||
likelyAvailable: 'Probablement disponible pour ces dates. La confirmation finale dépend encore de l’agence.',
|
||
unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.',
|
||
unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez d’autres dates pour continuer.',
|
||
availabilityError: 'La disponibilité ne peut pas être vérifiée pour le moment. Réessayez dans un instant.',
|
||
nextStepRequestSent: 'Demande envoyée',
|
||
nextStepRequestSentBody: 'L’agence reçoit d’abord vos dates et vos coordonnées.',
|
||
nextStepAvailability: 'Confirmation de disponibilité',
|
||
nextStepAvailabilityBody: 'L’agence vérifie la demande et l’accepte si la voiture est toujours libre.',
|
||
nextStepDocuments: 'Documents conducteur si nécessaires',
|
||
nextStepDocumentsBody: 'Le permis et les pièces d’identité peuvent être demandés après l’acceptation de la demande.',
|
||
nextStepPayment: 'Paiement ou dépôt',
|
||
nextStepPaymentBody: 'Le paiement demandé intervient seulement après la confirmation de disponibilité.',
|
||
nextStepConfirmed: 'Réservation confirmée',
|
||
nextStepConfirmedBody: 'La réservation devient finale une fois la disponibilité, les documents et le paiement validés.',
|
||
},
|
||
ar: {
|
||
title: 'اطلب هذه السيارة خلال دقيقة',
|
||
subtitle: 'أرسل المعلومات الأساسية الآن. يمكن إضافة وثائق السائق لاحقاً إذا احتاجت الشركة إليها.',
|
||
tripDetails: 'تفاصيل الرحلة',
|
||
contactDetails: 'بيانات التواصل',
|
||
optionalDetails: 'بيانات السائق، اختيارية الآن',
|
||
optionalDetailsHint: 'تجاوز هذا الجزء إلا إذا أردت تسريع الموافقة النهائية. يمكن للشركة طلبه بعد تأكيد التوفر.',
|
||
showDriverDetails: 'إضافة بيانات السائق الآن',
|
||
hideDriverDetails: 'إخفاء بيانات السائق',
|
||
firstName: 'الاسم الأول',
|
||
lastName: 'اسم العائلة',
|
||
email: 'البريد الإلكتروني',
|
||
phone: 'الهاتف',
|
||
dateOfBirth: 'تاريخ الميلاد',
|
||
nationality: 'الجنسية',
|
||
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
||
fullAddress: 'العنوان الكامل',
|
||
driverLicense: 'رقم الرخصة',
|
||
licenseIssuedAt: 'تاريخ الإصدار',
|
||
licenseExpiry: 'تاريخ الانتهاء',
|
||
licenseCountry: 'بلد الإصدار',
|
||
licenseCategory: 'فئة الرخصة',
|
||
internationalLicenseNumber: 'رقم الرخصة الدولية',
|
||
pickupLocation: 'موقع الاستلام',
|
||
returnLocation: 'موقع الإرجاع',
|
||
sameDropoff: 'نفس موقع الإرجاع',
|
||
differentDropoff: 'موقع إرجاع مختلف',
|
||
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
|
||
startDate: 'تاريخ الاستلام',
|
||
endDate: 'تاريخ الإرجاع',
|
||
notes: 'أي معلومة تريد إبلاغ الشركة بها؟ اختياري.',
|
||
submit: 'إرسال طلب الحجز',
|
||
submitting: 'جارٍ الإرسال…',
|
||
successTitle: 'تم إرسال الطلب.',
|
||
successBody: 'هذه الخطوة الأولى عبارة عن طلب وليست حجزاً فورياً. هذه هي الخطوات التالية.',
|
||
bookingReference: 'مرجع الحجز',
|
||
pendingStatus: 'قيد المراجعة',
|
||
invalidDates: 'يجب أن يكون تاريخ الإرجاع بعد تاريخ الاستلام.',
|
||
required: 'أضف تواريخ الرحلة والاسم والبريد والهاتف.',
|
||
perDay: '/ يوم',
|
||
estimatedTotal: 'الإجمالي التقديري',
|
||
days: 'يوم',
|
||
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||
locationRequired: 'اختر مواقع الاستلام والإرجاع المطلوبة.',
|
||
checkingAvailability: 'جارٍ التحقق من التوفر لهذه التواريخ…',
|
||
likelyAvailable: 'السيارة متاحة غالباً لهذه التواريخ، لكن التأكيد النهائي يعود إلى شركة التأجير.',
|
||
unavailableWithDate: 'غير متاحة لهذه التواريخ. أقرب توفر متوقع: {date}.',
|
||
unavailableWithoutDate: 'غير متاحة لهذه التواريخ. اختر تواريخ مختلفة للمتابعة.',
|
||
availabilityError: 'تعذر التحقق من التوفر الآن. حاول مرة أخرى بعد قليل.',
|
||
nextStepRequestSent: 'تم إرسال الطلب',
|
||
nextStepRequestSentBody: 'تتلقى الشركة أولاً التواريخ وبيانات التواصل.',
|
||
nextStepAvailability: 'تأكيد التوفر',
|
||
nextStepAvailabilityBody: 'تراجع الشركة الطلب وتقبله إذا كانت السيارة ما زالت متاحة.',
|
||
nextStepDocuments: 'وثائق السائق عند الحاجة',
|
||
nextStepDocumentsBody: 'يمكن طلب الرخصة ووثائق الهوية بعد قبول الطلب.',
|
||
nextStepPayment: 'الدفع أو العربون',
|
||
nextStepPaymentBody: 'أي دفعة مطلوبة تتم فقط بعد تأكيد التوفر.',
|
||
nextStepConfirmed: 'تأكيد الحجز',
|
||
nextStepConfirmedBody: 'يصبح الحجز نهائياً بعد اعتماد التوفر والوثائق والدفع.',
|
||
},
|
||
} as const
|
||
|
||
const emptyFields: BookingFields = {
|
||
firstName: '',
|
||
lastName: '',
|
||
email: '',
|
||
phone: '',
|
||
pickupLocation: '',
|
||
dropoffMode: 'same',
|
||
returnLocation: '',
|
||
startDate: '',
|
||
endDate: '',
|
||
notes: '',
|
||
dateOfBirth: '',
|
||
nationality: '',
|
||
identityDocumentNumber: '',
|
||
fullAddress: '',
|
||
driverLicense: '',
|
||
licenseIssuedAt: '',
|
||
licenseExpiry: '',
|
||
licenseCountry: '',
|
||
licenseCategory: '',
|
||
internationalLicenseNumber: '',
|
||
}
|
||
|
||
export function calculateBookingTotalDays(startDate: string, endDate: string): number {
|
||
if (!startDate || !endDate) return 0
|
||
return Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
|
||
}
|
||
|
||
export function resolveReturnLocation({
|
||
dropoffMode,
|
||
returnLocation,
|
||
pickupLocation,
|
||
}: {
|
||
dropoffMode: DropoffMode
|
||
returnLocation: string
|
||
pickupLocation: string
|
||
}): string | undefined {
|
||
if (dropoffMode === 'different') return returnLocation || undefined
|
||
return pickupLocation || undefined
|
||
}
|
||
|
||
export function normalizeOptionalText(value: string): string | undefined {
|
||
const trimmed = value.trim()
|
||
return trimmed || undefined
|
||
}
|
||
|
||
function interpolate(template: string, replacements: Record<string, string>) {
|
||
return Object.entries(replacements).reduce(
|
||
(current, [key, value]) => current.replace(`{${key}}`, value),
|
||
template,
|
||
)
|
||
}
|
||
|
||
function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') {
|
||
return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value))
|
||
}
|
||
|
||
function getBookingSessionId() {
|
||
if (typeof window === 'undefined') return 'server'
|
||
|
||
const existing = window.sessionStorage.getItem('marketplace-booking-session-id')
|
||
if (existing) return existing
|
||
|
||
const created =
|
||
typeof window.crypto?.randomUUID === 'function'
|
||
? window.crypto.randomUUID()
|
||
: `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||
|
||
window.sessionStorage.setItem('marketplace-booking-session-id', created)
|
||
return created
|
||
}
|
||
|
||
function RequiredMark() {
|
||
return <span className="text-red-500">*</span>
|
||
}
|
||
|
||
type FieldProps = {
|
||
label: string
|
||
value: string
|
||
onChange: (value: string) => void
|
||
required?: boolean
|
||
type?: string
|
||
textarea?: boolean
|
||
}
|
||
|
||
function Field({ label, value, onChange, required = false, type = 'text', textarea = false }: FieldProps) {
|
||
const className = 'w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500'
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">
|
||
{label} {required ? <RequiredMark /> : null}
|
||
</label>
|
||
{textarea ? (
|
||
<textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} className={className} />
|
||
) : (
|
||
<input required={required} type={type} value={value} onChange={(e) => onChange(e.target.value)} className={className} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function BookingForm({
|
||
vehicleId,
|
||
companySlug,
|
||
dailyRate,
|
||
pickupLocations,
|
||
allowDifferentDropoff,
|
||
dropoffLocations,
|
||
}: Props) {
|
||
const { language } = useMarketplacePreferences()
|
||
const t = copy[language]
|
||
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
|
||
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
|
||
|
||
const [fields, setFields] = useState<BookingFields>({
|
||
...emptyFields,
|
||
pickupLocation: pickupOptions[0] ?? '',
|
||
returnLocation: dropoffOptions[0] ?? '',
|
||
})
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [success, setSuccess] = useState(false)
|
||
const [successBooking, setSuccessBooking] = useState<BookingResponse | null>(null)
|
||
const [availability, setAvailability] = useState<AvailabilityState>({ status: 'idle', nextAvailableAt: null })
|
||
|
||
const totalDays = calculateBookingTotalDays(fields.startDate, fields.endDate)
|
||
const sessionIdRef = useRef<string>('')
|
||
const startedAtRef = useRef<number>(Date.now())
|
||
const trackedRef = useRef({
|
||
viewed: false,
|
||
tripDates: false,
|
||
contactStarted: false,
|
||
})
|
||
|
||
if (!sessionIdRef.current) {
|
||
sessionIdRef.current = getBookingSessionId()
|
||
}
|
||
|
||
function buildUnavailableMessage(nextAvailableAt: string | null) {
|
||
return nextAvailableAt
|
||
? interpolate(t.unavailableWithDate, { date: formatLocalizedDate(nextAvailableAt, language) })
|
||
: t.unavailableWithoutDate
|
||
}
|
||
|
||
async function trackEvent(eventName: FunnelEventName, metadata?: Record<string, string | number | boolean | null>) {
|
||
await marketplacePost('/marketplace/events', {
|
||
eventName,
|
||
companySlug,
|
||
vehicleId,
|
||
sessionId: sessionIdRef.current,
|
||
path: typeof window === 'undefined' ? undefined : window.location.pathname,
|
||
metadata,
|
||
}).catch(() => null)
|
||
}
|
||
|
||
async function checkAvailability(startDate: string, endDate: string, signal?: AbortSignal) {
|
||
const start = new Date(startDate)
|
||
const end = new Date(endDate)
|
||
const result = await marketplaceFetch<AvailabilityResponse>(
|
||
`/marketplace/${companySlug}/vehicles/${vehicleId}/availability?startDate=${encodeURIComponent(start.toISOString())}&endDate=${encodeURIComponent(end.toISOString())}`,
|
||
signal ? { signal } : undefined,
|
||
)
|
||
|
||
setAvailability({
|
||
status: result.available ? 'available' : 'unavailable',
|
||
nextAvailableAt: result.nextAvailableAt,
|
||
})
|
||
|
||
return result
|
||
}
|
||
|
||
function update<K extends keyof BookingFields>(key: K, value: BookingFields[K]) {
|
||
setFields((current) => ({ ...current, [key]: value }))
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (trackedRef.current.viewed) return
|
||
trackedRef.current.viewed = true
|
||
void trackEvent('booking_form_viewed', {
|
||
allowDifferentDropoff,
|
||
hasPickupLocations: pickupOptions.length > 0,
|
||
hasDropoffLocations: dropoffOptions.length > 0,
|
||
})
|
||
}, [allowDifferentDropoff, dropoffOptions.length, pickupOptions.length])
|
||
|
||
useEffect(() => {
|
||
if (trackedRef.current.contactStarted) return
|
||
if (!fields.firstName.trim() && !fields.lastName.trim() && !fields.email.trim() && !fields.phone.trim()) return
|
||
|
||
trackedRef.current.contactStarted = true
|
||
void trackEvent('contact_details_started')
|
||
}, [fields.email, fields.firstName, fields.lastName, fields.phone])
|
||
|
||
useEffect(() => {
|
||
if (!fields.startDate || !fields.endDate) {
|
||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||
return
|
||
}
|
||
|
||
const start = new Date(fields.startDate)
|
||
const end = new Date(fields.endDate)
|
||
if (end <= start) {
|
||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||
return
|
||
}
|
||
|
||
if (!trackedRef.current.tripDates) {
|
||
trackedRef.current.tripDates = true
|
||
void trackEvent('trip_dates_selected', { totalDays: calculateBookingTotalDays(fields.startDate, fields.endDate) })
|
||
}
|
||
|
||
const controller = new AbortController()
|
||
setAvailability((current) => ({ ...current, status: 'checking' }))
|
||
|
||
void checkAvailability(fields.startDate, fields.endDate, controller.signal).catch((err) => {
|
||
if (controller.signal.aborted) return
|
||
if (err instanceof MarketplaceApiError && err.code === 'invalid_dates') {
|
||
setAvailability({ status: 'idle', nextAvailableAt: null })
|
||
return
|
||
}
|
||
setAvailability({ status: 'error', nextAvailableAt: null })
|
||
})
|
||
|
||
return () => controller.abort()
|
||
}, [companySlug, fields.endDate, fields.startDate, vehicleId])
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setError(null)
|
||
|
||
if (!fields.firstName.trim() || !fields.lastName.trim() || !fields.email.trim() || !fields.phone.trim() || !fields.startDate || !fields.endDate) {
|
||
setError(t.required)
|
||
return
|
||
}
|
||
|
||
if ((pickupOptions.length > 0 && !fields.pickupLocation) || (fields.dropoffMode === 'different' && dropoffOptions.length > 0 && !fields.returnLocation)) {
|
||
setError(t.locationRequired)
|
||
return
|
||
}
|
||
|
||
const start = new Date(fields.startDate)
|
||
const end = new Date(fields.endDate)
|
||
if (end <= start) {
|
||
setError(t.invalidDates)
|
||
return
|
||
}
|
||
|
||
setSubmitting(true)
|
||
|
||
try {
|
||
void trackEvent('booking_request_submitted', {
|
||
totalDays,
|
||
})
|
||
|
||
const availabilityResult = await checkAvailability(fields.startDate, fields.endDate)
|
||
if (!availabilityResult.available) {
|
||
const message = buildUnavailableMessage(availabilityResult.nextAvailableAt)
|
||
setError(message)
|
||
void trackEvent('booking_request_failed', {
|
||
reason: 'unavailable',
|
||
nextAvailableAt: availabilityResult.nextAvailableAt,
|
||
})
|
||
return
|
||
}
|
||
|
||
const booking = await marketplacePost<BookingResponse>('/marketplace/reservations', {
|
||
vehicleId,
|
||
companySlug,
|
||
firstName: fields.firstName.trim(),
|
||
lastName: fields.lastName.trim(),
|
||
email: fields.email.trim(),
|
||
phone: fields.phone.trim(),
|
||
startDate: start.toISOString(),
|
||
endDate: end.toISOString(),
|
||
pickupLocation: fields.pickupLocation || undefined,
|
||
returnLocation: resolveReturnLocation({
|
||
dropoffMode: fields.dropoffMode,
|
||
returnLocation: fields.returnLocation,
|
||
pickupLocation: fields.pickupLocation,
|
||
}),
|
||
notes: normalizeOptionalText(fields.notes),
|
||
language,
|
||
})
|
||
|
||
setSuccessBooking(booking)
|
||
setSuccess(true)
|
||
void trackEvent('booking_request_success', {
|
||
totalDays,
|
||
elapsedMs: Date.now() - startedAtRef.current,
|
||
})
|
||
} catch (err: unknown) {
|
||
if (err instanceof MarketplaceApiError && err.code === 'unavailable') {
|
||
setAvailability({ status: 'unavailable', nextAvailableAt: err.nextAvailableAt ?? null })
|
||
setError(buildUnavailableMessage(err.nextAvailableAt ?? null))
|
||
} else {
|
||
setError(err instanceof Error ? err.message : t.genericError)
|
||
}
|
||
|
||
void trackEvent('booking_request_failed', {
|
||
reason: err instanceof MarketplaceApiError ? err.code ?? 'request_failed' : 'request_failed',
|
||
})
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
if (success) {
|
||
return (
|
||
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-950/40">
|
||
<p className="text-base font-semibold text-green-800 dark:text-green-300">{t.successTitle}</p>
|
||
<p className="mt-2 text-sm text-green-700 dark:text-green-400">{t.successBody}</p>
|
||
{successBooking?.bookingReference ? (
|
||
<div className="mt-4 rounded-2xl border border-green-200 bg-white/70 px-4 py-3 text-sm text-green-900 dark:border-green-800 dark:bg-green-950/20 dark:text-green-200">
|
||
<p className="font-semibold">{t.bookingReference}: {successBooking.bookingReference}</p>
|
||
<p className="mt-1 text-xs text-green-700 dark:text-green-400">
|
||
{successBooking.status ?? t.pendingStatus}
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
<div className="mt-4 space-y-3">
|
||
{[
|
||
{ title: t.nextStepRequestSent, body: t.nextStepRequestSentBody, active: true },
|
||
{ title: t.nextStepAvailability, body: t.nextStepAvailabilityBody },
|
||
{ title: t.nextStepDocuments, body: t.nextStepDocumentsBody },
|
||
{ title: t.nextStepPayment, body: t.nextStepPaymentBody },
|
||
{ title: t.nextStepConfirmed, body: t.nextStepConfirmedBody },
|
||
].map((step, index) => (
|
||
<div
|
||
key={step.title}
|
||
className={`rounded-2xl border px-4 py-3 ${step.active ? 'border-green-300 bg-white/80 dark:border-green-700 dark:bg-green-950/20' : 'border-green-100 bg-white/50 dark:border-green-900/60 dark:bg-transparent'}`}
|
||
>
|
||
<p className="text-sm font-semibold text-green-900 dark:text-green-200">{index + 1}. {step.title}</p>
|
||
<p className="mt-1 text-xs leading-5 text-green-700 dark:text-green-400">{step.body}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-4">
|
||
<div>
|
||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
|
||
<p className="mt-1 text-xs leading-5 text-stone-500 dark:text-stone-400">{t.subtitle}</p>
|
||
</div>
|
||
|
||
{error ? (
|
||
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
|
||
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.tripDetails}</p>
|
||
|
||
{pickupOptions.length > 0 ? (
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <RequiredMark /></label>
|
||
<select
|
||
value={fields.pickupLocation}
|
||
onChange={(e) => update('pickupLocation', e.target.value)}
|
||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
|
||
>
|
||
{pickupOptions.map((location) => <option key={location} value={location}>{location}</option>)}
|
||
</select>
|
||
</div>
|
||
) : null}
|
||
|
||
{allowDifferentDropoff ? (
|
||
<div className="space-y-3">
|
||
<div className="inline-flex rounded-full border border-stone-200 bg-white p-1 dark:border-blue-800 dark:bg-blue-900/30">
|
||
<button
|
||
type="button"
|
||
onClick={() => update('dropoffMode', 'same')}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'same' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
|
||
>
|
||
{t.sameDropoff}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => update('dropoffMode', 'different')}
|
||
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${fields.dropoffMode === 'different' ? 'bg-blue-900 text-white dark:bg-orange-400' : 'text-stone-500 dark:text-stone-400'}`}
|
||
>
|
||
{t.differentDropoff}
|
||
</button>
|
||
</div>
|
||
|
||
{fields.dropoffMode === 'different' && dropoffOptions.length > 0 ? (
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.returnLocation} <RequiredMark /></label>
|
||
<select
|
||
value={fields.returnLocation}
|
||
onChange={(e) => update('returnLocation', e.target.value)}
|
||
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500"
|
||
>
|
||
{dropoffOptions.map((location) => <option key={location} value={location}>{location}</option>)}
|
||
</select>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-stone-500 dark:text-stone-400">{fields.pickupLocation || t.sameAsPickup}</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label={t.startDate} required type="date" value={fields.startDate} onChange={(value) => update('startDate', value)} />
|
||
<Field label={t.endDate} required type="date" value={fields.endDate} onChange={(value) => update('endDate', value)} />
|
||
</div>
|
||
|
||
{totalDays > 0 ? (
|
||
<p className="text-sm text-stone-600 dark:text-stone-400">
|
||
{t.estimatedTotal}:{' '}
|
||
<strong className="text-stone-900 dark:text-stone-100">{formatCurrency(dailyRate * totalDays, 'MAD', language)}</strong>{' '}
|
||
({totalDays} {t.days})
|
||
</p>
|
||
) : null}
|
||
|
||
{availability.status === 'checking' ? (
|
||
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
|
||
{t.checkingAvailability}
|
||
</p>
|
||
) : null}
|
||
|
||
{availability.status === 'available' ? (
|
||
<p className="rounded-xl border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800 dark:bg-green-950/40 dark:text-green-300">
|
||
{t.likelyAvailable}
|
||
</p>
|
||
) : null}
|
||
|
||
{availability.status === 'unavailable' ? (
|
||
<p className="rounded-xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800 dark:border-orange-800 dark:bg-orange-950/40 dark:text-orange-300">
|
||
{buildUnavailableMessage(availability.nextAvailableAt)}
|
||
</p>
|
||
) : null}
|
||
|
||
{availability.status === 'error' ? (
|
||
<p className="rounded-xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-600 dark:border-blue-800 dark:bg-blue-900/30 dark:text-stone-300">
|
||
{t.availabilityError}
|
||
</p>
|
||
) : null}
|
||
</section>
|
||
|
||
<section className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-blue-950">
|
||
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.contactDetails}</p>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label={t.firstName} required value={fields.firstName} onChange={(value) => update('firstName', value)} />
|
||
<Field label={t.lastName} required value={fields.lastName} onChange={(value) => update('lastName', value)} />
|
||
</div>
|
||
<Field label={t.email} required type="email" value={fields.email} onChange={(value) => update('email', value)} />
|
||
<Field label={t.phone} required type="tel" value={fields.phone} onChange={(value) => update('phone', value)} />
|
||
<Field label={t.notes} textarea value={fields.notes} onChange={(value) => update('notes', value)} />
|
||
</section>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={submitting || availability.status === 'checking'}
|
||
className="w-full rounded-full bg-orange-600 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-orange-700 disabled:opacity-60 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300"
|
||
>
|
||
{submitting ? t.submitting : t.submit}
|
||
</button>
|
||
</form>
|
||
)
|
||
}
|