- {copy.firstName}
- - {display(bilingualPrimary(driver.firstName), copy.summaryEmpty)}
+ - {display(localizedText(driver.firstName, driver.language), copy.summaryEmpty)}
- {copy.lastName}
- - {display(bilingualPrimary(driver.lastName), copy.summaryEmpty)}
+ - {display(localizedText(driver.lastName, driver.language), copy.summaryEmpty)}
- {copy.driverLicenseNumber}
diff --git a/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx b/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx
index cfef1c5..0daa916 100644
--- a/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx
+++ b/apps/dashboard/src/components/reservations/new/ReservationWizard.tsx
@@ -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({
) : (
+
- onCustomerChange('firstName', value)} />
+ onCustomerChange('firstName', value)} maxLength={50} />
- onCustomerChange('lastName', value)} />
+ onCustomerChange('lastName', value)} maxLength={50} />
@@ -503,15 +534,15 @@ function IdentityStep({
{draft.customer.hydratedFromCustomerId ?
{copy.loadedCustomerData}
: null}
onChange('dateOfBirth', value)} />
- onChange('nationality', value)} />
+ onChange('nationality', value.toUpperCase())} />
- onChange('identityDocumentNumber', value)} />
- onChange('internationalLicenseNumber', value)} />
+ onChange('identityDocumentNumber', value.toUpperCase())} />
+ onChange('internationalLicenseNumber', value.toUpperCase())} />
@@ -534,13 +565,13 @@ function LicenseStep({
return (
- onChange('number', value)} />
- onChange('country', value)} />
+ onChange('number', value.toUpperCase())} />
+ onChange('country', value.toUpperCase())} />
onChange('issuedAt', value)} />
onChange('expiry', value)} />
- onChange('category', value)} />
+ onChange('category', value)} />
+
- onDriverChange(driver.id, 'firstName', value)} />
+ onDriverChange(driver.id, 'firstName', value)} maxLength={50} />
- onDriverChange(driver.id, 'lastName', value)} />
+ onDriverChange(driver.id, 'lastName', value)} maxLength={50} />
-
onDriverChange(driver.id, 'email', value)} />
- onDriverChange(driver.id, 'phone', value)} />
- onDriverChange(driver.id, 'driverLicense', value)} />
+ onDriverChange(driver.id, 'email', value)} />
+ onDriverChange(driver.id, 'phone', value)} />
+ onDriverChange(driver.id, 'driverLicense', value.toUpperCase())} />
- onDriverChange(driver.id, 'nationality', value)} />
+ onDriverChange(driver.id, 'nationality', withLocalizedText(driver.nationality, driver.language, value.toUpperCase()))} />
onDriverChange(driver.id, 'dateOfBirth', 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 (
)
}
+
+function LocalizedInput({
+ label,
+ language,
+ value,
+ onChange,
+ required = false,
+ maxLength,
+}: {
+ label: string
+ language: Language
+ value: BilingualField
+ onChange: (value: BilingualField) => void
+ required?: boolean
+ maxLength?: number
+}) {
+ return (
+
+ )
+}
diff --git a/apps/dashboard/src/components/reservations/new/reservationWizard.copy.ts b/apps/dashboard/src/components/reservations/new/reservationWizard.copy.ts
index 0b7ff61..721196b 100644
--- a/apps/dashboard/src/components/reservations/new/reservationWizard.copy.ts
+++ b/apps/dashboard/src/components/reservations/new/reservationWizard.copy.ts
@@ -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: 'لا يمكن أن يكون تاريخ الإصدار في المستقبل.',
diff --git a/apps/dashboard/src/components/reservations/new/reservationWizard.reducer.ts b/apps/dashboard/src/components/reservations/new/reservationWizard.reducer.ts
index 27d13c6..35731d3 100644
--- a/apps/dashboard/src/components/reservations/new/reservationWizard.reducer.ts
+++ b/apps/dashboard/src/components/reservations/new/reservationWizard.reducer.ts
@@ -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,
diff --git a/apps/dashboard/src/components/reservations/new/reservationWizard.schemas.ts b/apps/dashboard/src/components/reservations/new/reservationWizard.schemas.ts
index 6d65873..dc7b1f7 100644
--- a/apps/dashboard/src/components/reservations/new/reservationWizard.schemas.ts
+++ b/apps/dashboard/src/components/reservations/new/reservationWizard.schemas.ts
@@ -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 = {
+ en: /^[A-Za-z\s'-]+$/u,
+ fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
+ ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
+}
+const localizedAddressPatterns: Record = {
+ 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
}
diff --git a/apps/dashboard/src/components/reservations/new/reservationWizard.submit.ts b/apps/dashboard/src/components/reservations/new/reservationWizard.submit.ts
index 71f0d21..68c2045 100644
--- a/apps/dashboard/src/components/reservations/new/reservationWizard.submit.ts
+++ b/apps/dashboard/src/components/reservations/new/reservationWizard.submit.ts
@@ -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,
}
diff --git a/apps/dashboard/src/components/reservations/new/reservationWizard.types.ts b/apps/dashboard/src/components/reservations/new/reservationWizard.types.ts
index a10a427..fc97242 100644
--- a/apps/dashboard/src/components/reservations/new/reservationWizard.types.ts
+++ b/apps/dashboard/src/components/reservations/new/reservationWizard.types.ts
@@ -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
diff --git a/apps/dashboard/src/components/team/InviteModal.tsx b/apps/dashboard/src/components/team/InviteModal.tsx
index 66eb3d3..7b42995 100644
--- a/apps/dashboard/src/components/team/InviteModal.tsx
+++ b/apps/dashboard/src/components/team/InviteModal.tsx
@@ -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(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"
/>
@@ -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"
/>
@@ -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"
/>