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}
|
||||
|
||||
Reference in New Issue
Block a user