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
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:
@@ -210,6 +210,7 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">First name</label>
|
||||
<input
|
||||
required
|
||||
maxLength={50}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.firstName}
|
||||
onChange={(e) => setForm({ ...form, firstName: e.target.value })}
|
||||
@@ -219,6 +220,7 @@ export default function AdminUsersPage() {
|
||||
<label className="block text-xs font-medium text-zinc-400 mb-1">Last name</label>
|
||||
<input
|
||||
required
|
||||
maxLength={50}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.lastName}
|
||||
onChange={(e) => setForm({ ...form, lastName: e.target.value })}
|
||||
@@ -230,6 +232,7 @@ export default function AdminUsersPage() {
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
className="w-full px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
@@ -244,6 +247,7 @@ export default function AdminUsersPage() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!editingAdminId}
|
||||
minLength={editingAdminId ? undefined : 8}
|
||||
maxLength={128}
|
||||
className="w-full px-3 py-2 pr-10 rounded-xl bg-zinc-800 border border-zinc-700 text-zinc-100 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
|
||||
@@ -203,6 +203,7 @@ const STATUS_COLORS: Record<string, string> = {
|
||||
}
|
||||
|
||||
const INPUT_CLASS = 'mt-1 w-full rounded-xl border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-emerald-500'
|
||||
const MOROCCAN_PHONE_PATTERN = '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$'
|
||||
const LABEL_CLASS = 'text-xs font-medium uppercase tracking-wide text-zinc-500'
|
||||
|
||||
const COMPANY_TABS = [
|
||||
@@ -612,19 +613,19 @@ export default function AdminCompanyDetailPage() {
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Name</span>
|
||||
<input className={INPUT_CLASS} value={form.company.name} onChange={(e) => updateSection('company', { name: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.company.name} maxLength={100} onChange={(e) => updateSection('company', { name: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Slug</span>
|
||||
<input className={INPUT_CLASS} value={form.company.slug} onChange={(e) => updateSection('company', { slug: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.company.slug} maxLength={50} pattern="^[a-z0-9]+(?:-[a-z0-9]+)*$" onChange={(e) => updateSection('company', { slug: e.target.value.toLowerCase() })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Email</span>
|
||||
<input className={INPUT_CLASS} type="email" value={form.company.email} onChange={(e) => updateSection('company', { email: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="email" value={form.company.email} maxLength={254} onChange={(e) => updateSection('company', { email: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Phone</span>
|
||||
<input className={INPUT_CLASS} value={form.company.phone} onChange={(e) => updateSection('company', { phone: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="tel" value={form.company.phone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('company', { phone: e.target.value })} />
|
||||
</label>
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>Company status</span>
|
||||
@@ -632,7 +633,7 @@ export default function AdminCompanyDetailPage() {
|
||||
</div>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Subscription payment ref</span>
|
||||
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.company.subscriptionPaymentRef} maxLength={120} onChange={(e) => updateSection('company', { subscriptionPaymentRef: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
@@ -720,23 +721,23 @@ export default function AdminCompanyDetailPage() {
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Display name</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.displayName} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.displayName} maxLength={100} onChange={(e) => updateSection('brand', { displayName: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Subdomain</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.subdomain} onChange={(e) => updateSection('brand', { subdomain: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.subdomain} maxLength={50} pattern="^[a-z0-9-]+$" onChange={(e) => updateSection('brand', { subdomain: e.target.value.toLowerCase() })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>Tagline</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.tagline} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.tagline} maxLength={160} onChange={(e) => updateSection('brand', { tagline: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Public email</span>
|
||||
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="email" value={form.brand.publicEmail} maxLength={254} onChange={(e) => updateSection('brand', { publicEmail: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Public phone</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.publicPhone} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="tel" value={form.brand.publicPhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { publicPhone: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Custom domain</span>
|
||||
@@ -748,11 +749,11 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>WhatsApp</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.whatsappNumber} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="tel" value={form.brand.whatsappNumber} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('brand', { whatsappNumber: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Locale</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.defaultLocale} onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.defaultLocale} maxLength={2} pattern="^(en|fr|ar)$" onChange={(e) => updateSection('brand', { defaultLocale: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Brand currency</span>
|
||||
@@ -764,15 +765,15 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>City</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.publicCity} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.publicCity} maxLength={85} onChange={(e) => updateSection('brand', { publicCity: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Country</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.publicCountry} onChange={(e) => updateSection('brand', { publicCountry: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.publicCountry || 'MA'} maxLength={2} pattern="^[A-Z]{2}$" onChange={(e) => updateSection('brand', { publicCountry: e.target.value.toUpperCase() })} />
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>Address</span>
|
||||
<input className={INPUT_CLASS} value={form.brand.publicAddress} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.brand.publicAddress} maxLength={255} onChange={(e) => updateSection('brand', { publicAddress: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||
<input type="checkbox" checked={form.brand.isListedOnCarplace} onChange={(e) => updateSection('brand', { isListedOnCarplace: e.target.checked })} />
|
||||
@@ -797,11 +798,11 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Manager / owner name</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.managerName} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.managerName} maxLength={100} onChange={(e) => updateSection('companyProfile', { managerName: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>ZIP / postal code</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.zipCode} maxLength={10} onChange={(e) => updateSection('companyProfile', { zipCode: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Fax</span>
|
||||
@@ -813,11 +814,11 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>ICE number</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.iceNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { iceNumber: e.target.value.toUpperCase() })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Operating license number</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { operatingLicenseNumber: e.target.value.toUpperCase() })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Operating license issue date</span>
|
||||
@@ -856,7 +857,7 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Identity document number</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.responsibleIdentityNumber} maxLength={30} onChange={(e) => updateSection('companyProfile', { responsibleIdentityNumber: e.target.value.toUpperCase() })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Qualification / diploma / experience</span>
|
||||
@@ -864,11 +865,11 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Responsible phone</span>
|
||||
<input className={INPUT_CLASS} value={form.companyProfile.responsiblePhone} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="tel" value={form.companyProfile.responsiblePhone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(e) => updateSection('companyProfile', { responsiblePhone: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Responsible email</span>
|
||||
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="email" value={form.companyProfile.responsibleEmail} maxLength={254} onChange={(e) => updateSection('companyProfile', { responsibleEmail: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -887,15 +888,15 @@ export default function AdminCompanyDetailPage() {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Legal name</span>
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.legalName} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.legalName} maxLength={100} onChange={(e) => updateSection('contractSettings', { legalName: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Registration number</span>
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.registrationNumber} maxLength={30} onChange={(e) => updateSection('contractSettings', { registrationNumber: e.target.value.toUpperCase() })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Tax ID</span>
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.taxId} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.contractSettings.taxId} maxLength={30} onChange={(e) => updateSection('contractSettings', { taxId: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Fuel policy type</span>
|
||||
@@ -921,7 +922,7 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label className="md:col-span-2">
|
||||
<span className={LABEL_CLASS}>Terms</span>
|
||||
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
|
||||
<textarea className={`${INPUT_CLASS} min-h-28`} value={form.contractSettings.terms} maxLength={4000} onChange={(e) => updateSection('contractSettings', { terms: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -959,11 +960,11 @@ export default function AdminCompanyDetailPage() {
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Accountant name</span>
|
||||
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
|
||||
<input className={INPUT_CLASS} value={form.accountingSettings.accountantName} maxLength={50} onChange={(e) => updateSection('accountingSettings', { accountantName: e.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLASS}>Accountant email</span>
|
||||
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
|
||||
<input className={INPUT_CLASS} type="email" value={form.accountingSettings.accountantEmail} maxLength={254} onChange={(e) => updateSection('accountingSettings', { accountantEmail: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex items-center gap-3 pt-7 text-sm text-zinc-300">
|
||||
<input type="checkbox" checked={form.accountingSettings.autoSendReport} onChange={(e) => updateSection('accountingSettings', { autoSendReport: e.target.checked })} />
|
||||
|
||||
@@ -62,6 +62,7 @@ export default function AdminForgotPasswordPage() {
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
maxLength={254}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin@rentaldrivego.com"
|
||||
|
||||
@@ -102,6 +102,7 @@ function AdminResetPasswordContent() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
@@ -133,6 +134,7 @@ function AdminResetPasswordContent() {
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
|
||||
@@ -69,18 +69,18 @@ const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
||||
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
city: { maxLength: 85, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
||||
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
||||
|
||||
// Group 2: Title Case All
|
||||
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
fullAddress: { maxLength: 255, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
||||
|
||||
// Group 3: Lowercase — email
|
||||
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
email: { maxLength: 254, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
||||
|
||||
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
|
||||
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
||||
|
||||
@@ -10,6 +10,113 @@ const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/
|
||||
|
||||
/** Morocco phone: (+212|00212|0) + 5/6/7 + 8 digits (optional spaces) */
|
||||
const MA_PHONE_REGEX = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/
|
||||
const ALPHANUMERIC_REGEX = /^[A-Za-z0-9]+$/
|
||||
|
||||
export const languageSchema = z.enum(['en', 'fr', 'ar'])
|
||||
export type InputLanguage = z.infer<typeof languageSchema>
|
||||
|
||||
const LOCALIZED_TEXT_PATTERNS: Record<InputLanguage, RegExp> = {
|
||||
en: /^[A-Za-z\s'-]+$/u,
|
||||
fr: /^[A-Za-zÀ-ÖØ-öø-ÿŒœ\s'-]+$/u,
|
||||
ar: /^[\u0600-\u06FF\u0750-\u077F\s،؛؟ـ'-]+$/u,
|
||||
}
|
||||
const LOCALIZED_ADDRESS_PATTERNS: 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 normalizeNfc(value: string) {
|
||||
return value.trim().normalize('NFC')
|
||||
}
|
||||
|
||||
export function validateLocalizedText(value: string, language: InputLanguage, maxLength: number) {
|
||||
const normalized = normalizeNfc(value)
|
||||
return normalized.length >= 1 &&
|
||||
normalized.length <= maxLength &&
|
||||
LOCALIZED_TEXT_PATTERNS[language].test(normalized)
|
||||
}
|
||||
|
||||
export function validateLocalizedAddress(value: string, language: InputLanguage, maxLength: number) {
|
||||
const normalized = normalizeNfc(value)
|
||||
return normalized.length >= 1 &&
|
||||
normalized.length <= maxLength &&
|
||||
LOCALIZED_ADDRESS_PATTERNS[language].test(normalized)
|
||||
}
|
||||
|
||||
export function localizedTextIssue(field: string) {
|
||||
return `${field} contains characters that do not match the selected language`
|
||||
}
|
||||
|
||||
export function isoDateField() {
|
||||
return z.string().regex(ISO_DATE_REGEX, { message: 'Use YYYY-MM-DD format' })
|
||||
}
|
||||
|
||||
export function pastOrTodayIsoDateField() {
|
||||
return isoDateField().refine((value) => value <= new Date().toISOString().slice(0, 10), {
|
||||
message: 'Date cannot be in the future',
|
||||
})
|
||||
}
|
||||
|
||||
export function optionalVinField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(
|
||||
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' }).optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function vinField() {
|
||||
return z.string().trim().transform((value) => value.toUpperCase()).pipe(
|
||||
z.string().regex(VIN_REGEX, { message: 'VIN must be 17 characters and cannot contain I, O, or Q' })
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalAlphanumericIdField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(
|
||||
z.string()
|
||||
.min(5, { message: 'Minimum 5 characters required' })
|
||||
.max(30, { message: 'Maximum 30 characters allowed' })
|
||||
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
|
||||
.optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function requiredAlphanumericIdField() {
|
||||
return z.string()
|
||||
.trim()
|
||||
.min(5, { message: 'Minimum 5 characters required' })
|
||||
.max(30, { message: 'Maximum 30 characters allowed' })
|
||||
.regex(ALPHANUMERIC_REGEX, { message: 'Only letters and numbers are allowed' })
|
||||
.transform((value) => value.toUpperCase())
|
||||
}
|
||||
|
||||
export function countryCodeField() {
|
||||
return z.string()
|
||||
.trim()
|
||||
.length(2, { message: 'Use a valid ISO country code' })
|
||||
.transform((value) => value.toUpperCase())
|
||||
.refine((value) => {
|
||||
try {
|
||||
return new Intl.DisplayNames(['en'], { type: 'region' }).of(value) !== value
|
||||
} catch {
|
||||
return /^[A-Z]{2}$/.test(value)
|
||||
}
|
||||
}, { message: 'Use a valid ISO country code' })
|
||||
}
|
||||
|
||||
export function optionalCountryCodeField() {
|
||||
return z.string().optional().transform((value) => {
|
||||
if (value === undefined || value.trim() === '') return undefined
|
||||
return value.trim().toUpperCase()
|
||||
}).pipe(countryCodeField().optional())
|
||||
}
|
||||
|
||||
// ─── Phone sanitization ───────────────────────────────────────
|
||||
|
||||
@@ -21,6 +128,15 @@ function sanitizePhone(raw: string): string {
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
function normalizeMoroccanPhone(raw: string): string {
|
||||
const compact = sanitizePhone(raw).replace(/[\s().-]/g, '')
|
||||
if (!MA_PHONE_REGEX.test(sanitizePhone(raw))) return compact
|
||||
if (compact.startsWith('+212')) return compact
|
||||
if (compact.startsWith('00212')) return `+212${compact.slice(5)}`
|
||||
if (compact.startsWith('0')) return `+212${compact.slice(1)}`
|
||||
return compact
|
||||
}
|
||||
|
||||
// ─── Required fields ───────────────────────────────────────────
|
||||
|
||||
function applyFieldRules(fieldType: FieldType) {
|
||||
@@ -96,6 +212,7 @@ export function emailField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Email is required' })
|
||||
.max(254, { message: 'Maximum 254 characters allowed' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizeAndFormat(val, 'email'))
|
||||
.pipe(
|
||||
@@ -109,6 +226,7 @@ export function emailField() {
|
||||
export function optionalEmailField() {
|
||||
return z
|
||||
.string()
|
||||
.max(254, { message: 'Maximum 254 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
@@ -128,27 +246,27 @@ export function phoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.trim()
|
||||
.transform((val: string) => sanitizePhone(val))
|
||||
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
|
||||
.transform((val: string) => normalizeMoroccanPhone(val))
|
||||
.pipe(
|
||||
z.string().refine(
|
||||
(val: string) => MA_PHONE_REGEX.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
|
||||
)
|
||||
}
|
||||
|
||||
export function optionalPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
return normalizeMoroccanPhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().optional().refine(
|
||||
(val) => val === undefined || MA_PHONE_REGEX.test(val),
|
||||
(val) => val === undefined || /^\+212[5-7]\d{8}$/.test(val),
|
||||
{ message: 'Please enter a valid Morocco phone number' }
|
||||
)
|
||||
)
|
||||
@@ -157,12 +275,26 @@ export function optionalPhoneField() {
|
||||
export function optionalContactPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.optional()
|
||||
.transform((val) => {
|
||||
if (val === undefined || val === null || val.trim() === '') return undefined
|
||||
return sanitizePhone(val)
|
||||
return normalizeMoroccanPhone(val)
|
||||
})
|
||||
.pipe(
|
||||
z.string().max(30, { message: 'Maximum 30 characters allowed' }).optional()
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' }).optional()
|
||||
)
|
||||
}
|
||||
|
||||
export function contactPhoneField() {
|
||||
return z
|
||||
.string()
|
||||
.min(1, { message: 'Phone number is required' })
|
||||
.max(20, { message: 'Maximum 20 characters allowed' })
|
||||
.trim()
|
||||
.refine((val: string) => MA_PHONE_REGEX.test(sanitizePhone(val)), { message: 'Please enter a valid Morocco phone number' })
|
||||
.transform((val: string) => normalizeMoroccanPhone(val))
|
||||
.pipe(
|
||||
z.string().regex(/^\+212[5-7]\d{8}$/, { message: 'Please enter a valid Morocco phone number' })
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, countryCodeField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
import type {
|
||||
CarplaceHomepageContent,
|
||||
CarplaceHomepageHowItWorksStep,
|
||||
@@ -10,14 +11,14 @@ import type {
|
||||
} from '@rentaldrivego/types'
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
recoveryCode: z.string().min(8).max(32).optional(),
|
||||
})
|
||||
|
||||
export const forgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const resetPasswordSchema = z.object({
|
||||
@@ -87,23 +88,38 @@ export const permissionSchema = z.object({
|
||||
})
|
||||
|
||||
export const createAdminSchema = z.object({
|
||||
email: z.string().email().trim().toLowerCase(),
|
||||
firstName: z.string().min(1).max(100).trim(),
|
||||
lastName: z.string().min(1).max(100).trim(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).default('en'),
|
||||
preferredLocale: languageSchema.default('en'),
|
||||
password: z.string().min(8),
|
||||
permissions: z.array(permissionSchema).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (!validateLocalizedText(data.firstName, data.preferredLocale, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(data.lastName, data.preferredLocale, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const updateAdminSchema = z.object({
|
||||
email: z.string().email().trim().toLowerCase().optional(),
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
email: z.string().email().max(254).trim().toLowerCase().optional(),
|
||||
firstName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
|
||||
lastName: z.string().min(1).max(50).trim().transform((value) => value.normalize('NFC')).optional(),
|
||||
role: z.enum(['SUPER_ADMIN', 'ADMIN', 'SUPPORT', 'FINANCE', 'VIEWER']).optional(),
|
||||
preferredLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
preferredLocale: languageSchema.optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
const language = data.preferredLocale ?? 'en'
|
||||
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const adminRoleSchema = z.object({
|
||||
@@ -120,7 +136,7 @@ export const companyStatusSchema = z.object({
|
||||
})
|
||||
|
||||
const nullableString = z.union([z.string(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email(), z.null()]).optional()
|
||||
const nullableEmail = z.union([z.string().email().max(254).trim().toLowerCase(), z.null()]).optional()
|
||||
const nullableUrl = z.union([z.string().url(), z.null()]).optional()
|
||||
const nullableDate = z.union([z.string().datetime(), z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.null()]).optional()
|
||||
|
||||
@@ -133,13 +149,13 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Slug must be lowercase alphanumeric with optional hyphens')
|
||||
.max(50)
|
||||
.optional(),
|
||||
email: z.string().email().optional(), phone: nullableString,
|
||||
email: z.string().email().max(254).trim().toLowerCase().optional(), phone: z.union([contactPhoneField(), z.null()]).optional(),
|
||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||
subscriptionPaymentRef: nullableString,
|
||||
address: z.object({
|
||||
streetAddress: nullableString,
|
||||
city: nullableString,
|
||||
country: nullableString,
|
||||
city: z.union([z.string().trim().max(85), z.null()]).optional(),
|
||||
country: z.union([countryCodeField(), z.null()]).optional(),
|
||||
zipCode: nullableString,
|
||||
legalName: nullableString,
|
||||
legalForm: nullableString,
|
||||
@@ -157,7 +173,7 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
responsibleRole: nullableString,
|
||||
responsibleIdentityNumber: nullableString,
|
||||
responsibleQualification: nullableString,
|
||||
responsiblePhone: nullableString,
|
||||
responsiblePhone: z.union([contactPhoneField(), z.null()]).optional(),
|
||||
responsibleEmail: nullableEmail,
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
@@ -173,9 +189,9 @@ export const adminCompanyUpdateSchema = z.object({
|
||||
brand: z.object({
|
||||
displayName: z.string().min(1).optional(), tagline: nullableString,
|
||||
subdomain: z.string().min(1).optional(), customDomain: nullableString,
|
||||
publicEmail: nullableEmail, publicPhone: nullableString, publicAddress: nullableString,
|
||||
publicCity: nullableString, publicCountry: nullableString, websiteUrl: nullableUrl,
|
||||
whatsappNumber: nullableString, defaultLocale: z.string().min(2).optional(),
|
||||
publicEmail: nullableEmail, publicPhone: z.union([contactPhoneField(), z.null()]).optional(), publicAddress: z.union([z.string().trim().max(255), z.null()]).optional(),
|
||||
publicCity: z.union([z.string().trim().max(85), z.null()]).optional(), publicCountry: z.union([countryCodeField(), z.null()]).optional(), websiteUrl: nullableUrl,
|
||||
whatsappNumber: z.union([contactPhoneField(), z.null()]).optional(), defaultLocale: languageSchema.optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.any().optional(),
|
||||
@@ -247,7 +263,7 @@ export const invoiceIdParamSchema = z.object({
|
||||
|
||||
export const billingAccountUpdateSchema = z.object({
|
||||
legalName: z.string().min(1).max(255).optional(),
|
||||
billingEmail: z.string().email().optional(),
|
||||
billingEmail: z.string().email().max(254).trim().toLowerCase().optional(),
|
||||
billingAddress: z.any().optional(),
|
||||
taxId: z.union([z.string().max(120), z.null()]).optional(),
|
||||
taxExempt: z.boolean().optional(),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const summaryQuerySchema = z.object({
|
||||
period: z.string().default('30d'),
|
||||
period: z.string().trim().max(20).default('30d'),
|
||||
})
|
||||
|
||||
export const reportQuerySchema = z.object({
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
format: z.string().default('JSON'),
|
||||
period: z.string().optional(),
|
||||
from: z.string().trim().max(30).optional(),
|
||||
to: z.string().trim().max(30).optional(),
|
||||
format: z.enum(['JSON', 'CSV']).default('JSON'),
|
||||
period: z.string().trim().max(20).optional(),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { z } from 'zod'
|
||||
import { localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
/**
|
||||
* Minimal signup schema — only asks for what's needed to create an identity.
|
||||
* See progressive-signup-plan.md Section 3.
|
||||
*/
|
||||
export const accountStartSchema = z.object({
|
||||
firstName: z.string().trim().min(1).max(80),
|
||||
lastName: z.string().trim().min(1).max(80),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
companyName: z.string().trim().min(2).max(120),
|
||||
email: z.string().email(),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(8).max(128),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
||||
}).superRefine((account, ctx) => {
|
||||
if (!validateLocalizedText(account.firstName, account.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(account.lastName, account.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const companyProfileSchema = z.object({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, countryCodeField, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const companySignupSchema = z.object({
|
||||
firstName: z.string().min(1).max(80),
|
||||
lastName: z.string().min(1).max(80),
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
password: z.string().min(8).max(128),
|
||||
companyName: z.string().min(2).max(120),
|
||||
legalName: z.string().min(2).max(160),
|
||||
@@ -15,10 +16,10 @@ export const companySignupSchema = z.object({
|
||||
operatingLicenseIssuedAt: z.string().min(1).max(40),
|
||||
operatingLicenseIssuedBy: z.string().min(1).max(160),
|
||||
streetAddress: z.string().min(1).max(200),
|
||||
city: z.string().min(1).max(120),
|
||||
country: z.string().min(1).max(120),
|
||||
city: z.string().trim().min(1).max(85).transform((value) => value.normalize('NFC')),
|
||||
country: countryCodeField(),
|
||||
zipCode: z.string().min(1).max(40),
|
||||
companyPhone: z.string().min(1).max(80),
|
||||
companyPhone: contactPhoneField(),
|
||||
companyEmail: z.string().email(),
|
||||
fax: z.string().max(80).optional(),
|
||||
yearsActive: z.string().min(1).max(80),
|
||||
@@ -28,10 +29,20 @@ export const companySignupSchema = z.object({
|
||||
responsibleRole: z.string().min(1).max(120),
|
||||
responsibleIdentityNumber: z.string().min(1).max(120),
|
||||
responsibleQualification: z.string().max(200).optional(),
|
||||
responsiblePhone: z.string().min(1).max(80),
|
||||
responsiblePhone: contactPhoneField(),
|
||||
responsibleEmail: z.string().email(),
|
||||
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
|
||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
|
||||
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
|
||||
currency: z.literal('MAD'),
|
||||
}).superRefine((signup, ctx) => {
|
||||
if (!validateLocalizedText(signup.firstName, signup.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(signup.lastName, signup.preferredLanguage, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (!validateLocalizedText(signup.city, signup.preferredLanguage, 85)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['city'], message: localizedTextIssue('City') })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const employeeLoginSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
password: z.string().max(128),
|
||||
totpCode: z.string().length(6).optional(),
|
||||
})
|
||||
|
||||
export const employeeForgotPasswordSchema = z.object({
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
})
|
||||
|
||||
export const employeeLanguageSchema = z.object({
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const renterUpdateSchema = z.object({
|
||||
firstName: z.string().min(1).max(100).trim().optional(),
|
||||
lastName: z.string().min(1).max(100).trim().optional(),
|
||||
phone: z.string().max(30).trim().optional(),
|
||||
preferredLocale: z.enum(['en', 'fr', 'ar']).optional(),
|
||||
firstName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
|
||||
lastName: z.string().trim().max(50).transform((value) => value.normalize('NFC')).optional(),
|
||||
phone: contactPhoneField().optional(),
|
||||
preferredLocale: languageSchema.optional(),
|
||||
preferredCurrency: z.literal('MAD').optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
const language = data.preferredLocale ?? 'fr'
|
||||
if (data.firstName && !validateLocalizedText(data.firstName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (data.lastName && !validateLocalizedText(data.lastName, language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const renterFcmTokenSchema = z.object({
|
||||
|
||||
@@ -27,7 +27,7 @@ export const manualBillingPaymentSchema = z.object({
|
||||
currency: z.literal('MAD').default('MAD'),
|
||||
type: z.enum(['CHARGE', 'DEPOSIT']),
|
||||
method: z.enum(['CHECK', 'BANK_TRANSFER']),
|
||||
receivedAt: z.string().datetime().optional(),
|
||||
receivedAt: z.string().datetime({ offset: true }).optional(),
|
||||
reference: z.string().trim().max(120).optional(),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
idempotencyKey: z.string().uuid(),
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function findVehicleForCarplaceById(vehicleId: string) {
|
||||
}
|
||||
|
||||
export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -96,6 +97,7 @@ export async function upsertCarplaceCustomer(companyId: string, data: {
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
language: data.language,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: data.email,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, localizedTextIssue, validateLocalizedAddress, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(100).default(1),
|
||||
@@ -42,17 +43,17 @@ export const carplaceQuoteSchema = z.object({
|
||||
export const carplaceReservationSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
companySlug: z.string().trim().max(100).optional(),
|
||||
firstName: z.string().min(1).max(100),
|
||||
lastName: z.string().min(1).max(100),
|
||||
email: z.string().email(),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
// Contact info — collected at reservation time
|
||||
phone: z.string().min(1).max(30).optional(),
|
||||
phone: contactPhoneField(),
|
||||
driverAge: z.coerce.number().int().min(18).max(100).optional(),
|
||||
// Identity & license — optional at reservation, collected at pickup
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
fullAddress: z.string().min(1).max(255).optional(),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
@@ -67,6 +68,16 @@ export const carplaceReservationSchema = z.object({
|
||||
notes: z.string().max(500).optional(),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
idempotencyKey: z.string().uuid().optional(),
|
||||
}).superRefine((reservation, ctx) => {
|
||||
if (!validateLocalizedText(reservation.firstName, reservation.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(reservation.lastName, reservation.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (reservation.fullAddress && !validateLocalizedAddress(reservation.fullAddress, reservation.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
})
|
||||
|
||||
export const carplaceFunnelEventSchema = z.object({
|
||||
|
||||
@@ -339,6 +339,7 @@ export async function createCarplaceReservation(body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCarplaceCustomer(vehicle.companyId, {
|
||||
language: (body.language ?? 'fr') as 'en' | 'fr' | 'ar',
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
import { optionalTextField, optionalTitleCaseAllField, optionalUpperField, optionalEmailField, optionalPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
contactPhoneField,
|
||||
countryCodeField,
|
||||
optionalEmailField,
|
||||
optionalPhoneField,
|
||||
optionalTextField,
|
||||
optionalTitleCaseAllField,
|
||||
optionalUpperField,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
const nullableLabel = z.union([z.string().trim().max(80), z.null()]).optional()
|
||||
|
||||
export const companySchema = z.object({
|
||||
name: optionalTextField('commercialName'),
|
||||
@@ -9,34 +19,34 @@ export const companySchema = z.object({
|
||||
})
|
||||
|
||||
export const brandSchema = z.object({
|
||||
displayName: z.string().min(1).optional(),
|
||||
tagline: z.string().optional(),
|
||||
displayName: z.string().trim().min(1).max(100).optional(),
|
||||
tagline: z.string().trim().max(160).optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
publicEmail: optionalEmailField(),
|
||||
publicPhone: optionalPhoneField(),
|
||||
publicAddress: z.string().optional(),
|
||||
publicAddress: z.string().trim().max(255).optional(),
|
||||
publicCity: optionalTextField('city'),
|
||||
publicCountry: optionalTextField('country'),
|
||||
publicCountry: countryCodeField().optional(),
|
||||
websiteUrl: z.string().url().optional(),
|
||||
whatsappNumber: z.string().optional(),
|
||||
whatsappNumber: contactPhoneField().optional(),
|
||||
defaultLocale: z.enum(['ar', 'en', 'fr']).optional(),
|
||||
defaultCurrency: z.literal('MAD').optional(),
|
||||
isListedOnCarplace: z.boolean().optional(),
|
||||
homePageConfig: z.object({
|
||||
heroTitle: z.union([z.string(), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string(), z.null()]).optional(),
|
||||
viewOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
viewPricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactCompanyLabel: z.union([z.string(), z.null()]).optional(),
|
||||
activeOffersTitle: z.union([z.string(), z.null()]).optional(),
|
||||
seeAllOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
noActiveOffersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string(), z.null()]).optional(),
|
||||
viewVehicleLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingEyebrow: z.union([z.string(), z.null()]).optional(),
|
||||
pricingTitle: z.union([z.string(), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string(), z.null()]).optional(),
|
||||
heroTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
heroDescription: z.union([z.string().trim().max(500), z.null()]).optional(),
|
||||
viewOffersLabel: nullableLabel,
|
||||
viewPricingLabel: nullableLabel,
|
||||
contactCompanyLabel: nullableLabel,
|
||||
activeOffersTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
seeAllOffersLabel: nullableLabel,
|
||||
noActiveOffersLabel: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
publishedVehiclesTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
viewVehicleLabel: nullableLabel,
|
||||
pricingEyebrow: nullableLabel,
|
||||
pricingTitle: z.union([z.string().trim().max(120), z.null()]).optional(),
|
||||
pricingDescription: z.union([z.string().trim().max(500), z.null()]).optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showPricing: z.boolean().optional(),
|
||||
@@ -52,14 +62,14 @@ export const brandSchema = z.object({
|
||||
}).optional(),
|
||||
}).optional(),
|
||||
menuConfig: z.object({
|
||||
aboutLabel: z.union([z.string(), z.null()]).optional(),
|
||||
vehiclesLabel: z.union([z.string(), z.null()]).optional(),
|
||||
offersLabel: z.union([z.string(), z.null()]).optional(),
|
||||
pricingLabel: z.union([z.string(), z.null()]).optional(),
|
||||
blogLabel: z.union([z.string(), z.null()]).optional(),
|
||||
contactLabel: z.union([z.string(), z.null()]).optional(),
|
||||
bookCarLabel: z.union([z.string(), z.null()]).optional(),
|
||||
siteNavigationLabel: z.union([z.string(), z.null()]).optional(),
|
||||
aboutLabel: nullableLabel,
|
||||
vehiclesLabel: nullableLabel,
|
||||
offersLabel: nullableLabel,
|
||||
pricingLabel: nullableLabel,
|
||||
blogLabel: nullableLabel,
|
||||
contactLabel: nullableLabel,
|
||||
bookCarLabel: nullableLabel,
|
||||
siteNavigationLabel: nullableLabel,
|
||||
showAbout: z.boolean().optional(),
|
||||
showVehicles: z.boolean().optional(),
|
||||
showOffers: z.boolean().optional(),
|
||||
@@ -96,20 +106,20 @@ export const brandSchema = z.object({
|
||||
export const contractSettingsSchema = z.object({
|
||||
legalName: optionalTitleCaseAllField('legalCompanyName'),
|
||||
registrationNumber: optionalUpperField('commercialRegistry'),
|
||||
taxId: z.string().optional(),
|
||||
terms: z.string().optional(),
|
||||
fuelPolicy: z.string().optional(),
|
||||
depositPolicy: z.string().optional(),
|
||||
lateFeePolicy: z.string().optional(),
|
||||
damagePolicy: z.string().optional(),
|
||||
additionalDriverPolicy: z.string().optional(),
|
||||
contractFooterNote: z.string().optional(),
|
||||
invoiceFooterNote: z.string().optional(),
|
||||
taxId: z.string().trim().max(30).optional(),
|
||||
terms: z.string().trim().max(4000).optional(),
|
||||
fuelPolicy: z.string().trim().max(4000).optional(),
|
||||
depositPolicy: z.string().trim().max(2000).optional(),
|
||||
lateFeePolicy: z.string().trim().max(2000).optional(),
|
||||
damagePolicy: z.string().trim().max(4000).optional(),
|
||||
additionalDriverPolicy: z.string().trim().max(4000).optional(),
|
||||
contractFooterNote: z.string().trim().max(500).optional(),
|
||||
invoiceFooterNote: z.string().trim().max(500).optional(),
|
||||
signatureRequired: z.boolean().optional(),
|
||||
showTax: z.boolean().optional(),
|
||||
taxLabel: z.string().optional(),
|
||||
taxLabel: z.string().trim().max(80).optional(),
|
||||
fuelPolicyType: z.enum(['FULL_TO_FULL', 'FULL_TO_EMPTY', 'SAME_TO_SAME', 'PREPAID', 'FREE']).optional(),
|
||||
fuelPolicyNote: z.string().optional(),
|
||||
fuelPolicyNote: z.string().trim().max(1000).optional(),
|
||||
fuelChargePerLiter: z.number().int().optional(),
|
||||
fuelShortfallFee: z.number().int().optional(),
|
||||
lateFeePerHour: z.number().int().optional(),
|
||||
@@ -119,8 +129,8 @@ export const contractSettingsSchema = z.object({
|
||||
})
|
||||
|
||||
export const insurancePolicySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
type: z.enum(['CDW', 'SCDW', 'THEFT', 'THIRD_PARTY', 'FULL', 'BASIC', 'ROADSIDE', 'PERSONAL', 'CUSTOM']),
|
||||
chargeType: z.enum(['PER_DAY', 'PER_RENTAL', 'PERCENTAGE_OF_RENTAL']),
|
||||
chargeValue: z.number().int().min(0),
|
||||
@@ -130,14 +140,14 @@ export const insurancePolicySchema = z.object({
|
||||
})
|
||||
|
||||
export const pricingRuleSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
type: z.enum(['SURCHARGE', 'DISCOUNT']),
|
||||
condition: z.enum(['AGE_LESS_THAN', 'AGE_GREATER_THAN', 'LICENSE_YEARS_LESS_THAN', 'LICENSE_YEARS_GREATER_THAN']),
|
||||
conditionValue: z.number().int(),
|
||||
adjustmentType: z.enum(['PERCENTAGE', 'FLAT_PER_DAY', 'FLAT_TOTAL']),
|
||||
adjustmentValue: z.number().int(),
|
||||
isActive: z.boolean().default(true),
|
||||
description: z.string().optional(),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
})
|
||||
|
||||
export const accountingSettingsSchema = z.object({
|
||||
|
||||
@@ -15,8 +15,8 @@ export const createSchema = z.object({
|
||||
customerId: z.string().optional(),
|
||||
severity: z.enum(SEVERITIES).default('LEVEL_1'),
|
||||
category: z.enum(CATEGORIES),
|
||||
subject: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
subject: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -24,10 +24,10 @@ export const updateSchema = z.object({
|
||||
status: z.enum(STATUSES).optional(),
|
||||
severity: z.enum(SEVERITIES).optional(),
|
||||
category: z.enum(CATEGORIES).optional(),
|
||||
subject: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
resolution: z.string().optional(),
|
||||
subject: z.string().trim().min(1).max(120).optional(),
|
||||
description: z.string().trim().max(2000).optional(),
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
resolution: z.string().trim().max(2000).optional(),
|
||||
assignedTo: z.string().optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function buildCustomerSelect(includeLicenseImageUrl: boolean) {
|
||||
id: true,
|
||||
companyId: true,
|
||||
renterId: true,
|
||||
language: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parseBody, parseQuery, parseParams } from '../../http/validate'
|
||||
import { ok, created } from '../../http/respond'
|
||||
import { imageUpload, assertImageFile } from '../../http/upload'
|
||||
import * as service from './customer.service'
|
||||
import { customerSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
import { customerSchema, customerUpdateSchema, listQuerySchema, approveLicenseSchema, flagSchema, idParamSchema } from './customer.schemas'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -47,7 +47,7 @@ router.get('/:id', async (req, res, next) => {
|
||||
router.patch('/:id', requireSubscriptionWrite, requireRole('MANAGER'), async (req, res, next) => {
|
||||
try {
|
||||
const { id } = parseParams(idParamSchema, req)
|
||||
const body = parseBody(customerSchema.partial(), req)
|
||||
const body = parseBody(customerUpdateSchema, req)
|
||||
const customer = await service.updateCustomer(id, req.companyId, body)
|
||||
ok(res, customer)
|
||||
} catch (err) { next(err) }
|
||||
|
||||
@@ -1,28 +1,60 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, emailField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
countryCodeField,
|
||||
contactPhoneField,
|
||||
emailField,
|
||||
languageSchema,
|
||||
localizedTextIssue,
|
||||
optionalAlphanumericIdField,
|
||||
optionalCountryCodeField,
|
||||
validateLocalizedAddress,
|
||||
validateLocalizedText,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).max(10000).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const customerSchema = z.object({
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
const customerBaseSchema = z.object({
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: emailField(),
|
||||
phone: optionalContactPhoneField(),
|
||||
driverLicense: optionalUpperField('driverLicenseNumber'),
|
||||
phone: contactPhoneField(),
|
||||
driverLicense: optionalAlphanumericIdField(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
address: z.record(z.unknown()).optional(),
|
||||
nationality: countryCodeField(),
|
||||
address: z.record(z.unknown()),
|
||||
notes: z.string().max(2000).trim().optional(),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
licenseCountry: optionalTextField('country'),
|
||||
licenseNumber: optionalUpperField('driverLicenseNumber'),
|
||||
licenseCategory: optionalUpperField('licenseCategory'),
|
||||
licenseCountry: optionalCountryCodeField(),
|
||||
licenseNumber: optionalAlphanumericIdField(),
|
||||
licenseCategory: z.string().trim().min(1).max(30).optional(),
|
||||
})
|
||||
|
||||
function validateCustomerLanguageFields(customer: Partial<z.infer<typeof customerBaseSchema>>, ctx: z.RefinementCtx) {
|
||||
if (!customer.language) return
|
||||
if (customer.firstName !== undefined && !validateLocalizedText(customer.firstName, customer.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (customer.lastName !== undefined && !validateLocalizedText(customer.lastName, customer.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
const address = customer.address
|
||||
if (address !== undefined) {
|
||||
if (typeof address.fullAddress !== 'string') {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['address', 'fullAddress'], message: 'Address is required' })
|
||||
} else if (!validateLocalizedAddress(address.fullAddress, customer.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['address', 'fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const customerSchema = customerBaseSchema.superRefine(validateCustomerLanguageFields)
|
||||
export const customerUpdateSchema = customerBaseSchema.partial().superRefine(validateCustomerLanguageFields)
|
||||
|
||||
export const listQuerySchema = paginationSchema.extend({
|
||||
q: z.string().max(100).optional(),
|
||||
search: z.string().max(100).optional(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
import { requiredAlphanumericIdField } from '../../lib/zodValidation'
|
||||
|
||||
/** Request body for POST /api/v1/licenses/validate */
|
||||
export const validateLicenseSchema = z.object({
|
||||
expiration_date: z.string().min(1, 'expiration_date is required'),
|
||||
license_number: z.string().max(100).optional(),
|
||||
license_number: requiredAlphanumericIdField().optional(),
|
||||
state: z.string().length(2).optional(),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const offerSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
termsAndConds: z.string().optional(),
|
||||
title: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().max(1000).optional(),
|
||||
termsAndConds: z.string().trim().max(2000).optional(),
|
||||
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY', 'SPECIAL_RATE']),
|
||||
discountValue: z.number().int().min(0),
|
||||
specialRate: z.number().int().optional(),
|
||||
@@ -11,7 +11,7 @@ export const offerSchema = z.object({
|
||||
categories: z.array(z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'])).default([]),
|
||||
minRentalDays: z.number().int().optional(),
|
||||
maxRentalDays: z.number().int().optional(),
|
||||
promoCode: z.string().optional(),
|
||||
promoCode: z.string().trim().max(30).regex(/^[A-Z0-9_-]+$/, 'Code must be uppercase A-Z, 0-9, _ or -').optional(),
|
||||
maxRedemptions: z.number().int().optional(),
|
||||
validFrom: z.string().datetime(),
|
||||
validUntil: z.string().datetime(),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const manualPaymentSchema = z.object({
|
||||
|
||||
export const refundSchema = z.object({
|
||||
amount: z.number().int().positive().optional(),
|
||||
reason: z.string().optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export const reservationParamSchema = z.object({
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
import { textField, optionalTextField, optionalEmailField, upperField, optionalUpperField, optionalContactPhoneField } from '../../lib/zodValidation'
|
||||
import {
|
||||
emailField,
|
||||
languageSchema,
|
||||
localizedTextIssue,
|
||||
optionalAlphanumericIdField,
|
||||
optionalContactPhoneField,
|
||||
optionalCountryCodeField,
|
||||
validateLocalizedText,
|
||||
} from '../../lib/zodValidation'
|
||||
|
||||
const rentalPaymentModeSchema = z.enum(['BANK_TRANSFER', 'CHECK'])
|
||||
|
||||
export const additionalDriverSchema = z.object({
|
||||
firstName: textField('name'),
|
||||
lastName: textField('name'),
|
||||
email: optionalEmailField(),
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: emailField().optional(),
|
||||
phone: optionalContactPhoneField(),
|
||||
driverLicense: upperField('driverLicenseNumber'),
|
||||
licenseExpiry: z.string().datetime().optional(),
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: optionalTextField('nationality'),
|
||||
driverLicense: optionalAlphanumericIdField().refine((value) => value !== undefined, { message: 'This field is required' }),
|
||||
licenseExpiry: z.string().datetime({ offset: true }).optional(),
|
||||
licenseIssuedAt: z.string().datetime({ offset: true }).optional(),
|
||||
dateOfBirth: z.string().datetime({ offset: true }).optional(),
|
||||
nationality: optionalCountryCodeField(),
|
||||
}).superRefine((driver, ctx) => {
|
||||
if (!validateLocalizedText(driver.firstName, driver.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(driver.lastName, driver.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (driver.licenseIssuedAt && driver.licenseExpiry && new Date(driver.licenseExpiry) <= new Date(driver.licenseIssuedAt)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['licenseExpiry'], message: 'Expiry date must be after issue date' })
|
||||
}
|
||||
})
|
||||
|
||||
export const inspectionSchema = z.object({
|
||||
@@ -36,10 +55,10 @@ export const inspectionSchema = z.object({
|
||||
export const createSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
customerId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
pickupLocation: optionalTextField('pickupLocation'),
|
||||
returnLocation: optionalTextField('returnLocation'),
|
||||
startDate: z.string().datetime({ offset: true }),
|
||||
endDate: z.string().datetime({ offset: true }),
|
||||
pickupLocation: z.string().trim().min(1).max(255).optional(),
|
||||
returnLocation: z.string().trim().min(1).max(255).optional(),
|
||||
offerId: z.string().cuid().optional(),
|
||||
promoCodeUsed: z.string().optional(),
|
||||
depositAmount: z.number().int().min(0).default(0),
|
||||
@@ -50,15 +69,19 @@ export const createSchema = z.object({
|
||||
contractFields: z.record(z.string().max(120), z.string().max(500).nullable()).optional(),
|
||||
selectedInsurancePolicyIds: z.array(z.string()).default([]),
|
||||
additionalDrivers: z.array(additionalDriverSchema).default([]),
|
||||
}).superRefine((reservation, ctx) => {
|
||||
if (new Date(reservation.endDate) <= new Date(reservation.startDate)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['endDate'], message: 'End date must be after start date' })
|
||||
}
|
||||
})
|
||||
|
||||
export const contractFieldsSchema = z.record(z.string().max(120), z.string().max(500).nullable()).optional()
|
||||
|
||||
export const updateSchema = z.object({
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
pickupLocation: optionalTextField('pickupLocation').nullable(),
|
||||
returnLocation: optionalTextField('returnLocation').nullable(),
|
||||
startDate: z.string().datetime({ offset: true }).optional(),
|
||||
endDate: z.string().datetime({ offset: true }).optional(),
|
||||
pickupLocation: z.string().trim().min(1).max(255).optional().nullable(),
|
||||
returnLocation: z.string().trim().min(1).max(255).optional().nullable(),
|
||||
depositAmount: z.number().int().min(0).optional(),
|
||||
notes: z.string().optional().nullable(),
|
||||
paymentMode: rentalPaymentModeSchema.optional().nullable(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const replySchema = z.object({
|
||||
companyReply: z.string().min(1),
|
||||
companyReply: z.string().trim().min(1).max(2000),
|
||||
})
|
||||
|
||||
export const listQuerySchema = z.object({
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function findOfferByPromoCode(companyId: string, code: string) {
|
||||
}
|
||||
|
||||
export async function upsertCustomer(companyId: string, data: {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
email: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
@@ -79,6 +80,7 @@ export async function upsertCustomer(companyId: string, data: {
|
||||
: undefined
|
||||
|
||||
const payload = {
|
||||
language: data.language,
|
||||
firstName: data.firstName,
|
||||
lastName: data.lastName,
|
||||
email: normalizedEmail,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
import { contactPhoneField, localizedTextIssue, validateLocalizedAddress, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const slugParamSchema = z.object({ slug: z.string() })
|
||||
export const bookingParamSchema = z.object({ slug: z.string(), id: z.string() })
|
||||
@@ -15,10 +16,11 @@ export const bookSchema = z.object({
|
||||
vehicleId: z.string().cuid(),
|
||||
startDate: z.string().datetime(),
|
||||
endDate: z.string().datetime(),
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
email: z.string().email(),
|
||||
phone: z.string().min(1).max(30),
|
||||
language: z.enum(['en', 'fr', 'ar']).default('fr'),
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
phone: contactPhoneField(),
|
||||
pickupLocation: z.string().trim().min(1).max(100),
|
||||
returnLocation: z.string().trim().min(1).max(100),
|
||||
driverLicense: z.string().min(1).max(50).optional(),
|
||||
@@ -27,7 +29,7 @@ export const bookSchema = z.object({
|
||||
licenseIssuedAt: z.string().datetime().optional(),
|
||||
nationality: z.string().min(1).max(100).optional(),
|
||||
identityDocumentNumber: z.string().min(1).max(100).optional(),
|
||||
fullAddress: z.string().min(1).max(500).optional(),
|
||||
fullAddress: z.string().min(1).max(255).optional(),
|
||||
licenseCountry: z.string().min(1).max(100).optional(),
|
||||
licenseNumber: z.string().min(1).max(50).optional(),
|
||||
licenseCategory: z.string().min(1).max(20).optional(),
|
||||
@@ -48,6 +50,16 @@ export const bookSchema = z.object({
|
||||
dateOfBirth: z.string().datetime().optional(),
|
||||
nationality: z.string().optional(),
|
||||
})).default([]),
|
||||
}).superRefine((booking, ctx) => {
|
||||
if (!validateLocalizedText(booking.firstName, booking.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(booking.lastName, booking.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
if (booking.fullAddress && !validateLocalizedAddress(booking.fullAddress, booking.language, 255)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['fullAddress'], message: localizedTextIssue('Address') })
|
||||
}
|
||||
})
|
||||
|
||||
export const contactSchema = z.object({
|
||||
|
||||
@@ -132,6 +132,7 @@ export async function createBooking(slug: string, body: {
|
||||
}
|
||||
|
||||
const customer = await repo.upsertCustomer(company.id, {
|
||||
language: body.language ?? 'fr',
|
||||
email: body.email,
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
|
||||
@@ -25,7 +25,7 @@ export const startTrialSchema = z.object({
|
||||
|
||||
export const cancelSchema = z.object({
|
||||
mode: z.enum(['period_end', 'immediate']).default('period_end'),
|
||||
reason: z.string().max(500).optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export const manualCheckoutSchema = z.object({
|
||||
@@ -56,7 +56,7 @@ export const manualPaymentDocumentFieldsSchema = z.object({
|
||||
export const billingContactInputSchema = z.object({
|
||||
id: z.string().min(1).optional(),
|
||||
employeeId: z.string().min(1).nullable().optional(),
|
||||
email: z.string().email().max(255).trim().toLowerCase(),
|
||||
email: z.string().email().max(254).trim().toLowerCase(),
|
||||
locale: localeEnum.nullable().optional(),
|
||||
isPrimary: z.boolean(),
|
||||
receivePaymentNotices: z.boolean().default(true),
|
||||
@@ -98,7 +98,7 @@ export const acceptUpgradeQuoteSchema = z.object({
|
||||
})
|
||||
|
||||
export const cancelUpgradeRequestSchema = z.object({
|
||||
reason: z.string().max(500).optional(),
|
||||
reason: z.string().trim().max(500).optional(),
|
||||
})
|
||||
|
||||
export { manualMethodEnum, localeEnum, referenceSchema }
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { z } from 'zod'
|
||||
import { languageSchema, localizedTextIssue, validateLocalizedText } from '../../lib/zodValidation'
|
||||
|
||||
export const inviteSchema = z.object({
|
||||
firstName: z.string().min(1).max(64),
|
||||
lastName: z.string().min(1).max(64),
|
||||
email: z.string().email(),
|
||||
language: languageSchema,
|
||||
firstName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
lastName: z.string().trim().min(1).max(50).transform((value) => value.normalize('NFC')),
|
||||
email: z.string().trim().email().max(254).transform((value) => value.toLowerCase()),
|
||||
role: z.enum(['MANAGER', 'AGENT']),
|
||||
}).superRefine((invite, ctx) => {
|
||||
if (!validateLocalizedText(invite.firstName, invite.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['firstName'], message: localizedTextIssue('First name') })
|
||||
}
|
||||
if (!validateLocalizedText(invite.lastName, invite.language, 50)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['lastName'], message: localizedTextIssue('Last name') })
|
||||
}
|
||||
})
|
||||
|
||||
export const roleSchema = z.object({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { sanitizeAndFormat } from '../../lib/inputValidation'
|
||||
import { carTextField, upperField, optionalUpperField } from '../../lib/zodValidation'
|
||||
import { carTextField, upperField, vinField } from '../../lib/zodValidation'
|
||||
|
||||
export const vehicleSchema = z.object({
|
||||
make: carTextField('carMark'),
|
||||
@@ -8,7 +8,7 @@ export const vehicleSchema = z.object({
|
||||
year: z.number().int().min(1990).max(new Date().getFullYear() + 1),
|
||||
color: z.string().optional().default('').transform((v: string) => v ? sanitizeAndFormat(v, 'carColor') : ''),
|
||||
licensePlate: upperField('licensePlate'),
|
||||
vin: optionalUpperField('vin'),
|
||||
vin: vinField(),
|
||||
category: z.enum(['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK']),
|
||||
seats: z.number().int().min(1).max(20).default(5),
|
||||
transmission: z.enum(['AUTOMATIC', 'MANUAL']).default('AUTOMATIC'),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { validateLicense } from './licenseValidationService'
|
||||
type AdditionalDriverCharge = 'PER_DAY' | 'FLAT' | 'FREE'
|
||||
|
||||
export interface AdditionalDriverInput {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
email?: string
|
||||
@@ -61,6 +62,7 @@ export async function applyAdditionalDriversToReservation(
|
||||
return {
|
||||
reservationId,
|
||||
companyId,
|
||||
language: driver.language,
|
||||
firstName: driver.firstName,
|
||||
lastName: driver.lastName,
|
||||
email: driver.email ?? null,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { hashPublicAccessToken } from '../security/publicAccessTokens'
|
||||
const INVITE_TOKEN_TTL_MINUTES = 60 * 24 * 7
|
||||
|
||||
export interface InvitePayload {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
@@ -170,7 +171,7 @@ export async function inviteEmployee(companyId: string, inviterId: string, paylo
|
||||
const rawToken = crypto.randomBytes(32).toString('hex')
|
||||
const tokenHash = hashPublicAccessToken(rawToken)
|
||||
const expiresAt = new Date(Date.now() + INVITE_TOKEN_TTL_MINUTES * 60 * 1000)
|
||||
const locale = coerceNotificationLocale(company.brand?.defaultLocale)
|
||||
const locale = coerceNotificationLocale(payload.language)
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
|
||||
@@ -42,13 +42,13 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
|
||||
{!compact ? <h2 className="text-xl font-black text-blue-950 dark:text-white">{copy.search.title}</h2> : null}
|
||||
<div className={fieldsGridClass}>
|
||||
<SearchField icon={MapPin} label={copy.search.pickup}>
|
||||
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
||||
<input list="carplace-cities" name="pickupLocation" defaultValue={initial.pickupLocation ?? ''} placeholder={copy.search.locationPlaceholder} required maxLength={255} className="carplace-input" />
|
||||
</SearchField>
|
||||
<SearchField icon={MapPin} label={copy.search.returnLocation}>
|
||||
{dropoffMode === 'same' ? (
|
||||
<div className="carplace-input flex items-center text-stone-500 dark:text-slate-400">{copy.search.sameReturn}</div>
|
||||
) : (
|
||||
<input list="carplace-cities" name="dropoffLocation" defaultValue={initial.dropoffLocation ?? ''} placeholder={copy.search.locationPlaceholder} required className="carplace-input" />
|
||||
<input list="carplace-cities" name="dropoffLocation" defaultValue={initial.dropoffLocation ?? ''} placeholder={copy.search.locationPlaceholder} required maxLength={255} className="carplace-input" />
|
||||
)}
|
||||
</SearchField>
|
||||
<SearchField icon={CalendarDays} label={copy.search.pickupDate}>
|
||||
@@ -86,7 +86,7 @@ export default function CarplaceSearchForm({ cities, copy, initial = {}, compact
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">
|
||||
{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span>
|
||||
<input name="promoCode" defaultValue={initial.promoCode ?? ''} className="carplace-input" />
|
||||
<input name="promoCode" defaultValue={initial.promoCode ?? ''} maxLength={30} pattern="^[A-Z0-9_-]+$" className="carplace-input" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,6 +30,13 @@ type FormState = Required<Omit<BookingInitialValues, 'promoCode'>> & {
|
||||
consent: boolean
|
||||
}
|
||||
|
||||
const MOROCCAN_PHONE_PATTERN = '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$'
|
||||
const LOCALIZED_NAME_PATTERNS: Record<CarplaceLanguage, string> = {
|
||||
en: "^[A-Za-z\\s'-]+$",
|
||||
fr: "^[A-Za-zÀ-ÖØ-öø-ÿŒœ\\s'-]+$",
|
||||
ar: "^[\\u0600-\\u06FF\\u0750-\\u077F\\s،؛؟ـ'-]+$",
|
||||
}
|
||||
|
||||
export default function ProgressiveBookingFlow({
|
||||
vehicleId,
|
||||
companySlug,
|
||||
@@ -138,6 +145,12 @@ export default function ProgressiveBookingFlow({
|
||||
setError(copy.booking.error)
|
||||
return
|
||||
}
|
||||
const namePattern = new RegExp(LOCALIZED_NAME_PATTERNS[language], 'u')
|
||||
const phonePattern = /^(?:(?:\+|00)212|0)\s?[5-7](?:\s?\d){8}$/
|
||||
if (!namePattern.test(form.firstName.trim()) || !namePattern.test(form.lastName.trim()) || !phonePattern.test(form.phone.trim())) {
|
||||
setError(copy.booking.error)
|
||||
return
|
||||
}
|
||||
setStep(3)
|
||||
void track('contact_details_started')
|
||||
}
|
||||
@@ -256,7 +269,7 @@ export default function ProgressiveBookingFlow({
|
||||
</BookingField>
|
||||
</div>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.age}<select className="carplace-input" value={form.driverAge} onChange={(event) => update('driverAge', event.target.value)}><option value="18">18+</option><option value="21">21+</option><option value="23">23+</option><option value="25">25+</option><option value="30">30+</option></select></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span><input className="carplace-input" value={form.promoCode} onChange={(event) => update('promoCode', event.target.value)} /></label>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.search.promo} <span className="font-normal text-stone-400">({copy.search.optional})</span><input className="carplace-input" value={form.promoCode} maxLength={30} pattern="^[A-Z0-9_-]+$" onChange={(event) => update('promoCode', event.target.value.toUpperCase())} /></label>
|
||||
{quote ? <QuoteBox quote={quote} language={language} copy={copy} /> : null}
|
||||
<button type="button" className="carplace-primary-button justify-center" onClick={validateTrip} disabled={loading}>{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ChevronRight className="h-4 w-4 rtl:rotate-180" />}{copy.actions.continue}</button>
|
||||
</div>
|
||||
@@ -266,11 +279,11 @@ export default function ProgressiveBookingFlow({
|
||||
<div className="mt-5 grid gap-4">
|
||||
<p className="text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.booking.contactIntro}</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<BookingField label={copy.booking.firstName} icon={UserRound}><input className="carplace-input" value={form.firstName} onChange={(event) => update('firstName', event.target.value)} autoComplete="given-name" /></BookingField>
|
||||
<BookingField label={copy.booking.lastName} icon={UserRound}><input className="carplace-input" value={form.lastName} onChange={(event) => update('lastName', event.target.value)} autoComplete="family-name" /></BookingField>
|
||||
<BookingField label={copy.booking.firstName} icon={UserRound}><input className="carplace-input" value={form.firstName} maxLength={50} pattern={LOCALIZED_NAME_PATTERNS[language]} dir={language === 'ar' ? 'rtl' : 'ltr'} onChange={(event) => update('firstName', event.target.value)} autoComplete="given-name" /></BookingField>
|
||||
<BookingField label={copy.booking.lastName} icon={UserRound}><input className="carplace-input" value={form.lastName} maxLength={50} pattern={LOCALIZED_NAME_PATTERNS[language]} dir={language === 'ar' ? 'rtl' : 'ltr'} onChange={(event) => update('lastName', event.target.value)} autoComplete="family-name" /></BookingField>
|
||||
</div>
|
||||
<BookingField label={copy.booking.email} icon={Mail}><input type="email" className="carplace-input" value={form.email} onChange={(event) => update('email', event.target.value)} autoComplete="email" /></BookingField>
|
||||
<BookingField label={copy.booking.phone} icon={Phone}><input type="tel" className="carplace-input" value={form.phone} onChange={(event) => update('phone', event.target.value)} autoComplete="tel" /></BookingField>
|
||||
<BookingField label={copy.booking.email} icon={Mail}><input type="email" className="carplace-input" value={form.email} maxLength={254} onChange={(event) => update('email', event.target.value)} autoComplete="email" /></BookingField>
|
||||
<BookingField label={copy.booking.phone} icon={Phone}><input type="tel" className="carplace-input" value={form.phone} maxLength={20} pattern={MOROCCAN_PHONE_PATTERN} onChange={(event) => update('phone', event.target.value)} autoComplete="tel" /></BookingField>
|
||||
<label className="grid gap-1 text-xs font-bold text-stone-600 dark:text-slate-300">{copy.booking.notes} <span className="font-normal text-stone-400">({copy.search.optional})</span><textarea className="carplace-input min-h-24 resize-y" value={form.notes} onChange={(event) => update('notes', event.target.value)} placeholder={copy.booking.notesPlaceholder} maxLength={500} /></label>
|
||||
<div className="grid grid-cols-2 gap-2"><button type="button" className="carplace-secondary-button justify-center" onClick={() => setStep(1)}><ChevronLeft className="h-4 w-4 rtl:rotate-180" />{copy.actions.back}</button><button type="button" className="carplace-primary-button justify-center" onClick={validateContact}><ChevronRight className="h-4 w-4 rtl:rotate-180" />{copy.actions.continue}</button></div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TeamStats {
|
||||
}
|
||||
|
||||
export interface InvitePayload {
|
||||
language: 'en' | 'fr' | 'ar'
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
|
||||
@@ -210,6 +210,7 @@ function ResetPasswordContent({ locale }: { locale: Locale }) {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
@@ -243,6 +244,7 @@ function ResetPasswordContent({ locale }: { locale: Locale }) {
|
||||
type={showConfirm ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
maxLength={128}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
|
||||
@@ -268,6 +268,7 @@ export function SignInForm({
|
||||
id="signin-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
maxLength={128}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
|
||||
@@ -22,11 +22,15 @@ export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(function T
|
||||
ref,
|
||||
) {
|
||||
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
|
||||
const maxLength = props.maxLength ?? (type === 'email' ? 254 : type === 'tel' ? 20 : undefined);
|
||||
const pattern = props.pattern ?? (type === 'tel' ? '^(?:(?:\\+|00)212|0)\\s?[5-7](?:\\s?\\d){8}$' : undefined);
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
dir={dir}
|
||||
maxLength={maxLength}
|
||||
pattern={pattern}
|
||||
className={classNames(styles.control, className)}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={describedBy}
|
||||
|
||||
@@ -54,3 +54,68 @@ Only touch the files responsible for these fields: the form component(s), the AP
|
||||
- Confirm each rule above has a corresponding check in frontend AND backend (list them field by field in your report).
|
||||
- Confirm existing tests still pass; add new tests only for the fields touched here.
|
||||
- Report any field where the existing DB column type/length conflicts with the rule above, rather than silently changing it.
|
||||
|
||||
|
||||
# Task: Add Language-Aware Input Handling (EN / FR / AR)
|
||||
|
||||
## Objective
|
||||
Each record has ONE selected language (EN, FR, or AR). Free-text fields are typed and stored in that language's script. Add a `language` field to the record, validate free-text fields against the script rules for the selected language, and handle RTL display for Arabic. This extends `input-validation-plan.md` — do not redo work already covered there; only add what's described here.
|
||||
|
||||
## Scope
|
||||
Touch only: the form component(s) already handling these fields, the language selector (add one if it doesn't exist), the validation schema from the previous task, the API endpoint(s), and the DB column for storing the selected language. Do not touch fields/logic unrelated to language handling.
|
||||
|
||||
---
|
||||
|
||||
## 1. Data model change
|
||||
- Add a `language` column/field to the record: enum, one of `en`, `fr`, `ar`. Required, no default — must be explicitly selected (unless the project already has a default locale convention; ask if unsure).
|
||||
- This is a single value per record, not per field. All free-text fields in that record are assumed to be in this language/script.
|
||||
|
||||
## 2. Which fields are affected
|
||||
Only **free-text, human-language fields** are language/script-dependent:
|
||||
- firstName, lastName, address, city
|
||||
|
||||
These are **not** language-dependent — keep exactly as specified in `input-validation-plan.md`, do not add script validation to them:
|
||||
- email, phoneNumber, driverLicense, idPassport, vinNumber, amount, all dates, nationality, country
|
||||
|
||||
`nationality` and `country` stay as ISO codes regardless of selected language — only their *displayed label* is translated (a UI/i18n concern, not a data validation concern). Do not store translated country names as the value.
|
||||
|
||||
## 3. Script validation per language
|
||||
Update the charset rule for firstName, lastName, address, city based on the record's `language` value:
|
||||
|
||||
| Language | Allowed script | Notes |
|
||||
|---|---|---|
|
||||
| en | Latin (a-z, A-Z), spaces, hyphens, apostrophes | Same as current rule in input-validation-plan.md |
|
||||
| fr | Latin + French diacritics (à, â, ç, é, è, ê, ë, î, ï, ô, œ, ù, û, ü, ÿ, etc.), spaces, hyphens, apostrophes | Do not reject accented characters |
|
||||
| ar | Arabic script (Unicode range `\u0600-\u06FF` and `\u0750-\u077F` for extended Arabic), spaces, standard Arabic punctuation | Do not force Latin transliteration |
|
||||
|
||||
Implement this as a per-language regex/charset map, not a single hardcoded regex — so a future 4th language is a config addition, not a rewrite. Length limits (50 for names, 255 for address, 85 for city) stay the same as `input-validation-plan.md` regardless of language — do not add or remove digits from those limits.
|
||||
|
||||
Do not attempt to auto-detect the language from the text itself — the `language` field is user-selected and is the source of truth for which validation rule applies.
|
||||
|
||||
## 4. Frontend
|
||||
- Add a language selector if one doesn't already exist (dropdown or toggle: EN / FR / عربي).
|
||||
- When `ar` is selected, apply `dir="rtl"` to the affected text inputs (firstName, lastName, address, city) and their containing form section. Do not flip the entire page layout unless explicitly asked — scope this to the form only.
|
||||
- Switch the input validation pattern (`pattern` attribute / live validation) to match the table above based on the current language selection.
|
||||
- If the project already has an i18n/translation library in use (e.g. i18next, react-intl), use it for labels and error messages. Do not introduce a new i18n library if one already exists.
|
||||
|
||||
## 5. Backend
|
||||
- Validate `language` is one of `en`/`fr`/`ar`.
|
||||
- Apply the matching charset rule from the table above to firstName, lastName, address, city based on the submitted `language` value — reject if the text doesn't match the selected language's script (don't silently strip characters).
|
||||
- Store the text as submitted (UTF-8), no transliteration or normalization beyond standard Unicode NFC normalization (consistent with the rest of the validation plan).
|
||||
|
||||
## 6. Database
|
||||
- Ensure the column encoding/collation supports UTF-8 fully (`utf8mb4` if MySQL; Postgres is UTF-8 by default) — Arabic and accented French characters will break under older `utf8`/`latin1` collations. Only change this if it's actually currently a problem; check first, don't assume.
|
||||
- Add the `language` column via migration if it doesn't exist. Do not alter unrelated columns in the same migration.
|
||||
|
||||
## Explicitly out of scope (ask before doing)
|
||||
- Translating the actual stored data between languages (e.g. auto-translating an English name to Arabic) — this task is about validating/storing what the user typed, not translation.
|
||||
- Full-page RTL layout changes beyond the form fields listed.
|
||||
- Adding a new i18n library if the project doesn't already have one — flag and ask which one to use.
|
||||
- Any language beyond en/fr/ar.
|
||||
- Changing how nationality/country are stored (they remain ISO codes).
|
||||
|
||||
## Validation before reporting done
|
||||
- Confirm each of firstName/lastName/address/city is validated against the correct script for the selected `language` value, in both frontend and backend.
|
||||
- Confirm `language` is required and validated as an enum.
|
||||
- Confirm DB encoding supports Arabic/French characters (test by saving a record with Arabic text and reading it back unchanged).
|
||||
- Confirm unrelated fields (email, phone, VIN, dates, amount, nationality, country) are untouched by this change.
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
ALTER TABLE "customers"
|
||||
ADD COLUMN "language" VARCHAR(2) NOT NULL DEFAULT 'en';
|
||||
|
||||
ALTER TABLE "customers"
|
||||
ALTER COLUMN "language" DROP DEFAULT,
|
||||
ALTER COLUMN "firstName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "lastName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "email" TYPE VARCHAR(254),
|
||||
ALTER COLUMN "phone" TYPE VARCHAR(20),
|
||||
ALTER COLUMN "driverLicense" TYPE VARCHAR(30),
|
||||
ALTER COLUMN "nationality" TYPE VARCHAR(2),
|
||||
ALTER COLUMN "licenseCountry" TYPE VARCHAR(2),
|
||||
ALTER COLUMN "licenseNumber" TYPE VARCHAR(30),
|
||||
ALTER COLUMN "licenseCategory" TYPE VARCHAR(30);
|
||||
|
||||
ALTER TABLE "additional_drivers"
|
||||
ADD COLUMN "language" VARCHAR(2) NOT NULL DEFAULT 'en';
|
||||
|
||||
ALTER TABLE "additional_drivers"
|
||||
ALTER COLUMN "language" DROP DEFAULT,
|
||||
ALTER COLUMN "firstName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "lastName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "email" TYPE VARCHAR(254),
|
||||
ALTER COLUMN "phone" TYPE VARCHAR(20),
|
||||
ALTER COLUMN "driverLicense" TYPE VARCHAR(30),
|
||||
ALTER COLUMN "nationality" TYPE VARCHAR(2);
|
||||
|
||||
ALTER TABLE "employees"
|
||||
ALTER COLUMN "firstName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "lastName" TYPE VARCHAR(50),
|
||||
ALTER COLUMN "email" TYPE VARCHAR(254),
|
||||
ALTER COLUMN "phone" TYPE VARCHAR(20);
|
||||
|
||||
ALTER TABLE "companies"
|
||||
ALTER COLUMN "phone" TYPE VARCHAR(20);
|
||||
|
||||
ALTER TABLE "vehicles"
|
||||
ALTER COLUMN "vin" TYPE VARCHAR(17);
|
||||
|
||||
ALTER TABLE "customers"
|
||||
ADD CONSTRAINT "customers_language_check" CHECK ("language" IN ('en', 'fr', 'ar')),
|
||||
ADD CONSTRAINT "customers_driver_license_check" CHECK ("driverLicense" IS NULL OR "driverLicense" ~ '^[A-Za-z0-9]{5,30}$'),
|
||||
ADD CONSTRAINT "customers_license_number_check" CHECK ("licenseNumber" IS NULL OR "licenseNumber" ~ '^[A-Za-z0-9]{5,30}$');
|
||||
|
||||
ALTER TABLE "additional_drivers"
|
||||
ADD CONSTRAINT "additional_drivers_language_check" CHECK ("language" IN ('en', 'fr', 'ar')),
|
||||
ADD CONSTRAINT "additional_drivers_driver_license_check" CHECK ("driverLicense" ~ '^[A-Za-z0-9]{5,30}$');
|
||||
|
||||
ALTER TABLE "vehicles"
|
||||
ADD CONSTRAINT "vehicles_vin_check" CHECK ("vin" IS NULL OR "vin" ~ '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$');
|
||||
@@ -626,7 +626,7 @@ model Company {
|
||||
name String
|
||||
slug String @unique
|
||||
email String @unique
|
||||
phone String?
|
||||
phone String? @db.VarChar(20)
|
||||
address Json?
|
||||
status CompanyStatus @default(PENDING)
|
||||
subscriptionPaymentRef String?
|
||||
@@ -1472,10 +1472,10 @@ model Employee {
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
clerkUserId String @unique
|
||||
firstName String
|
||||
lastName String
|
||||
email String
|
||||
phone String?
|
||||
firstName String @db.VarChar(50)
|
||||
lastName String @db.VarChar(50)
|
||||
email String @db.VarChar(254)
|
||||
phone String? @db.VarChar(20)
|
||||
passwordHash String?
|
||||
passwordResetToken String? @unique
|
||||
passwordResetExpiresAt DateTime?
|
||||
@@ -1514,7 +1514,7 @@ model Vehicle {
|
||||
year Int
|
||||
color String
|
||||
licensePlate String
|
||||
vin String?
|
||||
vin String? @db.VarChar(17)
|
||||
category VehicleCategory @default(ECONOMY)
|
||||
seats Int @default(5)
|
||||
transmission Transmission @default(AUTOMATIC)
|
||||
@@ -1714,13 +1714,14 @@ model Customer {
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
|
||||
renterId String?
|
||||
firstName String
|
||||
lastName String
|
||||
email String
|
||||
phone String?
|
||||
driverLicense String?
|
||||
language String @db.VarChar(2)
|
||||
firstName String @db.VarChar(50)
|
||||
lastName String @db.VarChar(50)
|
||||
email String @db.VarChar(254)
|
||||
phone String? @db.VarChar(20)
|
||||
driverLicense String? @db.VarChar(30)
|
||||
dateOfBirth DateTime?
|
||||
nationality String?
|
||||
nationality String? @db.VarChar(2)
|
||||
address Json?
|
||||
notes String?
|
||||
flagged Boolean @default(false)
|
||||
@@ -1728,10 +1729,10 @@ model Customer {
|
||||
|
||||
licenseExpiry DateTime?
|
||||
licenseIssuedAt DateTime?
|
||||
licenseCountry String?
|
||||
licenseCountry String? @db.VarChar(2)
|
||||
licenseImageUrl String?
|
||||
licenseNumber String?
|
||||
licenseCategory String?
|
||||
licenseNumber String? @db.VarChar(30)
|
||||
licenseCategory String? @db.VarChar(30)
|
||||
licenseExpired Boolean @default(false)
|
||||
licenseExpiringSoon Boolean @default(false)
|
||||
licenseValidationStatus LicenseStatus @default(PENDING)
|
||||
@@ -2262,15 +2263,16 @@ model AdditionalDriver {
|
||||
reservationId String
|
||||
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
|
||||
companyId String
|
||||
firstName String
|
||||
lastName String
|
||||
email String?
|
||||
phone String?
|
||||
driverLicense String
|
||||
language String @db.VarChar(2)
|
||||
firstName String @db.VarChar(50)
|
||||
lastName String @db.VarChar(50)
|
||||
email String? @db.VarChar(254)
|
||||
phone String? @db.VarChar(20)
|
||||
driverLicense String @db.VarChar(30)
|
||||
licenseExpiry DateTime?
|
||||
licenseIssuedAt DateTime?
|
||||
dateOfBirth DateTime?
|
||||
nationality String?
|
||||
nationality String? @db.VarChar(2)
|
||||
chargeType AdditionalDriverCharge @default(FREE)
|
||||
chargeValue Int @default(0)
|
||||
totalCharge Int @default(0)
|
||||
|
||||
Reference in New Issue
Block a user