make the booking in many steps
Build & Deploy / Build & Push Docker Image (push) Successful in 8m27s
Test / Type Check (all packages) (push) Successful in 3m34s
Build & Deploy / Deploy to VPS (push) Successful in 2s
Test / API Unit Tests (push) Failing after 3m16s
Test / Homepage Unit Tests (push) Failing after 2m40s
Test / Storefront Unit Tests (push) Failing after 24s
Test / Admin Unit Tests (push) Failing after 22s
Test / Dashboard Unit Tests (push) Failing after 24s
Test / API Integration Tests (push) Failing after 33s

This commit is contained in:
root
2026-06-29 08:54:41 -04:00
parent ae10f5272f
commit 3d6607e6c8
20 changed files with 3894 additions and 836 deletions
@@ -198,4 +198,74 @@ describe('menu.service', () => {
})
expect(result.items.find((item) => item.label === 'Reports')?.reasons.join(' ')).toContain('STARTER does not include this menu item')
})
it('returns the approved seven-item STARTER owner sidebar in order', async () => {
vi.mocked(prisma.employee.findUniqueOrThrow).mockResolvedValue({
id: 'employee_1',
role: 'OWNER',
isActive: true,
companyId: 'company_1',
} as never)
vi.mocked(prisma.company.findUniqueOrThrow).mockResolvedValue({
id: 'company_1',
name: 'Atlas Cars',
status: 'ACTIVE',
subscription: { plan: 'STARTER', status: 'ACTIVE' },
} as never)
const item = (
systemKey: string,
label: string,
routeOrUrl: string,
icon: string,
displayOrder: number,
plans: string[],
) => ({
id: `item_${systemKey}`,
systemKey,
label,
itemType: 'INTERNAL_PAGE',
routeOrUrl,
icon,
parentId: null,
openInNewTab: false,
isRequired: displayOrder <= 70,
isActive: true,
displayOrder,
roleVisibilities: [{ role: 'OWNER' }],
subscriptionAssignments: plans.map((plan) => ({ plan, displayOrder, isActive: true })),
companyAssignments: [],
})
vi.mocked(prisma.menuItem.findMany).mockResolvedValue([
item('dashboard', 'Dashboard', '/', 'LayoutDashboard', 10, ['STARTER', 'GROWTH', 'PRO']),
item('reservations', 'Reservations', '/reservations', 'Calendar', 20, ['STARTER', 'GROWTH', 'PRO']),
item('fleet', 'Fleet', '/fleet', 'Car', 30, ['STARTER', 'GROWTH', 'PRO']),
item('customers', 'Customers', '/customers', 'Users', 40, ['STARTER', 'GROWTH', 'PRO']),
item('reports', 'Reports', '/reports', 'BarChart2', 50, ['STARTER', 'GROWTH', 'PRO']),
item('billing', 'Billing', '/billing', 'CreditCard', 60, ['STARTER', 'GROWTH', 'PRO']),
item('settings', 'Settings', '/settings', 'Settings', 70, ['STARTER', 'GROWTH', 'PRO']),
item('online-reservations', 'Online Reservations', '/online-reservations', 'Globe', 80, ['GROWTH', 'PRO']),
item('offers', 'Offers', '/offers', 'Tag', 90, ['GROWTH', 'PRO']),
item('team', 'Team', '/team', 'UserPlus', 100, ['GROWTH', 'PRO']),
item('contracts', 'Contracts', '/contracts', 'FileText', 110, ['GROWTH', 'PRO']),
item('notifications', 'Notifications', '/notifications', 'Bell', 120, ['GROWTH', 'PRO']),
item('reviews', 'Reviews', '/reviews', 'Star', 130, ['PRO']),
item('complaints', 'Complaints', '/complaints', 'AlertTriangle', 140, ['PRO']),
{ ...item('subscription', 'Subscription', '/subscription', 'CreditCard', 999, []), isActive: false },
] as never)
const result = await getEmployeeMenu('employee_1')
expect(result.items.map((menuItem) => menuItem.systemKey)).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
])
})
})
@@ -1,808 +1,70 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type Customer = {
id: string
firstName: string
lastName: string
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type AdditionalDriverForm = {
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
type CreatedReservation = { id: string }
import { ReservationWizard } from '@/components/reservations/new/ReservationWizard'
import { getReservationWizardCopy } from '@/components/reservations/new/reservationWizard.copy'
import type { Customer, Vehicle } from '@/components/reservations/new/reservationWizard.types'
export default function NewReservationPage() {
const { language } = useDashboardI18n()
const router = useRouter()
const copy = getReservationWizardCopy(language)
const [customers, setCustomers] = useState<Customer[]>([])
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [customerId, setCustomerId] = useState('')
const [customerSearch, setCustomerSearch] = useState('')
const [vehicleId, setVehicleId] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [pickupLocation, setPickupLocation] = useState('')
const [returnLocation, setReturnLocation] = useState('')
const [depositAmount, setDepositAmount] = useState('0')
const [paymentMode, setPaymentMode] = useState('CASH')
const [notes, setNotes] = useState('')
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [savingCustomer, setSavingCustomer] = useState(false)
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
const [customerNationality, setCustomerNationality] = useState('')
const [customerFullAddress, setCustomerFullAddress] = useState('')
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [licenseImageUrl, setLicenseImageUrl] = useState<string | null>(null)
const [licenseImageFile, setLicenseImageFile] = useState<File | null>(null)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
})
const copy = {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
customer: 'Customer',
searchCustomer: 'Search previous customer',
vehicle: 'Vehicle',
startDate: 'Start date & time',
endDate: 'End date & time',
pickup: 'Pickup location',
return: 'Return location',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
renterIdentity: 'Renter identity',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
notes: 'Notes',
notesPlaceholder: 'Optional notes…',
create: 'Create booking',
creating: 'Creating…',
cancel: 'Cancel',
loadFailed: 'Failed to load customers/vehicles.',
invalidDates: 'End date must be after start date.',
required: 'Please fill all required fields.',
selectCustomer: 'Select customer…',
addCustomer: 'Add customer',
addCustomerTitle: 'Add new customer',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
saveCustomer: 'Save customer',
savingCustomer: 'Saving customer…',
selectVehicle: 'Select vehicle…',
noVehicles: 'No available vehicles.',
paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
fr: {
title: 'Réservation locale',
subtitle: 'Créez une réservation directement depuis lespace, sans réservation en ligne préalable.',
customer: 'Client',
searchCustomer: 'Rechercher un client existant',
vehicle: 'Véhicule',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
renterIdentity: 'Identité du locataire',
driverLicenseInfo: 'Informations du permis',
driverLicense: 'Numéro du permis',
licenseExpiry: 'Expiration du permis',
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles…',
create: 'Créer la réservation',
creating: 'Création…',
cancel: 'Annuler',
loadFailed: 'Échec du chargement des clients/véhicules.',
invalidDates: 'La date de fin doit être après la date de début.',
required: 'Veuillez remplir tous les champs requis.',
selectCustomer: 'Sélectionner un client…',
addCustomer: 'Ajouter un client',
addCustomerTitle: 'Ajouter un nouveau client',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
saveCustomer: 'Enregistrer le client',
savingCustomer: 'Enregistrement du client…',
selectVehicle: 'Sélectionner un véhicule…',
noVehicles: 'Aucun véhicule disponible.',
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
customer: 'العميل',
searchCustomer: 'ابحث عن عميل سابق',
vehicle: 'المركبة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
renterIdentity: 'بيانات المستأجر',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية…',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء…',
cancel: 'إلغاء',
loadFailed: 'فشل تحميل العملاء/المركبات.',
invalidDates: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
selectCustomer: 'اختر عميلًا…',
addCustomer: 'إضافة عميل',
addCustomerTitle: 'إضافة عميل جديد',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
saveCustomer: 'حفظ العميل',
savingCustomer: 'جارٍ حفظ العميل…',
selectVehicle: 'اختر مركبة…',
noVehicles: 'لا توجد مركبات متاحة.',
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
const [customerLoadError, setCustomerLoadError] = useState<string | null>(null)
const [vehicleLoadError, setVehicleLoadError] = useState<string | null>(null)
useEffect(() => {
Promise.all([
apiFetch<Customer[]>('/customers?pageSize=100'),
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
])
.then(([c, v]) => {
setCustomers(c ?? [])
setVehicles(v ?? [])
})
.catch((err) => setError(err.message ?? copy.loadFailed))
.finally(() => setLoading(false))
}, [])
let cancelled = false
const filteredCustomers = (() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((c) =>
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q),
async function loadData() {
setLoading(true)
const [customerResult, vehicleResult] = await Promise.allSettled([
apiFetch<Customer[]>('/customers?pageSize=100'),
apiFetch<Vehicle[]>('/vehicles?pageSize=100'),
])
if (cancelled) return
if (customerResult.status === 'fulfilled') {
setCustomers(customerResult.value ?? [])
setCustomerLoadError(null)
} else {
setCustomerLoadError(customerResult.reason?.message ?? copy.loadFailed)
}
if (vehicleResult.status === 'fulfilled') {
setVehicles(vehicleResult.value ?? [])
setVehicleLoadError(null)
} else {
setVehicleLoadError(vehicleResult.reason?.message ?? copy.loadFailed)
}
setLoading(false)
}
void loadData()
return () => {
cancelled = true
}
}, [copy.loadFailed])
if (loading) {
return (
<div className="card max-w-3xl p-6">
<p className="text-sm text-slate-500">{copy.loading}</p>
</div>
)
})()
const availableVehicles = vehicles.filter((v) => v.status === 'AVAILABLE')
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
setCustomerNationality(selected.nationality ?? '')
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
setLicenseCategory(selected.licenseCategory ?? '')
setLicenseImageUrl(selected.licenseImageUrl ?? null)
setLicenseImageFile(null)
}, [customerId, customers])
useEffect(() => {
if (!licenseImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(licenseImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [licenseImageFile])
async function uploadLicenseImage(customerIdToUpdate: string) {
if (!licenseImageFile) return null
const formData = new FormData()
formData.append('file', licenseImageFile)
const updated = await apiFetch<Customer>(`/customers/${customerIdToUpdate}/license-image`, {
method: 'POST',
body: formData,
})
setCustomers((prev) => prev.map((customer) => customer.id === updated.id ? { ...customer, ...updated } : customer))
setLicenseImageUrl(updated.licenseImageUrl ?? null)
setLicenseImageFile(null)
return updated.licenseImageUrl ?? null
}
async function addCustomer() {
setError(null)
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
setError(copy.required)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: bilingualPrimary(newCustomerFirstName).trim(),
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
lastName: bilingualPrimary(newCustomerLastName).trim(),
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
setShowAddCustomer(false)
setNewCustomerFirstName(emptyBilingual())
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
setLicenseImageUrl(created.licenseImageUrl ?? null)
if (licenseImageFile) {
await uploadLicenseImage(created.id)
}
} catch (err: any) {
setError(err.message)
} finally {
setSavingCustomer(false)
}
}
async function submit() {
setError(null)
if (!canSubmit) {
setError(copy.required)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(copy.invalidDates)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
}
setSaving(true)
try {
await apiFetch(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify({
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
if (licenseImageFile) {
await uploadLicenseImage(customerId)
}
const created = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify({
customerId,
vehicleId,
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: returnLocation || undefined,
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
paymentMode,
additionalDrivers: includeAdditionalDriver ? [{
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
})
router.push(`/reservations/${created.id}`)
} catch (err: any) {
setError(err.message)
} finally {
setSaving(false)
}
}
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="text-sm text-slate-500 mt-1">{copy.subtitle}</p>
</div>
</div>
{error ? <div className="card p-4 text-sm text-red-700">{error}</div> : null}
<div className="card p-6 space-y-4">
{loading ? (
<p className="text-sm text-slate-500">Loading</p>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<input
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
placeholder={copy.searchCustomer}
className="input-field mb-2"
/>
<select value={customerId} onChange={(e) => setCustomerId(e.target.value)} className="input-field">
<option value="">{copy.selectCustomer}</option>
{filteredCustomers.map((c) => (
<option key={c.id} value={c.id}>{c.firstName} {c.lastName} · {c.email}</option>
))}
</select>
<button
type="button"
className="text-xs font-semibold text-blue-700 hover:underline mt-1"
onClick={() => setShowAddCustomer((v) => !v)}
>
{copy.addCustomer}
</button>
{showAddCustomer ? (
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
</div>
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
</div>
<div className="flex justify-end">
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
</button>
</div>
</div>
) : null}
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((v) => (
<option key={v.id} value={v.id}>{v.make} {v.model} · {v.licensePlate}</option>
))}
</select>
{vehicles.length === 0 ? (
<span className="text-xs text-orange-700">{copy.noVehicles}</span>
) : availableVehicles.length === 0 ? (
<span className="text-xs text-orange-700">
{language === 'fr'
? 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.'
: language === 'ar'
? 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.'
: 'Vehicles exist in fleet, but none are marked Available.'}
</span>
) : null}
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
<textarea value={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input
type="file"
accept="image/*"
onChange={(e) => setLicenseImageFile(e.target.files?.[0] ?? null)}
className="input-field"
/>
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{licenseImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {licenseImageFile.name}</p>
) : licenseImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{licenseImagePreviewUrl || licenseImageUrl ? (
<img
src={licenseImagePreviewUrl ?? licenseImageUrl ?? ''}
alt="Driver license preview"
className="h-40 w-full max-w-sm rounded-xl border border-slate-200 object-cover"
/>
) : null}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.pickup}</span>
<input value={pickupLocation} onChange={(e) => setPickupLocation(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.return}</span>
<input value={returnLocation} onChange={(e) => setReturnLocation(e.target.value)} className="input-field" />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.deposit}</span>
<input type="number" min={0} value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} className="input-field" />
</label>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode}</span>
<select value={paymentMode} onChange={(e) => setPaymentMode(e.target.value)} className="input-field">
{(['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const).map((mode) => (
<option key={mode} value={mode}>{copy.paymentModes[mode]}</option>
))}
</select>
</label>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<label className="flex items-center gap-3">
<input
type="checkbox"
checked={includeAdditionalDriver}
onChange={(e) => setIncludeAdditionalDriver(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600"
/>
<span className="text-sm font-medium text-slate-900">{copy.addAdditionalDriver}</span>
</label>
{includeAdditionalDriver ? (
<div className="space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email}</span>
<input
value={additionalDriver.email}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, email: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone}</span>
<input
value={additionalDriver.phone}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, phone: e.target.value }))}
className="input-field"
/>
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<div className="space-y-1">
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth}</span>
<input
type="date"
value={additionalDriver.dateOfBirth}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, dateOfBirth: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
<input
type="date"
value={additionalDriver.licenseIssuedAt}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseIssuedAt: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
<input
type="date"
value={additionalDriver.licenseExpiry}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, licenseExpiry: e.target.value }))}
className="input-field"
/>
</label>
</div>
</div>
) : null}
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={notes} onChange={(e) => setNotes(e.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="pt-2 flex items-center justify-end gap-3">
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
{saving ? copy.creating : copy.create}
</button>
</div>
</>
)}
</div>
<div className="space-y-4">
{customerLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{customerLoadError}</div> : null}
{vehicleLoadError ? <div className="card max-w-3xl p-4 text-sm text-red-700">{vehicleLoadError}</div> : null}
<ReservationWizard customers={customers} vehicles={vehicles} language={language} />
</div>
)
}
+12 -9
View File
@@ -321,14 +321,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
nav: {
dashboard: 'Dashboard',
fleet: 'Fleet',
reservations: 'Booking',
reservations: 'Reservations',
onlineReservations: 'Online Reservations',
'online-reservations': 'Online Reservations',
customers: 'Customers',
offers: 'Offers',
team: 'Team',
reports: 'Reports',
subscription: 'Subscription',
billing: 'Customer Billing',
billing: 'Billing',
contracts: 'Contracts',
notifications: 'Notifications',
settings: 'Settings',
@@ -338,7 +339,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
titles: {
'/': 'Dashboard',
'/fleet': 'Fleet Management',
'/reservations': 'Booking',
'/reservations': 'Reservations',
'/reservations/new': 'Book Car',
'/online-reservations': 'Online Reservations',
'/customers': 'Customers',
@@ -346,7 +347,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'Team',
'/reports': 'Reports',
'/subscription': 'Subscription',
'/billing': 'Customer Billing',
'/billing': 'Billing',
'/contracts': 'Contracts',
'/notifications': 'Notifications',
'/settings': 'Settings',
@@ -679,12 +680,13 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
fleet: 'Flotte',
reservations: 'Réservations',
onlineReservations: 'Réservations en ligne',
'online-reservations': 'Réservations en ligne',
customers: 'Clients',
offers: 'Offres',
team: 'Équipe',
reports: 'Rapports',
subscription: 'Abonnement',
billing: 'Facturation clients',
billing: 'Facturation',
contracts: 'Contrats',
notifications: 'Notifications',
settings: 'Paramètres',
@@ -702,7 +704,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'Équipe',
'/reports': 'Rapports',
'/subscription': 'Abonnement',
'/billing': 'Facturation clients',
'/billing': 'Facturation',
'/contracts': 'Contrats',
'/notifications': 'Notifications',
'/settings': 'Paramètres',
@@ -1034,13 +1036,14 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
dashboard: 'لوحة التحكم',
fleet: 'الأسطول',
reservations: 'الحجوزات',
onlineReservations: 'الحجوزات الإلكترونية',
onlineReservations: 'الحجوزات عبر الإنترنت',
'online-reservations': 'الحجوزات عبر الإنترنت',
customers: 'العملاء',
offers: 'العروض',
team: 'الفريق',
reports: 'التقارير',
subscription: 'الاشتراك',
billing: 'فوترة العملاء',
billing: 'الفوترة',
contracts: 'العقود',
notifications: 'الإشعارات',
settings: 'الإعدادات',
@@ -1058,7 +1061,7 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
'/team': 'الفريق',
'/reports': 'التقارير',
'/subscription': 'الاشتراك',
'/billing': 'فوترة العملاء',
'/billing': 'الفوترة',
'/contracts': 'العقود',
'/notifications': 'الإشعارات',
'/settings': 'الإعدادات',
@@ -1,7 +1,19 @@
import { describe, expect, it } from 'vitest'
import { hasRenderableMenuItems } from './Sidebar'
import { APPROVED_BASELINE_MENU_KEYS, hasRenderableMenuItems } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('keeps the safe fallback menu to the approved seven baseline items in order', () => {
expect(APPROVED_BASELINE_MENU_KEYS).toEqual([
'dashboard',
'reservations',
'fleet',
'customers',
'reports',
'billing',
'settings',
])
})
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false)
@@ -87,22 +87,16 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
const NAV_ITEMS = [
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/reviews', key: 'reviews', icon: Star, minRole: 'AGENT' },
{ href: '/complaints', key: 'complaints', icon: AlertTriangle, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
export const APPROVED_BASELINE_MENU_KEYS = NAV_ITEMS.map((item) => item.key)
const ICON_MAP = {
LayoutDashboard,
Car,
@@ -156,6 +150,7 @@ export default function Sidebar() {
})
const [role, setRole] = useState<string>('AGENT')
const [menuItems, setMenuItems] = useState<GeneratedMenuItem[] | null>(null)
const [menuLoadState, setMenuLoadState] = useState<'loading' | 'loaded' | 'failed'>('loading')
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
@@ -229,7 +224,12 @@ export default function Sidebar() {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (menu?.items) setMenuItems(menu.items)
if (menu) {
setMenuItems(menu.items)
setMenuLoadState('loaded')
} else {
setMenuLoadState('failed')
}
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
// Only apply the server-side preference when the employee has no local preference
// stored yet — avoids overriding a language the user manually switched to.
@@ -243,6 +243,7 @@ export default function Sidebar() {
.catch(() => {
if (cancelled) return
if (!cached) setUser(toSidebarUser({}, dict.workspaceUser, dict.localAuth))
setMenuLoadState('failed')
})
return () => { cancelled = true }
@@ -278,8 +279,8 @@ export default function Sidebar() {
children: [],
}))
const useGeneratedMenu = hasRenderableMenuItems(menuItems)
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
const useGeneratedMenu = menuLoadState === 'loaded'
const resolvedMenuItems = useGeneratedMenu ? (menuItems ?? []) : fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => {
@@ -406,33 +407,7 @@ export default function Sidebar() {
</a>
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = mounted && isActive(item)
return (
<Link
key={item.href}
href={item.href}
className={[
'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')}
>
<Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
{dict.nav[item.key]}
{item.key === 'dashboard' ? (
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
Live
</span>
) : null}
{item.key === 'notifications' && active ? (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
) : null}
</Link>
)
})}
{renderGeneratedMenu(resolvedMenuItems)}
</nav>
<div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
@@ -0,0 +1,126 @@
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { ReservationWizardCopy } from './reservationWizard.copy'
import type { Customer, ReservationDraft, Vehicle, WizardStepId } from './reservationWizard.types'
function display(value: string | null | undefined, fallback: string) {
return value?.trim() ? value : fallback
}
export function ReservationReview({
copy,
draft,
customers,
vehicles,
localeCode,
onEdit,
}: {
copy: ReservationWizardCopy
draft: ReservationDraft
customers: Customer[]
vehicles: Vehicle[]
localeCode: string
onEdit: (step: WizardStepId) => void
}) {
const selectedCustomer = customers.find((customer) => customer.id === draft.customer.selectedCustomerId)
const selectedVehicle = vehicles.find((vehicle) => vehicle.id === draft.rental.vehicleId)
const formatDateTime = (value: string) => value ? new Date(value).toLocaleString(localeCode, { dateStyle: 'medium', timeStyle: 'short' }) : copy.summaryEmpty
const sections: Array<{ step: WizardStepId; title: string; rows: Array<[string, string]> }> = [
{
step: 'customer',
title: copy.customer,
rows: [
[copy.customer, draft.customer.mode === 'existing' ? display(selectedCustomer ? `${selectedCustomer.firstName} ${selectedCustomer.lastName}` : '', copy.summaryEmpty) : `${bilingualPrimary(draft.customer.firstName)} ${bilingualPrimary(draft.customer.lastName)}`],
[copy.email, display(draft.customer.email, copy.summaryEmpty)],
[copy.phone, display(draft.customer.phone, copy.summaryEmpty)],
],
},
{
step: 'identity',
title: copy.identity,
rows: [
[copy.dateOfBirth, display(draft.identity.dateOfBirth, copy.summaryEmpty)],
[copy.nationality, display(draft.identity.nationality, copy.summaryEmpty)],
[copy.identityDocumentNumber, display(draft.identity.identityDocumentNumber, copy.summaryEmpty)],
[copy.fullAddress, display(draft.identity.fullAddress, copy.summaryEmpty)],
[copy.internationalLicenseNumber, display(draft.identity.internationalLicenseNumber, copy.summaryEmpty)],
],
},
{
step: 'license',
title: copy.license,
rows: [
[copy.driverLicenseNumber, display(draft.license.number, copy.summaryEmpty)],
[copy.licenseCountry, display(draft.license.country, copy.summaryEmpty)],
[copy.licenseIssuedAt, display(draft.license.issuedAt, copy.summaryEmpty)],
[copy.licenseExpiry, display(draft.license.expiry, copy.summaryEmpty)],
[copy.licenseCategory, display(draft.license.category, copy.summaryEmpty)],
[copy.licenseImage, draft.license.newImageFile ? draft.license.newImageFile.name : draft.license.existingImageUrl ? copy.licenseImageCurrent : copy.noLicenseImage],
],
},
{
step: 'rental',
title: copy.rental,
rows: [
[copy.vehicle, selectedVehicle ? `${selectedVehicle.make} ${selectedVehicle.model} · ${selectedVehicle.licensePlate}` : copy.summaryEmpty],
[copy.startDate, formatDateTime(draft.rental.startDate)],
[copy.endDate, formatDateTime(draft.rental.endDate)],
[copy.pickup, display(draft.rental.pickupLocation, copy.summaryEmpty)],
[copy.return, display(draft.rental.returnLocation, copy.summaryEmpty)],
],
},
{
step: 'payment',
title: copy.payment,
rows: [
[copy.deposit, display(draft.payment.depositAmount, copy.summaryEmpty)],
[copy.paymentMode, copy.paymentModes[draft.payment.paymentMode] ?? draft.payment.paymentMode],
[copy.additionalDriverInfo, draft.additionalDrivers.length ? String(draft.additionalDrivers.length) : copy.summaryEmpty],
[copy.notes, display(draft.payment.notes, copy.summaryEmpty)],
],
},
]
return (
<div className="space-y-4">
{sections.map((section) => (
<section key={section.step} className="rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900">{section.title}</h3>
<button type="button" className="text-sm font-semibold text-blue-700 hover:underline" onClick={() => onEdit(section.step)}>
{copy.edit}
</button>
</div>
<dl className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
{section.rows.map(([label, value]) => (
<div key={`${section.step}-${label}`}>
<dt className="text-xs font-medium text-slate-500">{label}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{value}</dd>
</div>
))}
</dl>
</section>
))}
{draft.additionalDrivers.map((driver, index) => (
<section key={driver.id} className="rounded-lg border border-slate-200 p-4">
<h3 className="text-sm font-semibold text-slate-900">
{copy.additionalDriverInfo} {index + 1}
</h3>
<dl className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<dt className="text-xs font-medium text-slate-500">{copy.firstName}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(bilingualPrimary(driver.firstName), copy.summaryEmpty)}</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500">{copy.lastName}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(bilingualPrimary(driver.lastName), copy.summaryEmpty)}</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500">{copy.driverLicenseNumber}</dt>
<dd className="mt-0.5 text-sm text-slate-900">{display(driver.driverLicense, copy.summaryEmpty)}</dd>
</div>
</dl>
</section>
))}
</div>
)
}
@@ -0,0 +1,59 @@
import { wizardSteps, type ReservationWizardCopy } from './reservationWizard.copy'
import type { WizardStepId } from './reservationWizard.types'
export function ReservationStepper({
copy,
currentStep,
completedSteps,
highestReachableIndex,
onStepClick,
}: {
copy: ReservationWizardCopy
currentStep: WizardStepId
completedSteps: Set<WizardStepId>
highestReachableIndex: number
onStepClick: (step: WizardStepId) => void
}) {
const currentIndex = wizardSteps.findIndex((step) => step.id === currentStep)
const currentLabel = copy[wizardSteps[currentIndex].labelKey] as string
return (
<div className="space-y-3">
<div className="md:hidden">
<p className="text-sm font-semibold text-slate-900">{copy.stepOf(currentIndex + 1, wizardSteps.length, currentLabel)}</p>
<div className="mt-2 h-2 rounded-full bg-slate-200">
<div
className="h-2 rounded-full bg-blue-700 transition-all"
style={{ width: `${((currentIndex + 1) / wizardSteps.length) * 100}%` }}
/>
</div>
</div>
<ol className="hidden grid-cols-6 gap-2 md:grid">
{wizardSteps.map((step, index) => {
const active = step.id === currentStep
const complete = completedSteps.has(step.id)
const disabled = index > highestReachableIndex
return (
<li key={step.id}>
<button
type="button"
aria-current={active ? 'step' : undefined}
disabled={disabled}
onClick={() => onStepClick(step.id)}
className={[
'h-full w-full rounded-lg border px-3 py-3 text-left text-sm transition',
active ? 'border-blue-700 bg-blue-50 text-blue-900' : complete ? 'border-emerald-200 bg-emerald-50 text-emerald-900' : 'border-slate-200 bg-white text-slate-500',
disabled ? 'cursor-not-allowed opacity-50' : 'hover:border-blue-300',
].join(' ')}
>
<span className="block text-xs font-semibold">{index + 1}</span>
<span className="mt-1 block font-medium">{copy[step.labelKey] as string}</span>
</button>
</li>
)
})}
</ol>
</div>
)
}
@@ -0,0 +1,710 @@
'use client'
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { BilingualInput, bilingualPrimary } from '@/components/ui/BilingualInput'
import { getReservationWizardCopy, wizardSteps } from './reservationWizard.copy'
import { ReservationReview } from './ReservationReview'
import { ReservationStepper } from './ReservationStepper'
import { createInitialDraft, reservationDraftReducer } from './reservationWizard.reducer'
import {
validateCompleteReservation,
validateCustomerStep,
validateIdentityStep,
validateLicenseStep,
validatePaymentExtrasStep,
validateRentalStep,
} from './reservationWizard.schemas'
import { checkVehicleAvailability, submitReservationDraft } from './reservationWizard.submit'
import type { AdditionalDriverDraft, Customer, FieldErrors, ReservationDraft, Vehicle, WizardStepId } from './reservationWizard.types'
import type { BilingualField } from '@/components/ui/BilingualInput'
type Language = 'en' | 'fr' | 'ar'
const paymentModes = ['CASH', 'CARD', 'BANK_TRANSFER', 'AMANPAY', 'PAYPAL'] as const
export function ReservationWizard({
customers,
vehicles,
language,
}: {
customers: Customer[]
vehicles: Vehicle[]
language: Language
}) {
const router = useRouter()
const copy = useMemo(() => getReservationWizardCopy(language), [language])
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const [draft, dispatch] = useReducer(reservationDraftReducer, undefined, createInitialDraft)
const [currentStep, setCurrentStep] = useState<WizardStepId>('customer')
const [completedSteps, setCompletedSteps] = useState<Set<WizardStepId>>(new Set())
const [errors, setErrors] = useState<FieldErrors>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [customerSearch, setCustomerSearch] = useState('')
const [saving, setSaving] = useState(false)
const [licenseImagePreviewUrl, setLicenseImagePreviewUrl] = useState<string | null>(null)
const headingRef = useRef<HTMLHeadingElement | null>(null)
const dirtyRef = useRef(false)
const pendingErrorFocusRef = useRef<string | null>(null)
const stepIndex = wizardSteps.findIndex((step) => step.id === currentStep)
const highestReachableIndex = Math.min(completedSteps.size, wizardSteps.length - 1)
const availableVehicles = vehicles.filter((vehicle) => vehicle.status === 'AVAILABLE')
const filteredCustomers = useMemo(() => {
const q = customerSearch.trim().toLowerCase()
if (!q) return customers
return customers.filter((customer) =>
`${customer.firstName} ${customer.lastName}`.toLowerCase().includes(q) ||
customer.email.toLowerCase().includes(q),
)
}, [customerSearch, customers])
useEffect(() => {
dirtyRef.current = JSON.stringify(draft) !== JSON.stringify(createInitialDraft())
}, [draft])
useEffect(() => {
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (!dirtyRef.current) return
event.preventDefault()
event.returnValue = copy.leaveWarning
}
window.addEventListener('beforeunload', onBeforeUnload)
return () => window.removeEventListener('beforeunload', onBeforeUnload)
}, [copy.leaveWarning])
useEffect(() => {
headingRef.current?.focus()
}, [currentStep])
useEffect(() => {
if (!pendingErrorFocusRef.current) return
const field = pendingErrorFocusRef.current
requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>(`[data-field="${field}"]`)
if (target) {
target.focus()
pendingErrorFocusRef.current = null
}
})
}, [currentStep, errors])
useEffect(() => {
if (!draft.license.newImageFile) {
setLicenseImagePreviewUrl(null)
return
}
const objectUrl = URL.createObjectURL(draft.license.newImageFile)
setLicenseImagePreviewUrl(objectUrl)
return () => URL.revokeObjectURL(objectUrl)
}, [draft.license.newImageFile])
function setFieldErrors(nextErrors: FieldErrors) {
setErrors(nextErrors)
if (Object.keys(nextErrors).length === 0) return
pendingErrorFocusRef.current = Object.keys(nextErrors)[0]
requestAnimationFrame(() => {
if (!pendingErrorFocusRef.current) return
const target = document.querySelector<HTMLElement>(`[data-field="${pendingErrorFocusRef.current}"]`)
if (target) {
target.focus()
pendingErrorFocusRef.current = null
}
})
}
function updateCustomer(field: keyof ReservationDraft['customer'], value: any) {
dispatch({ type: 'updateCustomer', field, value })
clearFieldError(`customer.${field}`)
}
function clearFieldError(field: string) {
setErrors((current) => {
if (!current[field]) return current
const next = { ...current }
delete next[field]
return next
})
}
function updateIdentity(field: keyof ReservationDraft['identity'], value: string) {
dispatch({ type: 'updateIdentity', field, value })
clearFieldError(`identity.${field}`)
}
function updateLicense(field: keyof ReservationDraft['license'], value: any) {
dispatch({ type: 'updateLicense', field, value })
clearFieldError(`license.${field}`)
}
function updateRental(field: keyof ReservationDraft['rental'], value: string) {
dispatch({ type: 'updateRental', field, value })
clearFieldError(`rental.${field}`)
}
function updatePayment(field: keyof ReservationDraft['payment'], value: string) {
dispatch({ type: 'updatePayment', field, value })
clearFieldError(`payment.${field}`)
}
function selectCustomer(customerId: string) {
if (!customerId) return
const customer = customers.find((item) => item.id === customerId)
if (!customer) return
if (draft.customer.hydratedFromCustomerId && draft.customer.hydratedFromCustomerId !== customer.id) {
const confirmed = window.confirm(copy.confirmReplaceCustomer)
if (!confirmed) return
}
dispatch({ type: 'selectExistingCustomer', customer })
setCustomerSearch(`${customer.firstName} ${customer.lastName}`)
setErrors({})
}
function validateStep(step: WizardStepId) {
if (step === 'customer') return validateCustomerStep(draft, copy)
if (step === 'identity') return validateIdentityStep(draft, copy)
if (step === 'license') return validateLicenseStep(draft, copy)
if (step === 'rental') return validateRentalStep(draft, copy)
if (step === 'payment') return validatePaymentExtrasStep(draft, copy)
return validateCompleteReservation(draft, copy)
}
async function handleContinue() {
setSubmitError(null)
const nextErrors = validateStep(currentStep)
if (Object.keys(nextErrors).length > 0) {
setFieldErrors(nextErrors)
return
}
if (currentStep === 'rental') {
try {
const availability = await checkVehicleAvailability(draft)
if (!availability.available) {
setFieldErrors({ 'rental.vehicleId': copy.availabilityConflict })
return
}
} catch (err: any) {
setFieldErrors({ 'rental.vehicleId': err.message ?? copy.availabilityConflict })
return
}
}
const nextCompleted = new Set(completedSteps)
nextCompleted.add(currentStep)
setCompletedSteps(nextCompleted)
const nextStep = wizardSteps[Math.min(stepIndex + 1, wizardSteps.length - 1)]
setErrors({})
setCurrentStep(nextStep.id)
}
function handleBack() {
if (stepIndex === 0) {
handleCancel()
return
}
setErrors({})
setCurrentStep(wizardSteps[stepIndex - 1].id)
}
function handleCancel() {
router.push('/reservations')
}
function handleStepClick(step: WizardStepId) {
const targetIndex = wizardSteps.findIndex((item) => item.id === step)
if (targetIndex > highestReachableIndex) return
setErrors({})
setCurrentStep(step)
}
async function handleSubmit() {
if (saving) {
setSubmitError(copy.duplicateGuard)
return
}
const nextErrors = validateCompleteReservation(draft, copy)
if (Object.keys(nextErrors).length > 0) {
const firstStepWithError = wizardSteps.find((step) =>
Object.keys(nextErrors).some((field) => {
if (step.id === 'customer') return field.startsWith('customer.')
if (step.id === 'identity') return field.startsWith('identity.')
if (step.id === 'license') return field.startsWith('license.')
if (step.id === 'rental') return field.startsWith('rental.')
if (step.id === 'payment') return field.startsWith('payment.') || field.startsWith('additionalDrivers.')
return false
}),
)
if (firstStepWithError) setCurrentStep(firstStepWithError.id)
setFieldErrors(nextErrors)
return
}
setSaving(true)
setSubmitError(null)
try {
const result = await submitReservationDraft(draft, {
onCustomerIdResolved: (customerId) => dispatch({ type: 'setCreatedCustomerId', customerId }),
})
dirtyRef.current = false
dispatch({ type: 'reset' })
router.push(`/reservations/${result.reservation.id}`)
} catch (err: any) {
setSubmitError(err.code === 'vehicle_unavailable' ? copy.availabilityConflict : err.message ?? copy.submitFailed)
} finally {
setSaving(false)
}
}
const currentLabel = copy[wizardSteps[stepIndex].labelKey] as string
return (
<div className="max-w-4xl space-y-6" dir={language === 'ar' ? 'rtl' : 'ltr'}>
<div>
<h2 className="text-xl font-semibold text-slate-900">{copy.title}</h2>
<p className="mt-1 text-sm text-slate-500">{copy.subtitle}</p>
</div>
<ReservationStepper
copy={copy}
currentStep={currentStep}
completedSteps={completedSteps}
highestReachableIndex={highestReachableIndex}
onStepClick={handleStepClick}
/>
{submitError ? <div className="card p-4 text-sm text-red-700">{submitError}</div> : null}
<form
className="card p-6"
onSubmit={(event) => {
event.preventDefault()
if (currentStep === 'review') void handleSubmit()
}}
>
<h3 ref={headingRef} tabIndex={-1} className="mb-5 text-lg font-semibold text-slate-900 outline-none">
{currentLabel}
</h3>
{currentStep === 'customer' ? (
<CustomerStep
copy={copy}
draft={draft}
customers={filteredCustomers}
customerSearch={customerSearch}
errors={errors}
onSearchChange={setCustomerSearch}
onSelectCustomer={selectCustomer}
onModeChange={(mode: 'existing' | 'new') => {
dispatch({ type: 'setCustomerMode', mode })
setErrors({})
}}
onCustomerChange={updateCustomer}
/>
) : null}
{currentStep === 'identity' ? (
<IdentityStep copy={copy} draft={draft} errors={errors} onChange={updateIdentity} />
) : null}
{currentStep === 'license' ? (
<LicenseStep
copy={copy}
draft={draft}
errors={errors}
previewUrl={licenseImagePreviewUrl}
onChange={updateLicense}
/>
) : null}
{currentStep === 'rental' ? (
<RentalStep
copy={copy}
draft={draft}
vehicles={vehicles}
availableVehicles={availableVehicles}
errors={errors}
onChange={updateRental}
/>
) : null}
{currentStep === 'payment' ? (
<PaymentStep
copy={copy}
draft={draft}
errors={errors}
onPaymentChange={updatePayment}
onAddDriver={() => dispatch({ type: 'addAdditionalDriver' })}
onRemoveDriver={(id: string) => dispatch({ type: 'removeAdditionalDriver', id })}
onDriverChange={(id: string, field: keyof AdditionalDriverDraft, value: any) => {
dispatch({ type: 'updateAdditionalDriver', id, field, value })
setErrors((current) => {
const index = draft.additionalDrivers.findIndex((driver) => driver.id === id)
const key = `additionalDrivers.${index}.${field}`
if (!current[key]) return current
const next = { ...current }
delete next[key]
return next
})
}}
/>
) : null}
{currentStep === 'review' ? (
<ReservationReview
copy={copy}
draft={draft}
customers={customers}
vehicles={vehicles}
localeCode={localeCode}
onEdit={setCurrentStep}
/>
) : null}
<div className="sticky bottom-0 mt-6 flex items-center justify-between gap-3 border-t border-slate-200 bg-white pt-4">
<div className="flex items-center gap-3">
<button type="button" className="btn-secondary" onClick={handleBack} disabled={saving}>
{stepIndex === 0 ? copy.cancel : copy.back}
</button>
{currentStep === 'review' ? (
<button type="button" className="btn-secondary" onClick={handleCancel} disabled={saving}>
{copy.cancel}
</button>
) : null}
</div>
{currentStep === 'review' ? (
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? copy.creating : copy.create}
</button>
) : (
<button type="button" className="btn-primary" onClick={handleContinue}>
{copy.continue}
</button>
)}
</div>
</form>
</div>
)
}
function FieldError({ id, errors }: { id: string; errors: FieldErrors }) {
return errors[id] ? <p id={`${id}-error`} className="text-xs text-red-700">{errors[id]}</p> : null
}
function inputProps(field: string, errors: FieldErrors) {
return {
'data-field': field,
'aria-invalid': Boolean(errors[field]),
'aria-describedby': errors[field] ? `${field}-error` : undefined,
}
}
function CustomerStep({
copy,
draft,
customers,
customerSearch,
errors,
onSearchChange,
onSelectCustomer,
onModeChange,
onCustomerChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
customers: Customer[]
customerSearch: string
errors: FieldErrors
onSearchChange: (value: string) => void
onSelectCustomer: (customerId: string) => void
onModeChange: (mode: 'existing' | 'new') => void
onCustomerChange: (field: keyof ReservationDraft['customer'], value: any) => void
}) {
return (
<div className="space-y-5">
<div className="flex flex-wrap gap-2">
{(['existing', 'new'] as const).map((mode) => (
<button
key={mode}
type="button"
className={draft.customer.mode === mode ? 'btn-primary' : 'btn-secondary'}
onClick={() => onModeChange(mode)}
>
{copy.modes[mode]}
</button>
))}
</div>
{draft.customer.mode === 'existing' ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.searchCustomer}</span>
<input value={customerSearch} onChange={(event) => onSearchChange(event.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<select
value={draft.customer.selectedCustomerId ?? ''}
onChange={(event) => onSelectCustomer(event.target.value)}
className="input-field"
{...inputProps('customer.selectedCustomerId', errors)}
>
<option value="">{copy.selectCustomer}</option>
{customers.map((customer: Customer) => (
<option key={customer.id} value={customer.id}>{customer.firstName} {customer.lastName} · {customer.email}</option>
))}
</select>
<FieldError id="customer.selectedCustomerId" errors={errors} />
</label>
</div>
) : (
<div className="space-y-4">
<div data-field="customer.firstName" tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={draft.customer.firstName} onChange={(value: BilingualField) => onCustomerChange('firstName', value)} />
<FieldError id="customer.firstName" errors={errors} />
</div>
<div data-field="customer.lastName" tabIndex={-1}>
<BilingualInput label={copy.lastName} required value={draft.customer.lastName} onChange={(value: BilingualField) => onCustomerChange('lastName', value)} />
<FieldError id="customer.lastName" errors={errors} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={draft.customer.email} onChange={(event) => onCustomerChange('email', event.target.value)} className="input-field" {...inputProps('customer.email', errors)} />
<FieldError id="customer.email" errors={errors} />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input value={draft.customer.phone} onChange={(event) => onCustomerChange('phone', event.target.value)} className="input-field" {...inputProps('customer.phone', errors)} />
<FieldError id="customer.phone" errors={errors} />
</label>
</div>
</div>
)}
</div>
)
}
function IdentityStep({
copy,
draft,
errors,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
onChange: (field: keyof ReservationDraft['identity'], value: string) => void
}) {
return (
<div className="space-y-4">
{draft.customer.hydratedFromCustomerId ? <p className="text-sm text-slate-500">{copy.loadedCustomerData}</p> : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="date" id="identity.dateOfBirth" label={copy.dateOfBirth} required value={draft.identity.dateOfBirth} errors={errors} onChange={(value) => onChange('dateOfBirth', value)} />
<TextField id="identity.nationality" label={copy.nationality} required value={draft.identity.nationality} errors={errors} onChange={(value) => onChange('nationality', value)} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="identity.identityDocumentNumber" label={copy.identityDocumentNumber} required value={draft.identity.identityDocumentNumber} errors={errors} onChange={(value) => onChange('identityDocumentNumber', value)} />
<TextField id="identity.internationalLicenseNumber" label={copy.internationalLicenseNumber} value={draft.identity.internationalLicenseNumber} errors={errors} onChange={(value) => onChange('internationalLicenseNumber', value)} />
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.fullAddress} <span className="text-red-600">*</span></span>
<textarea value={draft.identity.fullAddress} onChange={(event) => onChange('fullAddress', event.target.value)} className="input-field min-h-[96px]" {...inputProps('identity.fullAddress', errors)} />
<FieldError id="identity.fullAddress" errors={errors} />
</label>
</div>
)
}
function LicenseStep({
copy,
draft,
errors,
previewUrl,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
previewUrl: string | null
onChange: (field: keyof ReservationDraft['license'], value: any) => void
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="license.number" label={copy.driverLicenseNumber} required value={draft.license.number} errors={errors} onChange={(value) => onChange('number', value)} />
<TextField id="license.country" label={copy.licenseCountry} required value={draft.license.country} errors={errors} onChange={(value) => onChange('country', value)} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<TextField type="date" id="license.issuedAt" label={copy.licenseIssuedAt} required value={draft.license.issuedAt} errors={errors} onChange={(value) => onChange('issuedAt', value)} />
<TextField type="date" id="license.expiry" label={copy.licenseExpiry} required value={draft.license.expiry} errors={errors} onChange={(value) => onChange('expiry', value)} />
<TextField id="license.category" label={copy.licenseCategory} required value={draft.license.category} errors={errors} onChange={(value) => onChange('category', value)} />
</div>
<div className="space-y-2">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.licenseImage}</span>
<input type="file" accept="image/*" onChange={(event) => onChange('newImageFile', event.target.files?.[0] ?? null)} className="input-field" {...inputProps('license.newImageFile', errors)} />
<FieldError id="license.newImageFile" errors={errors} />
</label>
<p className="text-xs text-slate-500">{copy.licenseImageHint}</p>
{draft.license.newImageFile ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageSelected}: {draft.license.newImageFile.name}</p>
) : draft.license.existingImageUrl ? (
<p className="text-xs font-medium text-slate-700">{copy.licenseImageCurrent}</p>
) : (
<p className="text-xs text-slate-500">{copy.noLicenseImage}</p>
)}
{previewUrl || draft.license.existingImageUrl ? (
<img src={previewUrl ?? draft.license.existingImageUrl ?? ''} alt={copy.licenseImageAlt} className="h-40 w-full max-w-sm rounded-lg border border-slate-200 object-cover" />
) : null}
</div>
</div>
)
}
function RentalStep({
copy,
draft,
vehicles,
availableVehicles,
errors,
onChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
vehicles: Vehicle[]
availableVehicles: Vehicle[]
errors: FieldErrors
onChange: (field: keyof ReservationDraft['rental'], value: string) => void
}) {
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="datetime-local" id="rental.startDate" label={copy.startDate} required value={draft.rental.startDate} errors={errors} onChange={(value) => onChange('startDate', value)} />
<TextField type="datetime-local" id="rental.endDate" label={copy.endDate} required value={draft.rental.endDate} errors={errors} onChange={(value) => onChange('endDate', value)} />
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={draft.rental.vehicleId} onChange={(event) => onChange('vehicleId', event.target.value)} className="input-field" {...inputProps('rental.vehicleId', errors)}>
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((vehicle: Vehicle) => (
<option key={vehicle.id} value={vehicle.id}>{vehicle.make} {vehicle.model} · {vehicle.licensePlate}</option>
))}
</select>
<FieldError id="rental.vehicleId" errors={errors} />
{vehicles.length === 0 ? <span className="text-xs text-orange-700">{copy.noVehicles}</span> : null}
{vehicles.length > 0 && availableVehicles.length === 0 ? <span className="text-xs text-orange-700">{copy.noAvailableStatusVehicles}</span> : null}
</label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id="rental.pickupLocation" label={copy.pickup} value={draft.rental.pickupLocation} errors={errors} onChange={(value) => onChange('pickupLocation', value)} />
<TextField id="rental.returnLocation" label={copy.return} value={draft.rental.returnLocation} errors={errors} onChange={(value) => onChange('returnLocation', value)} />
</div>
</div>
)
}
function PaymentStep({
copy,
draft,
errors,
onPaymentChange,
onAddDriver,
onRemoveDriver,
onDriverChange,
}: {
copy: ReturnType<typeof getReservationWizardCopy>
draft: ReservationDraft
errors: FieldErrors
onPaymentChange: (field: keyof ReservationDraft['payment'], value: string) => void
onAddDriver: () => void
onRemoveDriver: (id: string) => void
onDriverChange: (id: string, field: keyof AdditionalDriverDraft, value: any) => void
}) {
return (
<div className="space-y-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField type="number" id="payment.depositAmount" label={copy.deposit} value={draft.payment.depositAmount} errors={errors} min={0} onChange={(value) => onPaymentChange('depositAmount', value)} />
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.paymentMode} <span className="text-red-600">*</span></span>
<select value={draft.payment.paymentMode} onChange={(event) => onPaymentChange('paymentMode', event.target.value)} className="input-field" {...inputProps('payment.paymentMode', errors)}>
{paymentModes.map((mode) => <option key={mode} value={mode}>{copy.paymentModes[mode]}</option>)}
</select>
<FieldError id="payment.paymentMode" errors={errors} />
</label>
</div>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.notes}</span>
<textarea value={draft.payment.notes} onChange={(event) => onPaymentChange('notes', event.target.value)} className="input-field min-h-[96px]" placeholder={copy.notesPlaceholder} />
</label>
<div className="rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<button type="button" className="btn-secondary" onClick={onAddDriver}>{copy.addAnotherDriver}</button>
</div>
<div className="mt-4 space-y-5">
{draft.additionalDrivers.map((driver, index) => (
<div key={driver.id} className="space-y-4 rounded-lg border border-slate-200 p-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo} {index + 1}</p>
<button type="button" className="text-sm font-semibold text-red-700 hover:underline" onClick={() => onRemoveDriver(driver.id)}>{copy.remove}</button>
</div>
<div data-field={`additionalDrivers.${index}.firstName`} tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={driver.firstName} onChange={(value: BilingualField) => onDriverChange(driver.id, 'firstName', value)} />
<FieldError id={`additionalDrivers.${index}.firstName`} errors={errors} />
</div>
<div data-field={`additionalDrivers.${index}.lastName`} tabIndex={-1}>
<BilingualInput label={copy.lastName} required value={driver.lastName} onChange={(value: BilingualField) => onDriverChange(driver.id, 'lastName', value)} />
<FieldError id={`additionalDrivers.${index}.lastName`} errors={errors} />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<TextField id={`additionalDrivers.${index}.email`} label={copy.email} value={driver.email} errors={errors} onChange={(value) => onDriverChange(driver.id, 'email', value)} />
<TextField id={`additionalDrivers.${index}.phone`} label={copy.phone} value={driver.phone} errors={errors} onChange={(value) => onDriverChange(driver.id, 'phone', value)} />
<TextField id={`additionalDrivers.${index}.driverLicense`} label={copy.driverLicenseNumber} required value={driver.driverLicense} errors={errors} onChange={(value) => onDriverChange(driver.id, 'driverLicense', value)} />
<div data-field={`additionalDrivers.${index}.nationality`} tabIndex={-1}>
<BilingualInput label={copy.nationality} value={driver.nationality} onChange={(value: BilingualField) => onDriverChange(driver.id, 'nationality', value)} />
</div>
<TextField type="date" id={`additionalDrivers.${index}.dateOfBirth`} label={copy.dateOfBirth} value={driver.dateOfBirth} errors={errors} onChange={(value) => onDriverChange(driver.id, 'dateOfBirth', value)} />
<TextField type="date" id={`additionalDrivers.${index}.licenseIssuedAt`} label={copy.licenseIssuedAt} value={driver.licenseIssuedAt} errors={errors} onChange={(value) => onDriverChange(driver.id, 'licenseIssuedAt', value)} />
<TextField type="date" id={`additionalDrivers.${index}.licenseExpiry`} label={copy.licenseExpiry} value={driver.licenseExpiry} errors={errors} onChange={(value) => onDriverChange(driver.id, 'licenseExpiry', value)} />
</div>
</div>
))}
</div>
</div>
</div>
)
}
function TextField({
id,
label,
value,
errors,
onChange,
required = false,
type = 'text',
min,
}: {
id: string
label: string
value: string
errors: FieldErrors
onChange: (value: string) => void
required?: boolean
type?: string
min?: number
}) {
return (
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">
{label}
{required ? <span className="text-red-600"> *</span> : null}
</span>
<input type={type} min={min} value={value} onChange={(event) => onChange(event.target.value)} className="input-field" {...inputProps(id, errors)} />
<FieldError id={id} errors={errors} />
</label>
)
}
@@ -0,0 +1,269 @@
import type { WizardStepId } from './reservationWizard.types'
type Language = 'en' | 'fr' | 'ar'
export function getReservationWizardCopy(language: Language) {
return {
en: {
title: 'Local Booking',
subtitle: 'Create a reservation directly from workspace without a previous online reservation.',
loading: 'Loading reservation data...',
cancel: 'Cancel',
back: 'Back',
continue: 'Continue',
create: 'Create booking',
creating: 'Creating...',
edit: 'Edit',
remove: 'Remove',
addAnotherDriver: 'Add another driver',
customer: 'Customer',
identity: 'Identity',
license: 'Driver license',
rental: 'Rental details',
payment: 'Payment and extras',
review: 'Review',
stepOf: (current: number, total: number, label: string) => `Step ${current} of ${total} · ${label}`,
modes: { existing: 'Existing customer', new: 'New customer' },
searchCustomer: 'Search previous customer',
selectCustomer: 'Select customer...',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
loadedCustomerData: 'Loaded from selected customer. You can correct these values before creating the reservation.',
driverLicenseNumber: 'Driver license number',
licenseCountry: 'License country',
licenseIssuedAt: 'License issued at',
licenseExpiry: 'License expiry',
licenseCategory: 'License category',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
licenseImageCurrent: 'Current image',
noLicenseImage: 'No license image uploaded yet.',
licenseImageAlt: 'Driver license preview',
startDate: 'Start date & time',
endDate: 'End date & time',
vehicle: 'Vehicle',
selectVehicle: 'Select vehicle...',
pickup: 'Pickup location',
return: 'Return location',
noVehicles: 'No available vehicles.',
noAvailableStatusVehicles: 'Vehicles exist in fleet, but none are marked Available.',
availabilityConflict: 'The selected vehicle is not available for those dates.',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
notes: 'Notes',
notesPlaceholder: 'Optional notes...',
additionalDriverInfo: 'Additional driver',
summaryEmpty: 'Not provided',
loadFailed: 'Failed to load customers/vehicles.',
submitFailed: 'Reservation creation failed. Your draft is still available for correction or retry.',
duplicateGuard: 'Reservation creation is already in progress.',
confirmReplaceCustomer: 'Replace the loaded customer details with the newly selected customer?',
leaveWarning: 'You have an unfinished reservation draft.',
validation: {
required: 'This field is required.',
email: 'Enter a valid email address.',
pastDate: 'Date must be in the past.',
futureIssue: 'Issue date cannot be in the future.',
expiryAfterIssue: 'Expiry date must be after the issue date.',
expiryFuture: 'Expiry date must be in the future.',
endAfterStart: 'End date must be after start date.',
deposit: 'Deposit cannot be negative.',
imageType: 'Upload a supported image file.',
imageSize: 'Image must be 5 MB or smaller.',
},
paymentModes: {
CASH: 'Cash',
CARD: 'Card',
BANK_TRANSFER: 'Bank transfer',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
fr: {
title: 'Réservation locale',
subtitle: 'Créez une réservation directement depuis lespace, sans réservation en ligne préalable.',
loading: 'Chargement des données de réservation...',
cancel: 'Annuler',
back: 'Retour',
continue: 'Continuer',
create: 'Créer la réservation',
creating: 'Création...',
edit: 'Modifier',
remove: 'Supprimer',
addAnotherDriver: 'Ajouter un conducteur',
customer: 'Client',
identity: 'Identité',
license: 'Permis',
rental: 'Location',
payment: 'Paiement et options',
review: 'Vérification',
stepOf: (current: number, total: number, label: string) => `Étape ${current} sur ${total} · ${label}`,
modes: { existing: 'Client existant', new: 'Nouveau client' },
searchCustomer: 'Rechercher un client existant',
selectCustomer: 'Sélectionner un client...',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
loadedCustomerData: 'Données chargées depuis le client sélectionné. Vous pouvez les corriger avant de créer la réservation.',
driverLicenseNumber: 'Numéro du permis',
licenseCountry: 'Pays du permis',
licenseIssuedAt: 'Permis délivré le',
licenseExpiry: 'Expiration du permis',
licenseCategory: 'Catégorie du permis',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
licenseImageCurrent: 'Image actuelle',
noLicenseImage: 'Aucune image de permis téléversée.',
licenseImageAlt: 'Aperçu du permis de conduire',
startDate: 'Date et heure de départ',
endDate: 'Date et heure de retour',
vehicle: 'Véhicule',
selectVehicle: 'Sélectionner un véhicule...',
pickup: 'Lieu de départ',
return: 'Lieu de retour',
noVehicles: 'Aucun véhicule disponible.',
noAvailableStatusVehicles: 'Des véhicules existent dans la flotte, mais aucun nest marqué Disponible.',
availabilityConflict: 'Le véhicule sélectionné nest pas disponible pour ces dates.',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles...',
additionalDriverInfo: 'Conducteur supplémentaire',
summaryEmpty: 'Non renseigné',
loadFailed: 'Échec du chargement des clients/véhicules.',
submitFailed: 'La création de la réservation a échoué. Le brouillon reste disponible pour correction ou nouvel essai.',
duplicateGuard: 'La création de réservation est déjà en cours.',
confirmReplaceCustomer: 'Remplacer les données chargées par celles du nouveau client sélectionné ?',
leaveWarning: 'Vous avez un brouillon de réservation non terminé.',
validation: {
required: 'Ce champ est obligatoire.',
email: 'Saisissez une adresse email valide.',
pastDate: 'La date doit être passée.',
futureIssue: 'La date de délivrance ne peut pas être future.',
expiryAfterIssue: 'La date dexpiration doit être après la date de délivrance.',
expiryFuture: 'La date dexpiration doit être future.',
endAfterStart: 'La date de fin doit être après la date de début.',
deposit: 'Le dépôt ne peut pas être négatif.',
imageType: 'Téléversez un fichier image pris en charge.',
imageSize: 'Limage doit faire 5 Mo ou moins.',
},
paymentModes: {
CASH: 'Espèces',
CARD: 'Carte',
BANK_TRANSFER: 'Virement',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
ar: {
title: 'حجز محلي',
subtitle: 'أنشئ حجزًا مباشرة من مساحة العمل بدون حجز إلكتروني مسبق.',
loading: 'جارٍ تحميل بيانات الحجز...',
cancel: 'إلغاء',
back: 'رجوع',
continue: 'متابعة',
create: 'إنشاء الحجز',
creating: 'جارٍ الإنشاء...',
edit: 'تعديل',
remove: 'إزالة',
addAnotherDriver: 'إضافة سائق آخر',
customer: 'العميل',
identity: 'الهوية',
license: 'رخصة القيادة',
rental: 'تفاصيل الكراء',
payment: 'الدفع والإضافات',
review: 'المراجعة',
stepOf: (current: number, total: number, label: string) => `الخطوة ${current} من ${total} · ${label}`,
modes: { existing: 'عميل موجود', new: 'عميل جديد' },
searchCustomer: 'ابحث عن عميل سابق',
selectCustomer: 'اختر عميلًا...',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
loadedCustomerData: 'تم تحميل البيانات من العميل المحدد. يمكنك تصحيحها قبل إنشاء الحجز.',
driverLicenseNumber: 'رقم رخصة القيادة',
licenseCountry: 'بلد الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseCategory: 'فئة الرخصة',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
licenseImageCurrent: 'الصورة الحالية',
noLicenseImage: 'لم يتم رفع صورة الرخصة بعد.',
licenseImageAlt: 'معاينة رخصة القيادة',
startDate: 'تاريخ ووقت البداية',
endDate: 'تاريخ ووقت النهاية',
vehicle: 'المركبة',
selectVehicle: 'اختر مركبة...',
pickup: 'موقع الاستلام',
return: 'موقع التسليم',
noVehicles: 'لا توجد مركبات متاحة.',
noAvailableStatusVehicles: 'توجد مركبات في الأسطول، لكن لا توجد مركبة بحالة متاحة.',
availabilityConflict: 'المركبة المحددة غير متاحة في هذه التواريخ.',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية...',
additionalDriverInfo: 'السائق الإضافي',
summaryEmpty: 'غير متوفر',
loadFailed: 'فشل تحميل العملاء/المركبات.',
submitFailed: 'فشل إنشاء الحجز. لا يزال المسود متاحًا للتصحيح أو إعادة المحاولة.',
duplicateGuard: 'إنشاء الحجز قيد التنفيذ بالفعل.',
confirmReplaceCustomer: 'هل تريد استبدال بيانات العميل المحملة ببيانات العميل المحدد الجديد؟',
leaveWarning: 'لديك مسودة حجز غير مكتملة.',
validation: {
required: 'هذا الحقل مطلوب.',
email: 'أدخل بريدًا إلكترونيًا صالحًا.',
pastDate: 'يجب أن يكون التاريخ في الماضي.',
futureIssue: 'لا يمكن أن يكون تاريخ الإصدار في المستقبل.',
expiryAfterIssue: 'يجب أن يكون تاريخ الانتهاء بعد تاريخ الإصدار.',
expiryFuture: 'يجب أن يكون تاريخ الانتهاء في المستقبل.',
endAfterStart: 'يجب أن يكون تاريخ النهاية بعد تاريخ البداية.',
deposit: 'لا يمكن أن يكون العربون سالبًا.',
imageType: 'ارفع ملف صورة مدعومًا.',
imageSize: 'يجب ألا تتجاوز الصورة 5 ميغابايت.',
},
paymentModes: {
CASH: 'نقدا',
CARD: 'بطاقة',
BANK_TRANSFER: 'تحويل بنكي',
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<string, string>,
},
}[language]
}
export type ReservationWizardCopy = ReturnType<typeof getReservationWizardCopy>
export const wizardSteps: Array<{ id: WizardStepId; labelKey: keyof ReservationWizardCopy }> = [
{ id: 'customer', labelKey: 'customer' },
{ id: 'identity', labelKey: 'identity' },
{ id: 'license', labelKey: 'license' },
{ id: 'rental', labelKey: 'rental' },
{ id: 'payment', labelKey: 'payment' },
{ id: 'review', labelKey: 'review' },
]
@@ -0,0 +1,163 @@
import { emptyBilingual } from '@/components/ui/BilingualInput'
import type { AdditionalDriverDraft, Customer, ReservationDraft } from './reservationWizard.types'
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
export function createAdditionalDriver(): AdditionalDriverDraft {
return {
id: crypto.randomUUID(),
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
}
}
export function createInitialDraft(): ReservationDraft {
return {
customer: {
mode: 'existing',
selectedCustomerId: null,
createdCustomerId: null,
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
hydratedFromCustomerId: null,
},
identity: {
dateOfBirth: '',
nationality: '',
fullAddress: '',
identityDocumentNumber: '',
internationalLicenseNumber: '',
},
license: {
number: '',
country: '',
issuedAt: '',
expiry: '',
category: '',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: '',
startDate: '',
endDate: '',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
export type ReservationDraftAction =
| { type: 'setCustomerMode'; mode: 'existing' | 'new' }
| { type: 'selectExistingCustomer'; customer: Customer }
| { type: 'setCreatedCustomerId'; customerId: string }
| { type: 'updateCustomer'; field: keyof ReservationDraft['customer']; value: any }
| { type: 'updateIdentity'; field: keyof ReservationDraft['identity']; value: string }
| { type: 'updateLicense'; field: keyof ReservationDraft['license']; value: any }
| { type: 'updateRental'; field: keyof ReservationDraft['rental']; value: string }
| { type: 'updatePayment'; field: keyof ReservationDraft['payment']; value: string }
| { type: 'addAdditionalDriver' }
| { type: 'removeAdditionalDriver'; id: string }
| { type: 'updateAdditionalDriver'; id: string; field: keyof AdditionalDriverDraft; value: any }
| { type: 'reset' }
export function reservationDraftReducer(draft: ReservationDraft, action: ReservationDraftAction): ReservationDraft {
switch (action.type) {
case 'setCustomerMode':
if (action.mode === draft.customer.mode) return draft
if (action.mode === 'new') {
return {
...draft,
customer: {
...createInitialDraft().customer,
mode: 'new',
createdCustomerId: draft.customer.createdCustomerId,
},
identity: createInitialDraft().identity,
license: createInitialDraft().license,
}
}
return {
...draft,
customer: createInitialDraft().customer,
identity: createInitialDraft().identity,
license: createInitialDraft().license,
}
case 'selectExistingCustomer':
return {
...draft,
customer: {
mode: 'existing',
selectedCustomerId: action.customer.id,
createdCustomerId: null,
firstName: { fr: action.customer.firstName ?? '', ar: action.customer.firstNameAr ?? '' },
lastName: { fr: action.customer.lastName ?? '', ar: action.customer.lastNameAr ?? '' },
email: action.customer.email ?? '',
phone: action.customer.phone ?? '',
hydratedFromCustomerId: action.customer.id,
},
identity: {
dateOfBirth: action.customer.dateOfBirth ? action.customer.dateOfBirth.slice(0, 10) : '',
nationality: action.customer.nationality ?? '',
fullAddress: readCustomerAddressValue(action.customer, 'fullAddress'),
identityDocumentNumber: readCustomerAddressValue(action.customer, 'identityDocumentNumber'),
internationalLicenseNumber: readCustomerAddressValue(action.customer, 'internationalLicenseNumber'),
},
license: {
number: action.customer.driverLicense ?? action.customer.licenseNumber ?? '',
country: action.customer.licenseCountry ?? '',
issuedAt: action.customer.licenseIssuedAt ? action.customer.licenseIssuedAt.slice(0, 10) : '',
expiry: action.customer.licenseExpiry ? action.customer.licenseExpiry.slice(0, 10) : '',
category: action.customer.licenseCategory ?? '',
existingImageUrl: action.customer.licenseImageUrl ?? null,
newImageFile: null,
},
}
case 'setCreatedCustomerId':
return { ...draft, customer: { ...draft.customer, createdCustomerId: action.customerId } }
case 'updateCustomer':
return { ...draft, customer: { ...draft.customer, [action.field]: action.value } }
case 'updateIdentity':
return { ...draft, identity: { ...draft.identity, [action.field]: action.value } }
case 'updateLicense':
return { ...draft, license: { ...draft.license, [action.field]: action.value } }
case 'updateRental':
return { ...draft, rental: { ...draft.rental, [action.field]: action.value } }
case 'updatePayment':
return { ...draft, payment: { ...draft.payment, [action.field]: action.value } }
case 'addAdditionalDriver':
return { ...draft, additionalDrivers: [...draft.additionalDrivers, createAdditionalDriver()] }
case 'removeAdditionalDriver':
return { ...draft, additionalDrivers: draft.additionalDrivers.filter((driver) => driver.id !== action.id) }
case 'updateAdditionalDriver':
return {
...draft,
additionalDrivers: draft.additionalDrivers.map((driver) =>
driver.id === action.id ? { ...driver, [action.field]: action.value } : driver,
),
}
case 'reset':
return createInitialDraft()
}
}
@@ -0,0 +1,138 @@
import { z } from 'zod'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { FieldErrors, ReservationDraft } from './reservationWizard.types'
import type { ReservationWizardCopy } from './reservationWizard.copy'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/heic', 'image/heif'])
function nonEmpty(message: string) {
return z.string().trim().min(1, message)
}
function emailSchema(copy: ReservationWizardCopy) {
return nonEmpty(copy.validation.required).email(copy.validation.email)
}
function dateInPast(value: string) {
if (!value) return false
const date = new Date(value)
return Number.isFinite(date.getTime()) && date < new Date()
}
function addIssue(ctx: z.RefinementCtx, path: string[], message: string) {
ctx.addIssue({ code: z.ZodIssueCode.custom, path, message })
}
export function validateCustomerStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
mode: z.enum(['existing', 'new']),
selectedCustomerId: z.string().nullable(),
firstName: z.any(),
lastName: z.any(),
email: z.string(),
phone: z.string(),
}).superRefine((customer, ctx) => {
if (customer.mode === 'existing') {
if (!customer.selectedCustomerId) addIssue(ctx, ['selectedCustomerId'], copy.validation.required)
return
}
if (!bilingualPrimary(customer.firstName).trim()) addIssue(ctx, ['firstName'], copy.validation.required)
if (!bilingualPrimary(customer.lastName).trim()) addIssue(ctx, ['lastName'], copy.validation.required)
if (!emailSchema(copy).safeParse(customer.email).success) addIssue(ctx, ['email'], customer.email.trim() ? copy.validation.email : copy.validation.required)
if (!customer.phone.trim()) addIssue(ctx, ['phone'], copy.validation.required)
})
return zodErrors(schema.safeParse(draft.customer), 'customer')
}
export function validateIdentityStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
dateOfBirth: nonEmpty(copy.validation.required),
nationality: nonEmpty(copy.validation.required),
fullAddress: nonEmpty(copy.validation.required),
identityDocumentNumber: nonEmpty(copy.validation.required),
internationalLicenseNumber: z.string(),
}).superRefine((identity, ctx) => {
if (identity.dateOfBirth && !dateInPast(identity.dateOfBirth)) addIssue(ctx, ['dateOfBirth'], copy.validation.pastDate)
})
return zodErrors(schema.safeParse(draft.identity), 'identity')
}
export function validateLicenseStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
number: nonEmpty(copy.validation.required),
country: nonEmpty(copy.validation.required),
issuedAt: nonEmpty(copy.validation.required),
expiry: nonEmpty(copy.validation.required),
category: nonEmpty(copy.validation.required),
newImageFile: z.any().nullable(),
}).superRefine((license, ctx) => {
const issuedAt = new Date(license.issuedAt)
const expiry = new Date(license.expiry)
const now = new Date()
if (license.issuedAt && issuedAt > now) addIssue(ctx, ['issuedAt'], copy.validation.futureIssue)
if (license.issuedAt && license.expiry && expiry <= issuedAt) addIssue(ctx, ['expiry'], copy.validation.expiryAfterIssue)
if (license.expiry && expiry <= now) addIssue(ctx, ['expiry'], copy.validation.expiryFuture)
if (typeof File !== 'undefined' && license.newImageFile instanceof File) {
if (!IMAGE_TYPES.has(license.newImageFile.type)) addIssue(ctx, ['newImageFile'], copy.validation.imageType)
if (license.newImageFile.size > MAX_IMAGE_BYTES) addIssue(ctx, ['newImageFile'], copy.validation.imageSize)
}
})
return zodErrors(schema.safeParse(draft.license), 'license')
}
export function validateRentalStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
vehicleId: nonEmpty(copy.validation.required),
startDate: nonEmpty(copy.validation.required),
endDate: nonEmpty(copy.validation.required),
pickupLocation: z.string(),
returnLocation: z.string(),
}).superRefine((rental, ctx) => {
if (rental.startDate && rental.endDate && new Date(rental.endDate) <= new Date(rental.startDate)) {
addIssue(ctx, ['endDate'], copy.validation.endAfterStart)
}
})
return zodErrors(schema.safeParse(draft.rental), 'rental')
}
export function validatePaymentExtrasStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const errors: FieldErrors = {}
const deposit = Number(draft.payment.depositAmount)
if (!Number.isFinite(deposit) || deposit < 0) errors['payment.depositAmount'] = copy.validation.deposit
if (!draft.payment.paymentMode.trim()) errors['payment.paymentMode'] = copy.validation.required
draft.additionalDrivers.forEach((driver, index) => {
if (!bilingualPrimary(driver.firstName).trim()) errors[`additionalDrivers.${index}.firstName`] = copy.validation.required
if (!bilingualPrimary(driver.lastName).trim()) errors[`additionalDrivers.${index}.lastName`] = copy.validation.required
if (!driver.driverLicense.trim()) errors[`additionalDrivers.${index}.driverLicense`] = copy.validation.required
if (driver.licenseIssuedAt && new Date(driver.licenseIssuedAt) > new Date()) {
errors[`additionalDrivers.${index}.licenseIssuedAt`] = copy.validation.futureIssue
}
if (driver.licenseIssuedAt && driver.licenseExpiry && new Date(driver.licenseExpiry) <= new Date(driver.licenseIssuedAt)) {
errors[`additionalDrivers.${index}.licenseExpiry`] = copy.validation.expiryAfterIssue
}
if (driver.dateOfBirth && !dateInPast(driver.dateOfBirth)) {
errors[`additionalDrivers.${index}.dateOfBirth`] = copy.validation.pastDate
}
})
return errors
}
export function validateCompleteReservation(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
return {
...validateCustomerStep(draft, copy),
...validateIdentityStep(draft, copy),
...validateLicenseStep(draft, copy),
...validateRentalStep(draft, copy),
...validatePaymentExtrasStep(draft, copy),
}
}
function zodErrors(result: z.SafeParseReturnType<unknown, unknown>, prefix: string): FieldErrors {
if (result.success) return {}
return result.error.issues.reduce<FieldErrors>((errors, issue) => {
const path = issue.path.length > 0 ? `${prefix}.${issue.path.join('.')}` : prefix
errors[path] = issue.message
return errors
}, {})
}
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from 'vitest'
import { apiFetch } from '@/lib/api'
import { submitReservationDraft } from './reservationWizard.submit'
import type { ReservationDraft } from './reservationWizard.types'
vi.mock('@/lib/api', () => ({
apiFetch: vi.fn(),
}))
function draft(): ReservationDraft {
return {
customer: {
mode: 'new',
selectedCustomerId: null,
createdCustomerId: null,
firstName: { fr: 'Sara', ar: '' },
lastName: { fr: 'Alaoui', ar: '' },
email: 'sara@example.com',
phone: '+212600000000',
hydratedFromCustomerId: null,
},
identity: {
dateOfBirth: '1990-01-01',
nationality: 'Moroccan',
fullAddress: '12 Main Street',
identityDocumentNumber: 'AB123',
internationalLicenseNumber: '',
},
license: {
number: 'DL-123',
country: 'MA',
issuedAt: '2020-01-01',
expiry: '2099-01-01',
category: 'B',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T10:00',
endDate: '2026-07-03T10:00',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
describe('submitReservationDraft', () => {
it('reports the created customer id before a later reservation failure', async () => {
const api = vi.mocked(apiFetch)
api
.mockResolvedValueOnce({ available: true })
.mockResolvedValueOnce({ id: 'customer_1' })
.mockRejectedValueOnce(new Error('reservation failed'))
const resolvedCustomerIds: string[] = []
await expect(submitReservationDraft(draft(), {
onCustomerIdResolved: (customerId) => resolvedCustomerIds.push(customerId),
})).rejects.toThrow('reservation failed')
expect(resolvedCustomerIds).toEqual(['customer_1'])
expect(api).toHaveBeenCalledTimes(3)
})
it('uses a previously created customer on retry', async () => {
const api = vi.mocked(apiFetch)
api
.mockResolvedValueOnce({ available: true })
.mockResolvedValueOnce({ id: 'reservation_1' })
const retryDraft = draft()
retryDraft.customer.createdCustomerId = 'customer_1'
await submitReservationDraft(retryDraft)
expect(api).toHaveBeenNthCalledWith(2, '/customers/customer_1', expect.objectContaining({ method: 'PATCH' }))
})
})
@@ -0,0 +1,139 @@
import { apiFetch } from '@/lib/api'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { CreatedReservation, Customer, ReservationDraft } from './reservationWizard.types'
type AvailabilityResult = { available: boolean }
function isoFromLocal(value: string) {
return new Date(value).toISOString()
}
export async function checkVehicleAvailability(draft: ReservationDraft) {
const { vehicleId, startDate, endDate } = draft.rental
const params = new URLSearchParams({
startDate: isoFromLocal(startDate),
endDate: isoFromLocal(endDate),
})
return apiFetch<AvailabilityResult>(`/vehicles/${vehicleId}/availability?${params.toString()}`)
}
export async function uploadLicenseImage(customerId: string, file: File) {
const formData = new FormData()
formData.append('file', file)
return apiFetch<Customer>(`/customers/${customerId}/license-image`, {
method: 'POST',
body: formData,
})
}
function customerPayload(draft: ReservationDraft) {
return {
firstName: bilingualPrimary(draft.customer.firstName).trim(),
firstNameAr: draft.customer.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(draft.customer.lastName).trim(),
lastNameAr: draft.customer.lastName.ar.trim() || undefined,
email: draft.customer.email.trim(),
phone: draft.customer.phone.trim(),
driverLicense: draft.license.number.trim(),
dateOfBirth: isoFromLocal(draft.identity.dateOfBirth),
nationality: draft.identity.nationality.trim(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCategory: draft.license.category.trim(),
}
}
function customerPatchPayload(draft: ReservationDraft) {
return {
driverLicense: draft.license.number.trim(),
dateOfBirth: isoFromLocal(draft.identity.dateOfBirth),
nationality: draft.identity.nationality.trim(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCategory: draft.license.category.trim(),
}
}
function reservationPayload(draft: ReservationDraft, customerId: string) {
return {
customerId,
vehicleId: draft.rental.vehicleId,
startDate: isoFromLocal(draft.rental.startDate),
endDate: isoFromLocal(draft.rental.endDate),
pickupLocation: draft.rental.pickupLocation.trim() || undefined,
returnLocation: draft.rental.returnLocation.trim() || undefined,
depositAmount: Number.isFinite(Number(draft.payment.depositAmount)) ? Math.max(0, Math.round(Number(draft.payment.depositAmount))) : 0,
paymentMode: draft.payment.paymentMode,
additionalDrivers: draft.additionalDrivers.map((driver) => ({
firstName: bilingualPrimary(driver.firstName).trim(),
firstNameAr: driver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(driver.lastName).trim(),
lastNameAr: driver.lastName.ar.trim() || undefined,
email: driver.email.trim() || undefined,
phone: driver.phone.trim() || undefined,
driverLicense: driver.driverLicense.trim(),
licenseExpiry: driver.licenseExpiry ? isoFromLocal(driver.licenseExpiry) : undefined,
licenseIssuedAt: driver.licenseIssuedAt ? isoFromLocal(driver.licenseIssuedAt) : undefined,
dateOfBirth: driver.dateOfBirth ? isoFromLocal(driver.dateOfBirth) : undefined,
nationality: bilingualPrimary(driver.nationality).trim() || undefined,
nationalityAr: driver.nationality.ar.trim() || undefined,
})),
notes: draft.payment.notes.trim() || undefined,
}
}
export async function submitReservationDraft(
draft: ReservationDraft,
options: { onCustomerIdResolved?: (customerId: string) => void } = {},
) {
const availability = await checkVehicleAvailability(draft)
if (!availability.available) {
const error = new Error('vehicle_unavailable') as Error & { code?: string }
error.code = 'vehicle_unavailable'
throw error
}
let customerId = draft.customer.mode === 'new'
? draft.customer.createdCustomerId
: draft.customer.selectedCustomerId
if (!customerId) {
const createdCustomer = await apiFetch<Customer>('/customers', {
method: 'POST',
body: JSON.stringify(customerPayload(draft)),
})
customerId = createdCustomer.id
options.onCustomerIdResolved?.(customerId)
} else {
await apiFetch<Customer>(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify(customerPatchPayload(draft)),
})
options.onCustomerIdResolved?.(customerId)
}
if (draft.license.newImageFile) {
await uploadLicenseImage(customerId, draft.license.newImageFile)
}
const reservation = await apiFetch<CreatedReservation>('/reservations', {
method: 'POST',
body: JSON.stringify(reservationPayload(draft, customerId)),
})
return { reservation, customerId }
}
@@ -0,0 +1,167 @@
import { describe, expect, it } from 'vitest'
import { emptyBilingual } from '@/components/ui/BilingualInput'
import { getReservationWizardCopy } from './reservationWizard.copy'
import { createInitialDraft, reservationDraftReducer } from './reservationWizard.reducer'
import {
validateCustomerStep,
validateIdentityStep,
validateLicenseStep,
validatePaymentExtrasStep,
validateRentalStep,
} from './reservationWizard.schemas'
import type { Customer } from './reservationWizard.types'
const copy = getReservationWizardCopy('en')
function validDraft() {
return {
...createInitialDraft(),
customer: {
...createInitialDraft().customer,
mode: 'new' as const,
firstName: { fr: 'Sara', ar: '' },
lastName: { fr: 'Alaoui', ar: '' },
email: 'sara@example.com',
phone: '+212600000000',
},
identity: {
dateOfBirth: '1990-01-01',
nationality: 'Moroccan',
fullAddress: '12 Main Street',
identityDocumentNumber: 'AB123',
internationalLicenseNumber: '',
},
license: {
number: 'DL-123',
country: 'MA',
issuedAt: '2020-01-01',
expiry: '2099-01-01',
category: 'B',
existingImageUrl: null,
newImageFile: null,
},
rental: {
vehicleId: 'vehicle_1',
startDate: '2026-07-01T10:00',
endDate: '2026-07-03T10:00',
pickupLocation: '',
returnLocation: '',
},
payment: {
depositAmount: '0',
paymentMode: 'CASH',
notes: '',
},
additionalDrivers: [],
}
}
describe('reservation wizard reducer', () => {
it('hydrates existing customer identity and license data', () => {
const customer: Customer = {
id: 'customer_1',
firstName: 'Sara',
firstNameAr: 'سارة',
lastName: 'Alaoui',
lastNameAr: 'العلوي',
email: 'sara@example.com',
phone: '+212600000000',
driverLicense: 'DL-1',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Moroccan',
address: { fullAddress: 'Rabat', identityDocumentNumber: 'AB123', internationalLicenseNumber: 'INT-1' },
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
licenseExpiry: '2099-01-01T00:00:00.000Z',
licenseCountry: 'MA',
licenseCategory: 'B',
licenseImageUrl: '/license.jpg',
}
const draft = reservationDraftReducer(createInitialDraft(), { type: 'selectExistingCustomer', customer })
expect(draft.customer.selectedCustomerId).toBe('customer_1')
expect(draft.customer.firstName).toEqual({ fr: 'Sara', ar: 'سارة' })
expect(draft.identity.fullAddress).toBe('Rabat')
expect(draft.license.number).toBe('DL-1')
expect(draft.license.existingImageUrl).toBe('/license.jpg')
})
it('adds and removes additional drivers as an array', () => {
const added = reservationDraftReducer(createInitialDraft(), { type: 'addAdditionalDriver' })
expect(added.additionalDrivers).toHaveLength(1)
const removed = reservationDraftReducer(added, { type: 'removeAdditionalDriver', id: added.additionalDrivers[0].id })
expect(removed.additionalDrivers).toEqual([])
})
it('clears hydrated customer identity and license data when switching modes', () => {
const customer: Customer = {
id: 'customer_1',
firstName: 'Sara',
lastName: 'Alaoui',
email: 'sara@example.com',
driverLicense: 'DL-1',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Moroccan',
address: { fullAddress: 'Rabat', identityDocumentNumber: 'AB123' },
licenseIssuedAt: '2020-01-01T00:00:00.000Z',
licenseExpiry: '2099-01-01T00:00:00.000Z',
licenseCountry: 'MA',
licenseCategory: 'B',
}
const hydrated = reservationDraftReducer(createInitialDraft(), { type: 'selectExistingCustomer', customer })
const switched = reservationDraftReducer(hydrated, { type: 'setCustomerMode', mode: 'new' })
expect(switched.customer.mode).toBe('new')
expect(switched.customer.selectedCustomerId).toBeNull()
expect(switched.customer.email).toBe('')
expect(switched.identity.fullAddress).toBe('')
expect(switched.license.number).toBe('')
})
})
describe('reservation wizard validation', () => {
it('validates new customer required fields', () => {
const draft = createInitialDraft()
const errors = validateCustomerStep({ ...draft, customer: { ...draft.customer, mode: 'new', firstName: emptyBilingual() } }, copy)
expect(errors['customer.firstName']).toBe(copy.validation.required)
expect(errors['customer.email']).toBe(copy.validation.required)
})
it('validates date relationships in rental and license steps', () => {
const draft = validDraft()
expect(validateRentalStep({ ...draft, rental: { ...draft.rental, endDate: '2026-06-30T10:00' } }, copy)['rental.endDate']).toBe(copy.validation.endAfterStart)
expect(validateLicenseStep({ ...draft, license: { ...draft.license, expiry: '2019-01-01' } }, copy)['license.expiry']).toBe(copy.validation.expiryFuture)
})
it('validates optional additional driver only when present', () => {
const draft = {
...validDraft(),
additionalDrivers: [{
id: 'driver_1',
firstName: emptyBilingual(),
lastName: { fr: 'Driver', ar: '' },
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: emptyBilingual(),
}],
}
const errors = validatePaymentExtrasStep(draft, copy)
expect(errors['additionalDrivers.0.firstName']).toBe(copy.validation.required)
expect(errors['additionalDrivers.0.driverLicense']).toBe(copy.validation.required)
})
it('accepts a complete valid draft', () => {
expect(validateCustomerStep(validDraft(), copy)).toEqual({})
expect(validateIdentityStep(validDraft(), copy)).toEqual({})
expect(validateLicenseStep(validDraft(), copy)).toEqual({})
expect(validateRentalStep(validDraft(), copy)).toEqual({})
expect(validatePaymentExtrasStep(validDraft(), copy)).toEqual({})
})
})
@@ -0,0 +1,91 @@
import type { BilingualField } from '@/components/ui/BilingualInput'
export type Customer = {
id: string
firstName: string
firstNameAr?: string | null
lastName: string
lastNameAr?: string | null
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
export type Vehicle = {
id: string
make: string
model: string
licensePlate: string
status: string
}
export type AdditionalDriverDraft = {
id: string
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: BilingualField
}
export type ReservationDraft = {
customer: {
mode: 'existing' | 'new'
selectedCustomerId: string | null
createdCustomerId: string | null
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
hydratedFromCustomerId: string | null
}
identity: {
dateOfBirth: string
nationality: string
fullAddress: string
identityDocumentNumber: string
internationalLicenseNumber: string
}
license: {
number: string
country: string
issuedAt: string
expiry: string
category: string
existingImageUrl: string | null
newImageFile: File | null
}
rental: {
vehicleId: string
startDate: string
endDate: string
pickupLocation: string
returnLocation: string
}
payment: {
depositAmount: string
paymentMode: string
notes: string
}
additionalDrivers: AdditionalDriverDraft[]
}
export type WizardStepId = 'customer' | 'identity' | 'license' | 'rental' | 'payment' | 'review'
export type FieldErrors = Record<string, string>
export type CreatedReservation = { id: string }