fe8ffbeb9f
Build & Push / Pipeline Tests (push) Failing after 59s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 48s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
731 lines
31 KiB
TypeScript
731 lines
31 KiB
TypeScript
'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 = ['BANK_TRANSFER', 'CHECK'] 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 | boolean) {
|
|
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 | boolean) => 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="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
checked={draft.payment.spareWheel}
|
|
onChange={(event) => onPaymentChange('spareWheel', event.target.checked)}
|
|
/>
|
|
{copy.spareWheel}
|
|
</label>
|
|
<label className="flex items-center gap-3 rounded-lg border border-slate-200 px-4 py-3 text-sm font-medium text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
checked={draft.payment.radioCd}
|
|
onChange={(event) => onPaymentChange('radioCd', event.target.checked)}
|
|
/>
|
|
{copy.radioCd}
|
|
</label>
|
|
</div>
|
|
|
|
<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>
|
|
)
|
|
}
|