add text input validation
Build & Push / Pipeline Tests (push) Failing after 1m5s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 52s
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

This commit is contained in:
root
2026-08-31 22:51:44 -04:00
parent b99e8d0be4
commit 057d41e1c2
61 changed files with 968 additions and 302 deletions
@@ -559,7 +559,7 @@ export default function BillingPage() {
</label>
<label className="space-y-1 text-sm font-medium text-slate-700">
<span>{copy.amount}</span>
<input type="number" min="0" step="0.01" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} className="input-field" />
<input type="number" min="0.01" max={(selectedRemaining / 100).toFixed(2)} step="0.01" value={paymentAmount} onChange={(event) => setPaymentAmount(event.target.value)} disabled={submittingPayment} className="input-field" />
</label>
<label className="space-y-1 text-sm font-medium text-slate-700">
<span>{copy.currency}</span>
@@ -567,7 +567,7 @@ export default function BillingPage() {
</label>
<label className="space-y-1 text-sm font-medium text-slate-700">
<span>{copy.receivedAt}</span>
<input type="datetime-local" value={receivedAt} onChange={(event) => setReceivedAt(event.target.value)} disabled={submittingPayment} className="input-field" />
<input type="datetime-local" required value={receivedAt} onChange={(event) => setReceivedAt(event.target.value)} disabled={submittingPayment} className="input-field" />
</label>
<label className="space-y-1 text-sm font-medium text-slate-700">
<span>{copy.reference}</span>
@@ -338,6 +338,7 @@ export default function ComplaintsPage() {
<input
type="text"
value={formReservationId}
maxLength={120}
onChange={(e) => setFormReservationId(e.target.value)}
className="input-field"
/>
@@ -368,6 +369,7 @@ export default function ComplaintsPage() {
<input
type="text"
value={formSubject}
maxLength={120}
onChange={(e) => setFormSubject(e.target.value)}
className="input-field"
/>
@@ -376,6 +378,7 @@ export default function ComplaintsPage() {
<label className="mb-1 block text-sm font-medium text-stone-700 dark:text-slate-300">{t.fieldDescription}</label>
<textarea
value={formDescription}
maxLength={2000}
onChange={(e) => setFormDescription(e.target.value)}
rows={3}
className="input-field resize-none"
@@ -505,6 +508,7 @@ export default function ComplaintsPage() {
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.notes}</label>
<textarea
value={editNotes[complaint.id] ?? ''}
maxLength={2000}
onChange={(e) => setEditNotes((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
@@ -516,6 +520,7 @@ export default function ComplaintsPage() {
<label className="mb-1 block text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-slate-400">{t.resolution}</label>
<textarea
value={editResolution[complaint.id] ?? ''}
maxLength={2000}
onChange={(e) => setEditResolution((prev) => ({ ...prev, [complaint.id]: e.target.value }))}
rows={2}
className="input-field resize-none"
@@ -277,6 +277,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!form.make) { setError(f.makeMissing); return }
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (!/^[A-HJ-NPR-Z0-9]{17}$/.test(form.vin.trim().toUpperCase())) { setError('VIN must be 17 characters and cannot contain I, O, or Q'); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setError(f.dropoffLocationsRequired)
return
@@ -290,7 +291,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
...form,
year: Number(form.year),
seats: Number(form.seats),
vin: form.vin.trim() || undefined,
vin: form.vin.trim().toUpperCase(),
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
@@ -396,7 +397,7 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.vin}</label>
<input className="input-field" placeholder="1HGCM82633A123456" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
<input className="input-field" placeholder="1HGCM82633A123456" required maxLength={17} pattern="[A-HJ-NPR-Za-hj-npr-z0-9]{17}" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value.toUpperCase() })} />
</div>
</div>
@@ -553,6 +553,7 @@ export default function OffersPage() {
<input
className="input-field"
value={form.title}
maxLength={120}
onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
placeholder={t.placeholderTitle}
/>
@@ -565,6 +566,7 @@ export default function OffersPage() {
className="input-field resize-none"
rows={2}
value={form.description}
maxLength={1000}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t.placeholderDescription}
/>
@@ -616,7 +618,7 @@ export default function OffersPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelPromoCode}</label>
<input className="input-field" value={form.promoCode} onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value }))} placeholder={t.placeholderPromoCode} />
<input className="input-field" value={form.promoCode} maxLength={30} pattern="^[A-Z0-9_-]+$" onChange={(e) => setForm((f) => ({ ...f, promoCode: e.target.value.toUpperCase() }))} placeholder={t.placeholderPromoCode} />
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">{t.labelMaxRedemptions}</label>
@@ -690,6 +692,7 @@ export default function OffersPage() {
<input
className="input-field pl-8 text-xs py-1.5"
value={vehicleSearch}
maxLength={120}
onChange={(e) => setVehicleSearch(e.target.value)}
placeholder={t.labelVehicleSearch}
/>
@@ -739,6 +742,7 @@ export default function OffersPage() {
className="input-field resize-none"
rows={2}
value={form.termsAndConds}
maxLength={2000}
onChange={(e) => setForm((f) => ({ ...f, termsAndConds: e.target.value }))}
placeholder={t.placeholderTerms}
/>
@@ -367,6 +367,7 @@ export default function ReviewsPage() {
<div className="mt-4 space-y-3">
<textarea
value={replyText}
maxLength={2000}
onChange={(e) => setReplyText(e.target.value)}
placeholder={t.replyPlaceholder}
rows={3}
@@ -553,14 +553,14 @@ function SettingsPageContent() {
<Input label={copy.labels.displayName} value={brand.displayName} disabled={!canEdit('settings.company_profile')} onChange={(v) => setBrand({ ...brand, displayName: v })} />
<Input label={copy.labels.tagline} value={brand.tagline ?? ''} disabled={!canEdit('settings.company_profile')} onChange={(v) => setBrand({ ...brand, tagline: v })} />
<Input label={copy.labels.publicEmail} type="email" value={brand.publicEmail ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicEmail: v })} />
<Input label={copy.labels.publicPhone} value={brand.publicPhone ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicPhone: v })} />
<Input label={copy.labels.whatsapp} value={brand.whatsappNumber ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, whatsappNumber: v })} />
<Input label={copy.labels.publicPhone} type="tel" value={brand.publicPhone ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicPhone: v })} />
<Input label={copy.labels.whatsapp} type="tel" value={brand.whatsappNumber ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, whatsappNumber: v })} />
<div>
<Label text={copy.labels.city} />
<input list="morocco-cities" className="input-field" value={brand.publicCity ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })} />
<input list="morocco-cities" className="input-field" value={brand.publicCity ?? ''} maxLength={85} disabled={!canEdit('settings.public_contact')} onChange={(e) => setBrand({ ...brand, publicCity: e.target.value })} />
<datalist id="morocco-cities">{cityOptions.map((city) => <option key={city} value={city} />)}</datalist>
</div>
<Input label={copy.labels.country} value={brand.publicCountry ?? 'Morocco'} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicCountry: v })} />
<Input label={copy.labels.country} value={brand.publicCountry ?? 'MA'} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, publicCountry: v.toUpperCase() })} />
<Input label={copy.labels.websiteUrl} value={brand.websiteUrl ?? ''} disabled={!canEdit('settings.public_contact')} onChange={(v) => setBrand({ ...brand, websiteUrl: v })} />
<Select label={copy.labels.defaultLocale} value={brand.defaultLocale ?? language} disabled={!canEdit('settings.locale_currency')} options={['en', 'fr', 'ar']} onChange={(v) => {
const officialLanguage = v as CommunicationLocale
@@ -680,12 +680,12 @@ function SettingsPageContent() {
)}
{activeSection === 'insurance' && <ListSection items={insurancePolicies} empty={copy.noItems} copy={copy} render={(policy) => `${policy.type} / ${policy.chargeType} / ${policy.chargeValue}`} formTitle={copy.insuranceNew} disabled={!canEdit('settings.insurance_policies')} fields={<>
<input className="input-field" placeholder="Name" value={newInsurance.name} onChange={(e) => setNewInsurance({ ...newInsurance, name: e.target.value })} />
<input className="input-field" placeholder="Name" value={newInsurance.name} maxLength={120} onChange={(e) => setNewInsurance({ ...newInsurance, name: e.target.value })} />
<input className="input-field" placeholder="Charge value" type="number" value={newInsurance.chargeValue} onChange={(e) => setNewInsurance({ ...newInsurance, chargeValue: Math.max(0, Number(e.target.value)) })} />
</>} onCreate={createInsurance} />}
{activeSection === 'pricing' && <ListSection items={pricingRules} empty={copy.noItems} copy={copy} render={(rule) => `${rule.type} when ${rule.condition} ${rule.conditionValue}: ${rule.adjustmentType} ${rule.adjustmentValue}`} formTitle={copy.pricingNew} disabled={!canEdit('settings.pricing_rules')} fields={<>
<input className="input-field" placeholder="Name" value={newRule.name} onChange={(e) => setNewRule({ ...newRule, name: e.target.value })} />
<input className="input-field" placeholder="Name" value={newRule.name} maxLength={120} onChange={(e) => setNewRule({ ...newRule, name: e.target.value })} />
<input className="input-field" placeholder="Adjustment" type="number" value={newRule.adjustmentValue} onChange={(e) => setNewRule({ ...newRule, adjustmentValue: Math.max(0, Number(e.target.value)) })} />
</>} onCreate={createRule} />}
@@ -717,11 +717,13 @@ function Label({ text }: { text: string }) {
}
function Input({ label, value, onChange, disabled, type = 'text' }: { label: string; value: string | number; onChange: (value: string) => void; disabled?: boolean; type?: string }) {
return <div><Label text={label} /><input className="input-field" type={type} value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} /></div>
const maxLength = type === 'email' ? 254 : type === 'tel' ? 20 : typeof value === 'string' ? 255 : undefined
const pattern = type === 'tel' ? '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$' : undefined
return <div><Label text={label} /><input className="input-field" type={type} value={value} disabled={disabled} maxLength={maxLength} pattern={pattern} onChange={(event) => onChange(event.target.value)} /></div>
}
function Textarea({ label, value, onChange, disabled }: { label: string; value: string; onChange: (value: string) => void; disabled?: boolean }) {
return <div className="lg:col-span-2"><Label text={label} /><textarea className="input-field min-h-24" value={value} disabled={disabled} onChange={(event) => onChange(event.target.value)} /></div>
return <div className="lg:col-span-2"><Label text={label} /><textarea className="input-field min-h-24" value={value} disabled={disabled} maxLength={4000} onChange={(event) => onChange(event.target.value)} /></div>
}
function Select({ label, value, onChange, disabled, options }: { label: string; value: string; onChange: (value: string) => void; disabled?: boolean; options: string[] }) {
@@ -157,6 +157,7 @@ export default function ForgotPasswordPageClient({
<input
type="email"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
@@ -153,6 +153,7 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
type={showPassword ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
@@ -184,6 +185,7 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
type={showConfirm ? 'text' : 'password'}
required
minLength={8}
maxLength={128}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••"
@@ -262,6 +262,11 @@ export default function SignUpForm({
const submitLabel = selectedPlan
? dict.subscribeToPlan.replace("{plan}", PLAN_LABELS[selectedPlan])
: dict.create;
const textPattern = {
en: "[A-Za-z\\s'-]+",
fr: "[A-Za-zÀ-ÖØ-öø-ÿŒœ\\s'-]+",
ar: "[\\u0600-\\u06FF\\u0750-\\u077F\\s،؛؟ـ'-]+",
}[preferredLanguage];
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
@@ -277,10 +282,10 @@ export default function SignUpForm({
await apiFetch("/auth/account/start", {
method: "POST",
body: JSON.stringify({
firstName,
lastName,
companyName,
email,
firstName: firstName.trim(),
lastName: lastName.trim(),
companyName: companyName.trim(),
email: email.trim(),
password,
preferredLanguage,
...(selectedPlan ? { subscriptionPlan: selectedPlan } : {}),
@@ -381,11 +386,13 @@ export default function SignUpForm({
<input
type="text"
required
maxLength={80}
maxLength={50}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={dict.firstNamePlaceholder}
autoFocus
dir={preferredLanguage === "ar" ? "rtl" : "ltr"}
pattern={textPattern}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
@@ -397,10 +404,12 @@ export default function SignUpForm({
<input
type="text"
required
maxLength={80}
maxLength={50}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={dict.lastNamePlaceholder}
dir={preferredLanguage === "ar" ? "rtl" : "ltr"}
pattern={textPattern}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
@@ -429,6 +438,7 @@ export default function SignUpForm({
<input
type="email"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
@@ -436,6 +446,22 @@ export default function SignUpForm({
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.languageLabel} <span className="text-red-500">*</span>
</label>
<select
required
value={preferredLanguage}
onChange={(e) => setPreferredLanguage(e.target.value as "en" | "fr" | "ar")}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100"
>
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="ar">عربي</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.password} <span className="text-red-500">*</span>
@@ -14,11 +14,12 @@ interface LocationDropdownFieldProps {
}
const MAX_SUGGESTIONS = 8
const MAX_LOCATION_LENGTH = 255
const CUSTOM_LOCATION_OPTIONS_KEY = 'dashboard-custom-vehicle-location-options'
const CUSTOM_LOCATION_OPTIONS_CHANGED = 'dashboard-custom-vehicle-location-options-changed'
function normalizeLocationLabel(value: string) {
return value.trim().replace(/\s+/g, ' ')
return value.trim().replace(/\s+/g, ' ').slice(0, MAX_LOCATION_LENGTH)
}
function normalizeSearchValue(value: string) {
@@ -144,6 +145,7 @@ export default function LocationDropdownField({
className="input-field"
placeholder={placeholder}
value={query}
maxLength={MAX_LOCATION_LENGTH}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
@@ -1,4 +1,3 @@
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { ReservationWizardCopy } from './reservationWizard.copy'
import type { Customer, ReservationDraft, Vehicle, WizardStepId } from './reservationWizard.types'
@@ -6,6 +5,10 @@ function display(value: string | null | undefined, fallback: string) {
return value?.trim() ? value : fallback
}
function localizedText(value: { fr: string; ar: string }, language: 'en' | 'fr' | 'ar') {
return language === 'ar' ? value.ar : value.fr
}
export function ReservationReview({
copy,
draft,
@@ -29,7 +32,7 @@ export function ReservationReview({
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.customer, draft.customer.mode === 'existing' ? display(selectedCustomer ? `${selectedCustomer.firstName} ${selectedCustomer.lastName}` : '', copy.summaryEmpty) : `${localizedText(draft.customer.firstName, draft.customer.language)} ${localizedText(draft.customer.lastName, draft.customer.language)}`],
[copy.email, display(draft.customer.email, copy.summaryEmpty)],
[copy.phone, display(draft.customer.phone, copy.summaryEmpty)],
],
@@ -110,11 +113,11 @@ export function ReservationReview({
<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>
<dd className="mt-0.5 text-sm text-slate-900">{display(localizedText(driver.firstName, driver.language), 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>
<dd className="mt-0.5 text-sm text-slate-900">{display(localizedText(driver.lastName, driver.language), copy.summaryEmpty)}</dd>
</div>
<div>
<dt className="text-xs font-medium text-slate-500">{copy.driverLicenseNumber}</dt>
@@ -2,7 +2,6 @@
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'
@@ -22,6 +21,24 @@ import type { BilingualField } from '@/components/ui/BilingualInput'
type Language = 'en' | 'fr' | 'ar'
const paymentModes = ['BANK_TRANSFER', 'CHECK'] as const
const inputLanguages = ['en', 'fr', 'ar'] as const
const moroccanPhonePattern = "(\\+212|00212|0)\\s?[5-7](\\s?\\d){8}"
function localizedTextValue(value: BilingualField, language: Language) {
return language === 'ar' ? value.ar : value.fr
}
function withLocalizedText(value: BilingualField, language: Language, next: string): BilingualField {
return language === 'ar' ? { ...value, ar: next } : { ...value, fr: next }
}
function localizedPattern(language: Language) {
return {
en: "[A-Za-z\\s'-]+",
fr: "[A-Za-zÀ-ÖØ-öø-ÿŒœ\\s'-]+",
ar: "[\\u0600-\\u06FF\\u0750-\\u077F\\s،؛؟ـ'-]+",
}[language]
}
export function ReservationWizard({
customers,
@@ -461,23 +478,37 @@ function CustomerStep({
</div>
) : (
<div className="space-y-4">
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.recordLanguage} <span className="text-red-600">*</span></span>
<select
value={draft.customer.language}
onChange={(event) => onCustomerChange('language', event.target.value)}
className="input-field"
{...inputProps('customer.language', errors)}
>
{inputLanguages.map((value) => (
<option key={value} value={value}>{value === 'ar' ? 'عربي' : value.toUpperCase()}</option>
))}
</select>
<FieldError id="customer.language" errors={errors} />
</label>
<div data-field="customer.firstName" tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={draft.customer.firstName} onChange={(value: BilingualField) => onCustomerChange('firstName', value)} />
<LocalizedInput label={copy.firstName} required language={draft.customer.language} value={draft.customer.firstName} onChange={(value) => onCustomerChange('firstName', value)} maxLength={50} />
<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)} />
<LocalizedInput label={copy.lastName} required language={draft.customer.language} value={draft.customer.lastName} onChange={(value) => onCustomerChange('lastName', value)} maxLength={50} />
<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)} />
<input type="email" maxLength={254} 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)} />
<input type="tel" maxLength={20} pattern={moroccanPhonePattern} 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>
@@ -503,15 +534,15 @@ function IdentityStep({
{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)} />
<TextField id="identity.nationality" label={copy.nationality} required value={draft.identity.nationality} errors={errors} maxLength={2} onChange={(value) => onChange('nationality', value.toUpperCase())} />
</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)} />
<TextField id="identity.identityDocumentNumber" label={copy.identityDocumentNumber} required value={draft.identity.identityDocumentNumber} errors={errors} maxLength={30} onChange={(value) => onChange('identityDocumentNumber', value.toUpperCase())} />
<TextField id="identity.internationalLicenseNumber" label={copy.internationalLicenseNumber} value={draft.identity.internationalLicenseNumber} errors={errors} maxLength={30} onChange={(value) => onChange('internationalLicenseNumber', value.toUpperCase())} />
</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)} />
<textarea value={draft.identity.fullAddress} maxLength={255} dir={draft.customer.language === 'ar' ? 'rtl' : 'ltr'} 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>
@@ -534,13 +565,13 @@ function LicenseStep({
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)} />
<TextField id="license.number" label={copy.driverLicenseNumber} required value={draft.license.number} errors={errors} maxLength={30} onChange={(value) => onChange('number', value.toUpperCase())} />
<TextField id="license.country" label={copy.licenseCountry} required value={draft.license.country} errors={errors} maxLength={2} onChange={(value) => onChange('country', value.toUpperCase())} />
</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)} />
<TextField id="license.category" label={copy.licenseCategory} required value={draft.license.category} errors={errors} maxLength={30} onChange={(value) => onChange('category', value)} />
</div>
<div className="space-y-2">
<label className="space-y-1 block">
@@ -671,20 +702,32 @@ function PaymentStep({
<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>
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">{copy.recordLanguage} <span className="text-red-600">*</span></span>
<select
value={driver.language}
onChange={(event) => onDriverChange(driver.id, 'language', event.target.value)}
className="input-field"
>
{inputLanguages.map((value) => (
<option key={value} value={value}>{value === 'ar' ? 'عربي' : value.toUpperCase()}</option>
))}
</select>
</label>
<div data-field={`additionalDrivers.${index}.firstName`} tabIndex={-1}>
<BilingualInput label={copy.firstName} required value={driver.firstName} onChange={(value: BilingualField) => onDriverChange(driver.id, 'firstName', value)} />
<LocalizedInput label={copy.firstName} required language={driver.language} value={driver.firstName} onChange={(value) => onDriverChange(driver.id, 'firstName', value)} maxLength={50} />
<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)} />
<LocalizedInput label={copy.lastName} required language={driver.language} value={driver.lastName} onChange={(value) => onDriverChange(driver.id, 'lastName', value)} maxLength={50} />
<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)} />
<TextField type="email" id={`additionalDrivers.${index}.email`} label={copy.email} value={driver.email} errors={errors} maxLength={254} onChange={(value) => onDriverChange(driver.id, 'email', value)} />
<TextField type="tel" id={`additionalDrivers.${index}.phone`} label={copy.phone} value={driver.phone} errors={errors} maxLength={20} pattern={moroccanPhonePattern} onChange={(value) => onDriverChange(driver.id, 'phone', value)} />
<TextField id={`additionalDrivers.${index}.driverLicense`} label={copy.driverLicenseNumber} required value={driver.driverLicense} errors={errors} maxLength={30} onChange={(value) => onDriverChange(driver.id, 'driverLicense', value.toUpperCase())} />
<div data-field={`additionalDrivers.${index}.nationality`} tabIndex={-1}>
<BilingualInput label={copy.nationality} value={driver.nationality} onChange={(value: BilingualField) => onDriverChange(driver.id, 'nationality', value)} />
<TextField id={`additionalDrivers.${index}.nationality`} label={copy.nationality} value={localizedTextValue(driver.nationality, driver.language)} errors={errors} maxLength={2} onChange={(value) => onDriverChange(driver.id, 'nationality', withLocalizedText(driver.nationality, driver.language, value.toUpperCase()))} />
</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)} />
@@ -707,6 +750,8 @@ function TextField({
required = false,
type = 'text',
min,
maxLength,
pattern,
}: {
id: string
label: string
@@ -716,6 +761,8 @@ function TextField({
required?: boolean
type?: string
min?: number
maxLength?: number
pattern?: string
}) {
return (
<label className="space-y-1">
@@ -723,8 +770,43 @@ function TextField({
{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)} />
<input type={type} min={min} maxLength={maxLength} pattern={pattern} value={value} onChange={(event) => onChange(event.target.value)} className="input-field" {...inputProps(id, errors)} />
<FieldError id={id} errors={errors} />
</label>
)
}
function LocalizedInput({
label,
language,
value,
onChange,
required = false,
maxLength,
}: {
label: string
language: Language
value: BilingualField
onChange: (value: BilingualField) => void
required?: boolean
maxLength?: number
}) {
return (
<label className="space-y-1 block">
<span className="text-sm font-medium text-slate-700">
{label}
{required ? <span className="text-red-600"> *</span> : null}
</span>
<input
type="text"
dir={language === 'ar' ? 'rtl' : 'ltr'}
lang={language}
maxLength={maxLength}
pattern={localizedPattern(language)}
value={localizedTextValue(value, language)}
onChange={(event) => onChange(withLocalizedText(value, language, event.target.value))}
className="input-field"
/>
</label>
)
}
@@ -28,6 +28,7 @@ export function getReservationWizardCopy(language: Language) {
selectCustomer: 'Select customer...',
firstName: 'First name',
lastName: 'Last name',
recordLanguage: 'Language',
email: 'Email',
phone: 'Phone',
dateOfBirth: 'Date of birth',
@@ -73,6 +74,10 @@ export function getReservationWizardCopy(language: Language) {
leaveWarning: 'You have an unfinished reservation draft.',
validation: {
required: 'This field is required.',
languageText: 'Use characters that match the selected language.',
countryCode: 'Use a two-letter ISO country code.',
alphanumericId: 'Use 5-30 letters and numbers only.',
phone: 'Enter a valid Moroccan phone number.',
email: 'Enter a valid email address.',
pastDate: 'Date must be in the past.',
futureIssue: 'Issue date cannot be in the future.',
@@ -112,6 +117,7 @@ export function getReservationWizardCopy(language: Language) {
selectCustomer: 'Sélectionner un client...',
firstName: 'Prénom',
lastName: 'Nom',
recordLanguage: 'Langue',
email: 'Email',
phone: 'Téléphone',
dateOfBirth: 'Date de naissance',
@@ -157,6 +163,10 @@ export function getReservationWizardCopy(language: Language) {
leaveWarning: 'Vous avez un brouillon de réservation non terminé.',
validation: {
required: 'Ce champ est obligatoire.',
languageText: 'Utilisez des caractères correspondant à la langue sélectionnée.',
countryCode: 'Utilisez un code pays ISO à deux lettres.',
alphanumericId: 'Utilisez 5 à 30 lettres et chiffres uniquement.',
phone: 'Saisissez un numéro de téléphone marocain valide.',
email: 'Saisissez une adresse email valide.',
pastDate: 'La date doit être passée.',
futureIssue: 'La date de délivrance ne peut pas être future.',
@@ -196,6 +206,7 @@ export function getReservationWizardCopy(language: Language) {
selectCustomer: 'اختر عميلًا...',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
recordLanguage: 'اللغة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
dateOfBirth: 'تاريخ الميلاد',
@@ -241,6 +252,10 @@ export function getReservationWizardCopy(language: Language) {
leaveWarning: 'لديك مسودة حجز غير مكتملة.',
validation: {
required: 'هذا الحقل مطلوب.',
languageText: 'استخدم أحرفاً تطابق اللغة المحددة.',
countryCode: 'استخدم رمز بلد ISO من حرفين.',
alphanumericId: 'استخدم من 5 إلى 30 حرفاً ورقماً فقط.',
phone: 'أدخل رقم هاتف مغربي صالحاً.',
email: 'أدخل بريدًا إلكترونيًا صالحًا.',
pastDate: 'يجب أن يكون التاريخ في الماضي.',
futureIssue: 'لا يمكن أن يكون تاريخ الإصدار في المستقبل.',
@@ -8,9 +8,14 @@ function readCustomerAddressValue(customer: Customer | undefined, key: string) {
return typeof value === 'string' ? value : ''
}
function customerNameField(value: string, language: 'en' | 'fr' | 'ar') {
return language === 'ar' ? { fr: '', ar: value } : { fr: value, ar: '' }
}
export function createAdditionalDriver(): AdditionalDriverDraft {
return {
id: crypto.randomUUID(),
language: 'fr',
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
@@ -27,6 +32,7 @@ export function createInitialDraft(): ReservationDraft {
return {
customer: {
mode: 'existing',
language: 'fr',
selectedCustomerId: null,
createdCustomerId: null,
firstName: emptyBilingual(),
@@ -92,8 +98,9 @@ export function reservationDraftReducer(draft: ReservationDraft, action: Reserva
...draft,
customer: {
...createInitialDraft().customer,
mode: 'new',
createdCustomerId: draft.customer.createdCustomerId,
mode: 'new',
language: 'fr',
createdCustomerId: draft.customer.createdCustomerId,
},
identity: createInitialDraft().identity,
license: createInitialDraft().license,
@@ -107,14 +114,16 @@ export function reservationDraftReducer(draft: ReservationDraft, action: Reserva
license: createInitialDraft().license,
}
case 'selectExistingCustomer':
const language = action.customer.language ?? 'fr'
return {
...draft,
customer: {
mode: 'existing',
language,
selectedCustomerId: action.customer.id,
createdCustomerId: null,
firstName: { fr: action.customer.firstName ?? '', ar: action.customer.firstNameAr ?? '' },
lastName: { fr: action.customer.lastName ?? '', ar: action.customer.lastNameAr ?? '' },
firstName: customerNameField(action.customer.firstName ?? '', language),
lastName: customerNameField(action.customer.lastName ?? '', language),
email: action.customer.email ?? '',
phone: action.customer.phone ?? '',
hydratedFromCustomerId: action.customer.id,
@@ -1,5 +1,4 @@
import { z } from 'zod'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { FieldErrors, ReservationDraft } from './reservationWizard.types'
import type { ReservationWizardCopy } from './reservationWizard.copy'
@@ -10,8 +9,47 @@ function nonEmpty(message: string) {
return z.string().trim().min(1, message)
}
type InputLanguage = 'en' | 'fr' | 'ar'
const localizedTextPatterns: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z\s'-]+$/u,
fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
}
const localizedAddressPatterns: Record<InputLanguage, RegExp> = {
en: /^[A-Za-z0-9\s,'-]+$/u,
fr: /^[A-Za-z0-9À-ÖØ-öø-ÿŒœ\s,'-]+$/u,
ar: /^[0-9\u0660-\u0669\u0600-\u06FF\u0750-\u077F\s،؛؟ـ,'-]+$/u,
}
function localizedValue(value: { fr: string; ar: string }, language: InputLanguage) {
return language === 'ar' ? value.ar : value.fr
}
function localizedText(value: string, language: InputLanguage, maxLength: number) {
const normalized = value.trim().normalize('NFC')
return normalized.length >= 1 && normalized.length <= maxLength && localizedTextPatterns[language].test(normalized)
}
function localizedAddress(value: string, language: InputLanguage, maxLength: number) {
const normalized = value.trim().normalize('NFC')
return normalized.length >= 1 && normalized.length <= maxLength && localizedAddressPatterns[language].test(normalized)
}
function alphanumericId(value: string, required: boolean) {
const normalized = value.trim()
if (!normalized) return !required
return normalized.length >= 5 && normalized.length <= 30 && /^[A-Za-z0-9]+$/.test(normalized)
}
function moroccanPhone(value: string, required: boolean) {
const normalized = value.trim()
if (!normalized) return !required
return normalized.length <= 20 && /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/.test(normalized)
}
function emailSchema(copy: ReservationWizardCopy) {
return nonEmpty(copy.validation.required).email(copy.validation.email)
return nonEmpty(copy.validation.required).max(254).email(copy.validation.email)
}
function dateInPast(value: string) {
@@ -27,6 +65,7 @@ function addIssue(ctx: z.RefinementCtx, path: string[], message: string) {
export function validateCustomerStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
mode: z.enum(['existing', 'new']),
language: z.enum(['en', 'fr', 'ar']),
selectedCustomerId: z.string().nullable(),
firstName: z.any(),
lastName: z.any(),
@@ -37,10 +76,10 @@ export function validateCustomerStep(draft: ReservationDraft, copy: ReservationW
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 (!localizedText(localizedValue(customer.firstName, customer.language), customer.language, 50)) addIssue(ctx, ['firstName'], copy.validation.languageText)
if (!localizedText(localizedValue(customer.lastName, customer.language), customer.language, 50)) addIssue(ctx, ['lastName'], copy.validation.languageText)
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)
if (!moroccanPhone(customer.phone, true)) addIssue(ctx, ['phone'], copy.validation.phone)
})
return zodErrors(schema.safeParse(draft.customer), 'customer')
}
@@ -48,20 +87,23 @@ export function validateCustomerStep(draft: ReservationDraft, copy: ReservationW
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),
language: z.enum(['en', 'fr', 'ar']),
nationality: nonEmpty(copy.validation.required).length(2, copy.validation.countryCode),
fullAddress: nonEmpty(copy.validation.required).max(255),
identityDocumentNumber: nonEmpty(copy.validation.required),
internationalLicenseNumber: z.string(),
}).superRefine((identity, ctx) => {
if (identity.dateOfBirth && !dateInPast(identity.dateOfBirth)) addIssue(ctx, ['dateOfBirth'], copy.validation.pastDate)
if (identity.fullAddress && !localizedAddress(identity.fullAddress, identity.language, 255)) addIssue(ctx, ['fullAddress'], copy.validation.languageText)
if (!alphanumericId(identity.identityDocumentNumber, true)) addIssue(ctx, ['identityDocumentNumber'], copy.validation.alphanumericId)
})
return zodErrors(schema.safeParse(draft.identity), 'identity')
return zodErrors(schema.safeParse({ ...draft.identity, language: draft.customer.language }), 'identity')
}
export function validateLicenseStep(draft: ReservationDraft, copy: ReservationWizardCopy): FieldErrors {
const schema = z.object({
number: nonEmpty(copy.validation.required),
country: nonEmpty(copy.validation.required),
country: nonEmpty(copy.validation.required).length(2, copy.validation.countryCode),
issuedAt: nonEmpty(copy.validation.required),
expiry: nonEmpty(copy.validation.required),
category: nonEmpty(copy.validation.required),
@@ -73,6 +115,7 @@ export function validateLicenseStep(draft: ReservationDraft, copy: ReservationWi
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 (!alphanumericId(license.number, true)) addIssue(ctx, ['number'], copy.validation.alphanumericId)
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)
@@ -102,9 +145,10 @@ export function validatePaymentExtrasStep(draft: ReservationDraft, copy: Reserva
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 (!localizedText(localizedValue(driver.firstName, driver.language), driver.language, 50)) errors[`additionalDrivers.${index}.firstName`] = copy.validation.languageText
if (!localizedText(localizedValue(driver.lastName, driver.language), driver.language, 50)) errors[`additionalDrivers.${index}.lastName`] = copy.validation.languageText
if (!alphanumericId(driver.driverLicense, true)) errors[`additionalDrivers.${index}.driverLicense`] = copy.validation.alphanumericId
if (!moroccanPhone(driver.phone, false)) errors[`additionalDrivers.${index}.phone`] = copy.validation.phone
if (driver.licenseIssuedAt && new Date(driver.licenseIssuedAt) > new Date()) {
errors[`additionalDrivers.${index}.licenseIssuedAt`] = copy.validation.futureIssue
}
@@ -1,7 +1,10 @@
import { apiFetch } from '@/lib/api'
import { bilingualPrimary } from '@/components/ui/BilingualInput'
import type { CreatedReservation, Customer, ReservationDraft } from './reservationWizard.types'
function localizedText(value: { fr: string; ar: string }, language: 'en' | 'fr' | 'ar') {
return language === 'ar' ? value.ar : value.fr
}
type AvailabilityResult = { available: boolean }
function isoFromLocal(value: string) {
@@ -51,24 +54,24 @@ export async function uploadLicenseImage(customerId: string, file: File) {
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,
language: draft.customer.language,
firstName: localizedText(draft.customer.firstName, draft.customer.language).trim(),
lastName: localizedText(draft.customer.lastName, draft.customer.language).trim(),
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(),
nationality: draft.identity.nationality.trim().toUpperCase(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
fullAddressLanguage: draft.customer.language,
identityDocumentNumber: draft.identity.identityDocumentNumber.trim().toUpperCase(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim().toUpperCase() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCountry: draft.license.country.trim().toUpperCase(),
licenseNumber: draft.license.number.trim().toUpperCase(),
licenseCategory: draft.license.category.trim(),
}
}
@@ -77,16 +80,18 @@ function customerPatchPayload(draft: ReservationDraft) {
return {
driverLicense: draft.license.number.trim(),
dateOfBirth: isoFromLocal(draft.identity.dateOfBirth),
nationality: draft.identity.nationality.trim(),
language: draft.customer.language,
nationality: draft.identity.nationality.trim().toUpperCase(),
address: {
fullAddress: draft.identity.fullAddress.trim(),
identityDocumentNumber: draft.identity.identityDocumentNumber.trim(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim() || undefined,
fullAddressLanguage: draft.customer.language,
identityDocumentNumber: draft.identity.identityDocumentNumber.trim().toUpperCase(),
internationalLicenseNumber: draft.identity.internationalLicenseNumber.trim().toUpperCase() || undefined,
},
licenseExpiry: isoFromLocal(draft.license.expiry),
licenseIssuedAt: isoFromLocal(draft.license.issuedAt),
licenseCountry: draft.license.country.trim(),
licenseNumber: draft.license.number.trim(),
licenseCountry: draft.license.country.trim().toUpperCase(),
licenseNumber: draft.license.number.trim().toUpperCase(),
licenseCategory: draft.license.category.trim(),
}
}
@@ -95,8 +100,8 @@ function contractFieldsPayload(draft: ReservationDraft) {
const firstAdditionalDriver = draft.additionalDrivers[0]
return compactFields({
driverFirstName: bilingualPrimary(draft.customer.firstName),
driverLastName: bilingualPrimary(draft.customer.lastName),
driverFirstName: localizedText(draft.customer.firstName, draft.customer.language),
driverLastName: localizedText(draft.customer.lastName, draft.customer.language),
driverBirthDate: formatDateForContract(draft.identity.dateOfBirth),
driverNationality: draft.identity.nationality,
driverAddress: draft.identity.fullAddress,
@@ -106,10 +111,10 @@ function contractFieldsPayload(draft: ReservationDraft) {
driverLicense: draft.license.number,
driverLicenseIssuedAt: formatDateForContract(draft.license.issuedAt),
driverLicenseExpiry: formatDateForContract(draft.license.expiry),
secondDriverFirstName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.firstName) : '',
secondDriverLastName: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.lastName) : '',
secondDriverFirstName: firstAdditionalDriver ? localizedText(firstAdditionalDriver.firstName, firstAdditionalDriver.language) : '',
secondDriverLastName: firstAdditionalDriver ? localizedText(firstAdditionalDriver.lastName, firstAdditionalDriver.language) : '',
secondDriverBirthDate: firstAdditionalDriver?.dateOfBirth ? formatDateForContract(firstAdditionalDriver.dateOfBirth) : '',
secondDriverNationality: firstAdditionalDriver ? bilingualPrimary(firstAdditionalDriver.nationality) : '',
secondDriverNationality: firstAdditionalDriver ? localizedText(firstAdditionalDriver.nationality, firstAdditionalDriver.language) : '',
secondDriverPhone: firstAdditionalDriver?.phone ?? '',
secondDriverLicense: firstAdditionalDriver?.driverLicense ?? '',
secondDriverLicenseIssuedAt: firstAdditionalDriver?.licenseIssuedAt ? formatDateForContract(firstAdditionalDriver.licenseIssuedAt) : '',
@@ -134,18 +139,16 @@ function reservationPayload(draft: ReservationDraft, customerId: string) {
radioCd: draft.payment.radioCd,
contractFields: contractFieldsPayload(draft),
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,
language: driver.language,
firstName: localizedText(driver.firstName, driver.language).trim(),
lastName: localizedText(driver.lastName, driver.language).trim(),
email: driver.email.trim() || undefined,
phone: driver.phone.trim() || undefined,
driverLicense: driver.driverLicense.trim(),
driverLicense: driver.driverLicense.trim().toUpperCase(),
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,
nationality: localizedText(driver.nationality, driver.language).trim().toUpperCase() || undefined,
})),
notes: draft.payment.notes.trim() || undefined,
}
@@ -2,6 +2,7 @@ import type { BilingualField } from '@/components/ui/BilingualInput'
export type Customer = {
id: string
language?: 'en' | 'fr' | 'ar' | null
firstName: string
firstNameAr?: string | null
lastName: string
@@ -30,6 +31,7 @@ export type Vehicle = {
export type AdditionalDriverDraft = {
id: string
language: 'en' | 'fr' | 'ar'
firstName: BilingualField
lastName: BilingualField
email: string
@@ -44,6 +46,7 @@ export type AdditionalDriverDraft = {
export type ReservationDraft = {
customer: {
mode: 'existing' | 'new'
language: 'en' | 'fr' | 'ar'
selectedCustomerId: string | null
createdCustomerId: string | null
firstName: BilingualField
@@ -17,6 +17,7 @@ const copy = {
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
language: 'Language',
role: 'Role',
cancel: 'Cancel',
sending: 'Sending…',
@@ -44,6 +45,7 @@ const copy = {
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
language: 'Langue',
role: 'Rôle',
cancel: 'Annuler',
sending: 'Envoi…',
@@ -71,6 +73,7 @@ const copy = {
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
language: 'اللغة',
role: 'الدور',
cancel: 'إلغاء',
sending: 'جارٍ الإرسال…',
@@ -102,9 +105,15 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [memberLanguage, setMemberLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [role, setRole] = useState<'MANAGER' | 'AGENT'>('AGENT')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const textPattern = {
en: "[A-Za-z\\s'-]+",
fr: "[A-Za-zÀ-ÖØ-öø-ÿŒœ\\s'-]+",
ar: "[\\u0600-\\u06FF\\u0750-\\u077F\\s،؛؟ـ'-]+",
}[memberLanguage]
useEffect(() => {
if (!open) {
@@ -112,18 +121,19 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
setFirstName('')
setLastName('')
setEmail('')
setMemberLanguage(language)
setRole('AGENT')
setError(null)
}, 200)
}
}, [open])
}, [language, open])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setLoading(true)
try {
await onInvite({ firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
await onInvite({ language: memberLanguage, firstName: firstName.trim(), lastName: lastName.trim(), email: email.trim(), role })
onClose()
} catch (err: any) {
setError(err.message ?? t.failedInvite)
@@ -160,6 +170,9 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
onChange={(e) => setFirstName(e.target.value)}
placeholder="Youssef"
required
maxLength={50}
dir={memberLanguage === 'ar' ? 'rtl' : 'ltr'}
pattern={textPattern}
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:ring-offset-0 focus:border-transparent"
/>
</div>
@@ -173,6 +186,9 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
onChange={(e) => setLastName(e.target.value)}
placeholder="Benali"
required
maxLength={50}
dir={memberLanguage === 'ar' ? 'rtl' : 'ltr'}
pattern={textPattern}
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
@@ -188,10 +204,27 @@ export default function InviteModal({ open, onClose, onInvite }: Props) {
onChange={(e) => setEmail(e.target.value)}
placeholder="youssef@yourcompany.com"
required
maxLength={254}
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.language}
</label>
<select
value={memberLanguage}
onChange={(e) => setMemberLanguage(e.target.value as 'en' | 'fr' | 'ar')}
required
className="w-full px-3 py-2 text-sm border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-zinc-900 dark:focus:ring-white focus:border-transparent"
>
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="ar">عربي</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-1.5">
{t.role}
+1
View File
@@ -28,6 +28,7 @@ export interface TeamStats {
}
export interface InvitePayload {
language: 'en' | 'fr' | 'ar'
firstName: string
lastName: string
email: string