update reservation

This commit is contained in:
root
2026-05-25 15:56:14 -04:00
parent 914ae839a7
commit 8ed572b3bd
17 changed files with 809 additions and 63 deletions
@@ -5,12 +5,38 @@ import { useParams, useRouter } from 'next/navigation'
import Link from 'next/link'
import { ADMIN_API_BASE } from '@/lib/api'
interface CompanyAddressProfile {
streetAddress?: string | null
city?: string | null
country?: string | null
zipCode?: string | null
legalName?: string | null
legalForm?: string | null
companyEmail?: string | null
managerName?: string | null
iceNumber?: string | null
operatingLicenseNumber?: string | null
operatingLicenseIssuedAt?: string | null
operatingLicenseIssuedBy?: string | null
fax?: string | null
yearsActive?: string | null
representativeName?: string | null
representativeTitle?: string | null
responsibleName?: string | null
responsibleRole?: string | null
responsibleIdentityNumber?: string | null
responsibleQualification?: string | null
responsiblePhone?: string | null
responsibleEmail?: string | null
}
interface CompanyDetail {
id: string
name: string
slug: string
email: string
phone: string | null
address: CompanyAddressProfile | string | null
status: string
subscriptionPaymentRef: string | null
createdAt: string
@@ -100,6 +126,25 @@ interface FormState {
status: string
subscriptionPaymentRef: string
}
companyProfile: {
legalForm: string
managerName: string
zipCode: string
fax: string
yearsActive: string
iceNumber: string
operatingLicenseNumber: string
operatingLicenseIssuedAt: string
operatingLicenseIssuedBy: string
representativeName: string
representativeTitle: string
responsibleName: string
responsibleRole: string
responsibleIdentityNumber: string
responsibleQualification: string
responsiblePhone: string
responsibleEmail: string
}
subscription: {
plan: string
billingPeriod: string
@@ -175,7 +220,13 @@ function toNullable(value: string) {
return trimmed ? trimmed : null
}
function normalizeAddressProfile(value: CompanyDetail['address']): CompanyAddressProfile {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
return value
}
function createFormState(company: CompanyDetail): FormState {
const address = normalizeAddressProfile(company.address)
return {
company: {
name: company.name ?? '',
@@ -185,6 +236,25 @@ function createFormState(company: CompanyDetail): FormState {
status: company.status ?? 'TRIALING',
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
},
companyProfile: {
legalForm: address.legalForm ?? '',
managerName: address.managerName ?? '',
zipCode: address.zipCode ?? '',
fax: address.fax ?? '',
yearsActive: address.yearsActive ?? '',
iceNumber: address.iceNumber ?? '',
operatingLicenseNumber: address.operatingLicenseNumber ?? '',
operatingLicenseIssuedAt: address.operatingLicenseIssuedAt ?? '',
operatingLicenseIssuedBy: address.operatingLicenseIssuedBy ?? '',
representativeName: address.representativeName ?? '',
representativeTitle: address.representativeTitle ?? '',
responsibleName: address.responsibleName ?? '',
responsibleRole: address.responsibleRole ?? '',
responsibleIdentityNumber: address.responsibleIdentityNumber ?? '',
responsibleQualification: address.responsibleQualification ?? '',
responsiblePhone: address.responsiblePhone ?? '',
responsibleEmail: address.responsibleEmail ?? '',
},
subscription: {
plan: company.subscription?.plan ?? 'STARTER',
billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY',
@@ -303,6 +373,30 @@ export default function AdminCompanyDetailPage() {
email: form.company.email,
phone: toNullable(form.company.phone),
subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef),
address: {
streetAddress: toNullable(form.brand.publicAddress),
city: toNullable(form.brand.publicCity),
country: toNullable(form.brand.publicCountry),
zipCode: toNullable(form.companyProfile.zipCode),
legalName: toNullable(form.contractSettings.legalName),
legalForm: toNullable(form.companyProfile.legalForm),
companyEmail: toNullable(form.company.email),
managerName: toNullable(form.companyProfile.managerName),
iceNumber: toNullable(form.companyProfile.iceNumber),
operatingLicenseNumber: toNullable(form.companyProfile.operatingLicenseNumber),
operatingLicenseIssuedAt: toNullable(form.companyProfile.operatingLicenseIssuedAt),
operatingLicenseIssuedBy: toNullable(form.companyProfile.operatingLicenseIssuedBy),
fax: toNullable(form.companyProfile.fax),
yearsActive: toNullable(form.companyProfile.yearsActive),
representativeName: toNullable(form.companyProfile.representativeName),
representativeTitle: toNullable(form.companyProfile.representativeTitle),
responsibleName: toNullable(form.companyProfile.responsibleName),
responsibleRole: toNullable(form.companyProfile.responsibleRole),
responsibleIdentityNumber: toNullable(form.companyProfile.responsibleIdentityNumber),
responsibleQualification: toNullable(form.companyProfile.responsibleQualification),
responsiblePhone: toNullable(form.companyProfile.responsiblePhone),
responsibleEmail: toNullable(form.companyProfile.responsibleEmail),
},
},
subscription: {
plan: form.subscription.plan,
@@ -637,6 +731,94 @@ export default function AdminCompanyDetailPage() {
</div>
</section>
<section className="panel p-6">
<h2 className="text-base font-semibold">Company legal profile</h2>
<div className="mt-4 grid gap-6">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Legal form</span>
<input className={INPUT_CLASS} value={form.companyProfile.legalForm} onChange={(e) => updateSection('companyProfile', { legalForm: e.target.value })} />
</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 })} />
</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 })} />
</label>
<label>
<span className={LABEL_CLASS}>Fax</span>
<input className={INPUT_CLASS} value={form.companyProfile.fax} onChange={(e) => updateSection('companyProfile', { fax: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Years active</span>
<input className={INPUT_CLASS} value={form.companyProfile.yearsActive} onChange={(e) => updateSection('companyProfile', { yearsActive: e.target.value })} />
</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 })} />
</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 })} />
</label>
<label>
<span className={LABEL_CLASS}>Operating license issue date</span>
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseIssuedAt} onChange={(e) => updateSection('companyProfile', { operatingLicenseIssuedAt: e.target.value })} />
</label>
<label className="md:col-span-2">
<span className={LABEL_CLASS}>Issuing authority</span>
<input className={INPUT_CLASS} value={form.companyProfile.operatingLicenseIssuedBy} onChange={(e) => updateSection('companyProfile', { operatingLicenseIssuedBy: e.target.value })} />
</label>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-zinc-200">Legal representative</h3>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Representative name</span>
<input className={INPUT_CLASS} value={form.companyProfile.representativeName} onChange={(e) => updateSection('companyProfile', { representativeName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Representative title</span>
<input className={INPUT_CLASS} value={form.companyProfile.representativeTitle} onChange={(e) => updateSection('companyProfile', { representativeTitle: e.target.value })} />
</label>
</div>
</div>
<div className="border-t border-zinc-800 pt-6">
<h3 className="text-sm font-semibold text-zinc-200">Responsible person</h3>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLASS}>Responsible name</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsibleName} onChange={(e) => updateSection('companyProfile', { responsibleName: e.target.value })} />
</label>
<label>
<span className={LABEL_CLASS}>Responsible role</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsibleRole} onChange={(e) => updateSection('companyProfile', { responsibleRole: e.target.value })} />
</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 })} />
</label>
<label>
<span className={LABEL_CLASS}>Qualification / diploma / experience</span>
<input className={INPUT_CLASS} value={form.companyProfile.responsibleQualification} onChange={(e) => updateSection('companyProfile', { responsibleQualification: e.target.value })} />
</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 })} />
</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 })} />
</label>
</div>
</div>
</div>
</section>
<section className="panel p-6">
<h2 className="text-base font-semibold">Operations and finance</h2>
<div className="mt-4 grid gap-6">
@@ -11,6 +11,7 @@ interface Company {
slug: string
status: string
email: string
contractSettings: { legalName: string | null } | null
subscription: { plan: string; status: string } | null
_count: { employees: number; vehicles: number }
}
@@ -32,6 +33,7 @@ export default function AdminCompaniesPage() {
loading: 'Loading…',
empty: 'No companies found',
company: 'Company',
legalName: 'Legal name',
slug: 'Slug',
status: 'Status',
plan: 'Plan',
@@ -47,6 +49,7 @@ export default function AdminCompaniesPage() {
loading: 'Chargement…',
empty: 'Aucune entreprise trouvée',
company: 'Entreprise',
legalName: 'Raison sociale',
slug: 'Slug',
status: 'Statut',
plan: 'Plan',
@@ -62,6 +65,7 @@ export default function AdminCompaniesPage() {
loading: 'جارٍ التحميل…',
empty: 'لم يتم العثور على شركات',
company: 'الشركة',
legalName: 'الاسم القانوني',
slug: 'Slug',
status: 'الحالة',
plan: 'الخطة',
@@ -162,6 +166,9 @@ export default function AdminCompaniesPage() {
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
<td className="px-6 py-4">
<p className="font-medium text-zinc-100">{c.name}</p>
{c.contractSettings?.legalName && c.contractSettings.legalName !== c.name ? (
<p className="text-xs text-zinc-400">{copy.legalName}: {c.contractSettings.legalName}</p>
) : null}
<p className="text-xs text-zinc-500">{c.email}</p>
</td>
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</td>
+21 -2
View File
@@ -2,6 +2,7 @@ import { prisma } from '../../lib/prisma'
const companyListInclude = {
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
contractSettings: { select: { legalName: true } },
subscription: { select: { plan: true, status: true } },
_count: { select: { employees: true, vehicles: true } },
} as const
@@ -125,9 +126,27 @@ export function getCompanyUpdateSnapshot(id: string) {
})
}
export async function applyCompanyUpdate(id: string, body: any, current: { name: string; slug: string; brand?: { paymentMethodsEnabled?: any[] | null } | null }) {
export async function applyCompanyUpdate(
id: string,
body: any,
current: {
name: string
slug: string
address?: unknown
brand?: { paymentMethodsEnabled?: any[] | null } | null
},
) {
return prisma.$transaction(async (tx) => {
if (body.company) await tx.company.update({ where: { id }, data: body.company })
if (body.company) {
const companyData = { ...body.company }
if (companyData.address && typeof companyData.address === 'object' && !Array.isArray(companyData.address)) {
const baseAddress = current.address && typeof current.address === 'object' && !Array.isArray(current.address)
? current.address as Record<string, unknown>
: {}
companyData.address = { ...baseAddress, ...companyData.address }
}
await tx.company.update({ where: { id }, data: companyData })
}
if (body.subscription) {
const sub = body.subscription
@@ -112,6 +112,30 @@ export const adminCompanyUpdateSchema = z.object({
email: z.string().email().optional(), phone: nullableString,
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
subscriptionPaymentRef: nullableString,
address: z.object({
streetAddress: nullableString,
city: nullableString,
country: nullableString,
zipCode: nullableString,
legalName: nullableString,
legalForm: nullableString,
companyEmail: nullableEmail,
managerName: nullableString,
iceNumber: nullableString,
operatingLicenseNumber: nullableString,
operatingLicenseIssuedAt: nullableString,
operatingLicenseIssuedBy: nullableString,
fax: nullableString,
yearsActive: nullableString,
representativeName: nullableString,
representativeTitle: nullableString,
responsibleName: nullableString,
responsibleRole: nullableString,
responsibleIdentityNumber: nullableString,
responsibleQualification: nullableString,
responsiblePhone: nullableString,
responsibleEmail: nullableEmail,
}).optional(),
}).optional(),
subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
@@ -49,13 +49,59 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug:
}
export async function upsertMarketplaceCustomer(companyId: string, data: {
email: string; firstName: string; lastName: string; phone?: string
email: string
firstName: string
lastName: string
phone?: string
dateOfBirth?: string
nationality?: string
identityDocumentNumber?: string
fullAddress?: string
driverLicense?: string
licenseExpiry?: string
licenseIssuedAt?: string
licenseCountry?: string
licenseCategory?: string
internationalLicenseNumber?: string
}) {
return prisma.customer.upsert({
const existing = await prisma.customer.findUnique({
where: { companyId_email: { companyId, email: data.email } },
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone },
update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) },
select: { id: true, address: true },
})
// Only patch the address JSON with fields that were actually provided
const prevAddress = (existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address))
? existing.address as Record<string, unknown>
: {}
const hasAddressPatch = data.fullAddress || data.identityDocumentNumber || data.internationalLicenseNumber !== undefined
const address = hasAddressPatch
? {
...prevAddress,
...(data.fullAddress ? { fullAddress: data.fullAddress } : {}),
...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}),
...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}),
}
: undefined
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
...(data.phone ? { phone: data.phone } : {}),
...(data.driverLicense ? { driverLicense: data.driverLicense, licenseNumber: data.driverLicense } : {}),
...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}),
...(data.nationality ? { nationality: data.nationality } : {}),
...(address !== undefined ? { address } : {}),
...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}),
...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}),
...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}),
...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}),
}
if (!existing) {
return prisma.customer.create({ data: { companyId, ...payload } })
}
return prisma.customer.update({ where: { id: existing.id }, data: payload })
}
export async function createMarketplaceReservation(data: {
@@ -30,7 +30,19 @@ export const marketplaceReservationSchema = z.object({
firstName: z.string().min(1).max(100),
lastName: z.string().min(1).max(100),
email: z.string().email(),
phone: z.string().max(30).optional(),
// Contact info — collected at reservation time
phone: z.string().min(1).max(30).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(),
driverLicense: z.string().min(1).max(50).optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
licenseCountry: z.string().min(1).max(100).optional(),
licenseCategory: z.string().min(1).max(20).optional(),
internationalLicenseNumber: z.string().trim().max(100).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(),
@@ -121,7 +121,11 @@ export async function searchVehicles(params: {
export async function createMarketplaceReservation(body: {
vehicleId: string; companySlug: string; firstName: string; lastName: string
email: string; phone?: string; startDate: string; endDate: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string
driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string
licenseCountry?: string; licenseCategory?: string; internationalLicenseNumber?: string
startDate: string; endDate: string
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
}) {
const startDate = new Date(body.startDate)
@@ -160,7 +164,20 @@ export async function createMarketplaceReservation(body: {
}
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
phone: body.phone,
dateOfBirth: body.dateOfBirth,
nationality: body.nationality,
identityDocumentNumber: body.identityDocumentNumber,
fullAddress: body.fullAddress,
driverLicense: body.driverLicense,
licenseExpiry: body.licenseExpiry,
licenseIssuedAt: body.licenseIssuedAt,
licenseCountry: body.licenseCountry,
licenseCategory: body.licenseCategory,
internationalLicenseNumber: body.internationalLicenseNumber,
})
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
@@ -121,6 +121,16 @@ describe('createMarketplaceReservation', () => {
const baseBody = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
phone: '+212600000000',
dateOfBirth: '1990-01-01T00:00:00.000Z',
nationality: 'Moroccan',
identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
licenseExpiry: '2030-01-01T00:00:00.000Z',
licenseIssuedAt: '2015-01-01T00:00:00.000Z',
licenseCountry: 'Morocco',
licenseCategory: 'B',
startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z',
}
@@ -132,6 +142,11 @@ describe('createMarketplaceReservation', () => {
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
identityDocumentNumber: 'CIN123456',
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
}))
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
@@ -8,6 +8,17 @@ import { coerceNotificationLocale } from '../../services/notificationLocalizatio
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
import * as repo from './reservation.repo'
function buildPickupAddress(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
const address = value as Record<string, unknown>
return [
typeof address.streetAddress === 'string' ? address.streetAddress.trim() : '',
typeof address.city === 'string' ? address.city.trim() : '',
typeof address.country === 'string' ? address.country.trim() : '',
].filter(Boolean).join(', ')
}
async function assertLicenseCompliance(reservationId: string, companyId: string) {
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
@@ -46,6 +57,7 @@ export async function confirmReservation(id: string, companyId: string) {
repo.findVehicle(reservation.vehicleId, companyId),
])
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address)
await sendNotification({
type: 'BOOKING_CONFIRMED',
companyId,
@@ -57,8 +69,29 @@ export async function confirmReservation(id: string, companyId: string) {
templateVariables: {
firstName: customer.firstName,
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
startDate: reservation.startDate,
endDate: reservation.endDate,
startDate: {
type: 'date',
value: reservation.startDate,
options: {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
},
endDate: {
type: 'date',
value: reservation.endDate,
options: {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
},
},
pickupLocation,
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
},
}).catch(() => null)
@@ -72,6 +72,7 @@ import * as pricingService from './reservation.pricing.service'
import * as insuranceService from './reservation.insurance.service'
import * as additionalDriverService from './reservation.additional-driver.service'
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification } from '../../services/notificationService'
import { buildReservationWorkflow } from './reservation.presenter'
import { createReservation } from './reservation.service'
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
@@ -158,7 +159,7 @@ describe('createReservation', () => {
// ────────────────────────────────────────────────────────────────────────────
describe('confirmReservation', () => {
it('confirms a DRAFT reservation', async () => {
const res = makeReservation({ status: 'DRAFT' })
const res = makeReservation({ status: 'DRAFT', pickupLocation: 'Casablanca Airport Terminal 1' })
vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any)
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
...res,
@@ -174,6 +175,20 @@ describe('confirmReservation', () => {
const result = await confirmReservation(RES_ID, COMPANY)
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({
templateKey: 'booking.confirmed',
templateVariables: expect.objectContaining({
pickupLocation: 'Casablanca Airport Terminal 1',
startDate: expect.objectContaining({
type: 'date',
value: res.startDate,
}),
endDate: expect.objectContaining({
type: 'date',
value: res.endDate,
}),
}),
}))
expect((result as any).status).toBe('CONFIRMED')
})
+53 -11
View File
@@ -41,19 +41,61 @@ export async function findOfferByPromoCode(companyId: string, code: string) {
}
export async function upsertCustomer(companyId: string, data: {
email: string; firstName: string; lastName: string; phone?: string | null
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
licenseIssuedAt?: string | null; nationality?: string | null
email: string
firstName: string
lastName: string
phone: string
driverLicense: string
dateOfBirth: string
licenseExpiry: string
licenseIssuedAt: string
nationality: string
identityDocumentNumber: string
fullAddress: string
licenseCountry: string
licenseNumber?: string | null
licenseCategory: string
internationalLicenseNumber?: string | null
}) {
const parsed = {
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
licenseExpiry: data.licenseExpiry ? new Date(data.licenseExpiry) : null,
licenseIssuedAt: data.licenseIssuedAt ? new Date(data.licenseIssuedAt) : null,
}
return prisma.customer.upsert({
const existing = await prisma.customer.findUnique({
where: { companyId_email: { companyId, email: data.email } },
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
select: { id: true, address: true },
})
const address = {
...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address)
? existing.address as Record<string, unknown>
: {}),
fullAddress: data.fullAddress,
identityDocumentNumber: data.identityDocumentNumber,
internationalLicenseNumber: data.internationalLicenseNumber ?? null,
}
const payload = {
firstName: data.firstName,
lastName: data.lastName,
email: data.email,
phone: data.phone,
driverLicense: data.driverLicense,
dateOfBirth: new Date(data.dateOfBirth),
nationality: data.nationality,
address,
licenseExpiry: new Date(data.licenseExpiry),
licenseIssuedAt: new Date(data.licenseIssuedAt),
licenseCountry: data.licenseCountry,
licenseNumber: data.licenseNumber ?? data.driverLicense,
licenseCategory: data.licenseCategory,
}
if (!existing) {
return prisma.customer.create({
data: { companyId, ...payload },
})
}
return prisma.customer.update({
where: { id: existing.id },
data: payload,
})
}
+12 -6
View File
@@ -18,12 +18,18 @@ export const bookSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
driverLicense: z.string().optional(),
dateOfBirth: z.string().datetime().optional(),
licenseExpiry: z.string().datetime().optional(),
licenseIssuedAt: z.string().datetime().optional(),
nationality: z.string().optional(),
phone: z.string().min(1).max(30),
driverLicense: z.string().min(1).max(50),
dateOfBirth: z.string().datetime(),
licenseExpiry: z.string().datetime(),
licenseIssuedAt: z.string().datetime(),
nationality: z.string().min(1).max(100),
identityDocumentNumber: z.string().min(1).max(100),
fullAddress: z.string().min(1).max(500),
licenseCountry: z.string().min(1).max(100),
licenseNumber: z.string().min(1).max(50).optional(),
licenseCategory: z.string().min(1).max(20),
internationalLicenseNumber: z.string().trim().max(100).optional(),
offerId: z.string().cuid().optional(),
promoCodeUsed: z.string().optional(),
notes: z.string().optional(),
+18 -5
View File
@@ -93,8 +93,9 @@ export async function validatePromoCode(slug: string, code: string) {
export async function createBooking(slug: string, body: {
vehicleId: string; startDate: string; endDate: string
firstName: string; lastName: string; email: string; phone?: string
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: string
firstName: string; lastName: string; email: string; phone: string
driverLicense: string; dateOfBirth: string; licenseExpiry: string; licenseIssuedAt: string; nationality: string
identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
}) {
@@ -113,9 +114,21 @@ export async function createBooking(slug: string, body: {
}
const customer = await repo.upsertCustomer(company.id, {
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
email: body.email,
firstName: body.firstName,
lastName: body.lastName,
phone: body.phone,
driverLicense: body.driverLicense,
dateOfBirth: body.dateOfBirth,
licenseExpiry: body.licenseExpiry,
licenseIssuedAt: body.licenseIssuedAt,
nationality: body.nationality,
identityDocumentNumber: body.identityDocumentNumber,
fullAddress: body.fullAddress,
licenseCountry: body.licenseCountry,
licenseNumber: body.licenseNumber,
licenseCategory: body.licenseCategory,
internationalLicenseNumber: body.internationalLicenseNumber,
})
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
@@ -13,9 +13,13 @@ type Customer = {
email: string
phone?: string | null
driverLicense?: string | null
dateOfBirth?: string | null
nationality?: string | null
address?: Record<string, unknown> | null
licenseExpiry?: string | null
licenseIssuedAt?: string | null
licenseCountry?: string | null
licenseNumber?: string | null
licenseCategory?: string | null
licenseImageUrl?: string | null
}
@@ -62,6 +66,11 @@ export default function NewReservationPage() {
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [customerDateOfBirth, setCustomerDateOfBirth] = useState('')
const [customerNationality, setCustomerNationality] = useState('')
const [customerFullAddress, setCustomerFullAddress] = useState('')
const [customerIdentityDocumentNumber, setCustomerIdentityDocumentNumber] = useState('')
const [customerInternationalLicenseNumber, setCustomerInternationalLicenseNumber] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
@@ -94,12 +103,16 @@ export default function NewReservationPage() {
return: 'Return location',
deposit: 'Deposit amount (MAD)',
paymentMode: 'Payment mode',
renterIdentity: 'Renter identity',
driverLicenseInfo: 'Driver license information',
driverLicense: 'Driver license number',
licenseExpiry: 'License expiry',
licenseIssuedAt: 'License issued at',
licenseCountry: 'License country',
licenseCategory: 'License category',
fullAddress: 'Full address',
identityDocumentNumber: 'CIN / Passport number',
internationalLicenseNumber: 'International permit number',
licenseImage: 'Driver license image',
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
licenseImageSelected: 'Selected file',
@@ -108,7 +121,7 @@ export default function NewReservationPage() {
addAdditionalDriver: 'Add additional driver',
additionalDriverInfo: 'Additional driver',
dateOfBirth: 'Date of birth',
nationality: 'License country',
nationality: 'Nationality',
notes: 'Notes',
notesPlaceholder: 'Optional notes…',
create: 'Create booking',
@@ -148,12 +161,16 @@ export default function NewReservationPage() {
return: 'Lieu de retour',
deposit: 'Montant du dépôt (MAD)',
paymentMode: 'Mode de paiement',
renterIdentity: 'Identité du locataire',
driverLicenseInfo: 'Informations du permis',
driverLicense: 'Numéro du permis',
licenseExpiry: 'Expiration du permis',
licenseIssuedAt: 'Permis délivré le',
licenseCountry: 'Pays du permis',
licenseCategory: 'Catégorie du permis',
fullAddress: 'Adresse complète',
identityDocumentNumber: 'N° CIN / passeport',
internationalLicenseNumber: 'N° de permis international',
licenseImage: 'Image du permis',
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
licenseImageSelected: 'Fichier sélectionné',
@@ -162,7 +179,7 @@ export default function NewReservationPage() {
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
additionalDriverInfo: 'Conducteur supplémentaire',
dateOfBirth: 'Date de naissance',
nationality: 'Pays du permis',
nationality: 'Nationalité',
notes: 'Notes',
notesPlaceholder: 'Notes optionnelles…',
create: 'Créer la réservation',
@@ -202,12 +219,16 @@ export default function NewReservationPage() {
return: 'موقع التسليم',
deposit: 'مبلغ العربون (MAD)',
paymentMode: 'طريقة الدفع',
renterIdentity: 'بيانات المستأجر',
driverLicenseInfo: 'معلومات رخصة القيادة',
driverLicense: 'رقم رخصة القيادة',
licenseExpiry: 'تاريخ انتهاء الرخصة',
licenseIssuedAt: 'تاريخ إصدار الرخصة',
licenseCountry: 'بلد الرخصة',
licenseCategory: 'فئة الرخصة',
fullAddress: 'العنوان الكامل',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
internationalLicenseNumber: 'رقم الرخصة الدولية',
licenseImage: 'صورة رخصة القيادة',
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
licenseImageSelected: 'الملف المحدد',
@@ -216,7 +237,7 @@ export default function NewReservationPage() {
addAdditionalDriver: 'إضافة سائق إضافي',
additionalDriverInfo: 'السائق الإضافي',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'بلد الرخصة',
nationality: 'الجنسية',
notes: 'ملاحظات',
notesPlaceholder: 'ملاحظات اختيارية…',
create: 'إنشاء الحجز',
@@ -272,10 +293,22 @@ export default function NewReservationPage() {
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
function readCustomerAddressValue(customer: Customer | undefined, key: string) {
const address = customer?.address
if (!address || typeof address !== 'object' || Array.isArray(address)) return ''
const value = address[key]
return typeof value === 'string' ? value : ''
}
useEffect(() => {
const selected = customers.find((customer) => customer.id === customerId)
if (!selected) return
setDriverLicense(selected.driverLicense ?? '')
setDriverLicense(selected.driverLicense ?? selected.licenseNumber ?? '')
setCustomerDateOfBirth(selected.dateOfBirth ? selected.dateOfBirth.slice(0, 10) : '')
setCustomerNationality(selected.nationality ?? '')
setCustomerFullAddress(readCustomerAddressValue(selected, 'fullAddress'))
setCustomerIdentityDocumentNumber(readCustomerAddressValue(selected, 'identityDocumentNumber'))
setCustomerInternationalLicenseNumber(readCustomerAddressValue(selected, 'internationalLicenseNumber'))
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
setLicenseCountry(selected.licenseCountry ?? '')
@@ -318,6 +351,20 @@ export default function NewReservationPage() {
setError(copy.required)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
setSavingCustomer(true)
try {
const created = await apiFetch<Customer>('/customers', {
@@ -329,11 +376,19 @@ export default function NewReservationPage() {
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim() || undefined,
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
licenseCountry: licenseCountry.trim() || undefined,
licenseCategory: licenseCategory.trim() || undefined,
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
setCustomers((prev) => [created, ...prev])
@@ -368,6 +423,20 @@ export default function NewReservationPage() {
setError(copy.invalidDates)
return
}
if (
!driverLicense.trim() ||
!customerDateOfBirth ||
!customerNationality.trim() ||
!customerFullAddress.trim() ||
!customerIdentityDocumentNumber.trim() ||
!licenseExpiry ||
!licenseIssuedAt ||
!licenseCountry.trim() ||
!licenseCategory.trim()
) {
setError(copy.required)
return
}
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
@@ -378,11 +447,19 @@ export default function NewReservationPage() {
await apiFetch(`/customers/${customerId}`, {
method: 'PATCH',
body: JSON.stringify({
driverLicense: driverLicense.trim() || undefined,
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
licenseCountry: licenseCountry.trim() || undefined,
licenseCategory: licenseCategory.trim() || undefined,
driverLicense: driverLicense.trim(),
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
nationality: customerNationality.trim(),
address: {
fullAddress: customerFullAddress.trim(),
identityDocumentNumber: customerIdentityDocumentNumber.trim(),
internationalLicenseNumber: customerInternationalLicenseNumber.trim() || undefined,
},
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseNumber: driverLicense.trim(),
licenseCategory: licenseCategory.trim(),
}),
})
@@ -509,29 +586,57 @@ export default function NewReservationPage() {
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.renterIdentity}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.dateOfBirth} <span className="text-red-600">*</span></span>
<input type="date" value={customerDateOfBirth} onChange={(e) => setCustomerDateOfBirth(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality} <span className="text-red-600">*</span></span>
<input value={customerNationality} onChange={(e) => setCustomerNationality(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.identityDocumentNumber} <span className="text-red-600">*</span></span>
<input value={customerIdentityDocumentNumber} onChange={(e) => setCustomerIdentityDocumentNumber(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.internationalLicenseNumber}</span>
<input value={customerInternationalLicenseNumber} onChange={(e) => setCustomerInternationalLicenseNumber(e.target.value)} className="input-field" />
</label>
</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={customerFullAddress} onChange={(e) => setCustomerFullAddress(e.target.value)} className="input-field min-h-[88px]" />
</label>
</div>
<div className="rounded-xl border border-slate-200 p-4 space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense}</span>
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry}</span>
<span className="text-sm font-medium text-slate-700">{copy.licenseCountry} <span className="text-red-600">*</span></span>
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
</label>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt}</span>
<span className="text-sm font-medium text-slate-700">{copy.licenseIssuedAt} <span className="text-red-600">*</span></span>
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry}</span>
<span className="text-sm font-medium text-slate-700">{copy.licenseExpiry} <span className="text-red-600">*</span></span>
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory}</span>
<span className="text-sm font-medium text-slate-700">{copy.licenseCategory} <span className="text-red-600">*</span></span>
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
</label>
</div>
@@ -11,7 +11,7 @@ export type VehicleSheetPoint = {
const SHEET_WIDTH = 573
const SHEET_HEIGHT = 876
const SHEET_IMAGE_PATH = '/vehicle-condition-template.png'
const SHEET_IMAGE_PATH = '/dashboard/vehicle-condition-template.png'
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
MINOR: '#f59e0b',
+193 -9
View File
@@ -16,10 +16,22 @@ type Props = {
const copy = {
en: {
title: 'Reserve this vehicle',
renterIdentity: 'Renter identity',
firstName: 'First name',
lastName: 'Last name',
dateOfBirth: 'Date of birth',
nationality: 'Nationality',
identityDocumentNumber: 'CIN / Passport number',
fullAddress: 'Full address',
driverLicenseSection: 'Driver license',
email: 'Email',
phone: 'Phone',
driverLicense: 'License number',
licenseIssuedAt: 'Issue date',
licenseExpiry: 'Expiry date',
licenseCountry: 'Country of issue',
licenseCategory: 'License category',
internationalLicenseNumber: 'International permit number (optional)',
pickupLocation: 'Pick-up location',
returnLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
@@ -42,10 +54,22 @@ const copy = {
},
fr: {
title: 'Réserver ce véhicule',
renterIdentity: 'Identité du locataire',
firstName: 'Prénom',
lastName: 'Nom',
dateOfBirth: 'Date de naissance',
nationality: 'Nationalité',
identityDocumentNumber: 'N° CIN / passeport',
fullAddress: 'Adresse complète',
driverLicenseSection: 'Permis de conduire',
email: 'E-mail',
phone: 'Téléphone',
driverLicense: 'Numéro du permis',
licenseIssuedAt: 'Date de délivrance',
licenseExpiry: 'Date dexpiration',
licenseCountry: 'Pays de délivrance',
licenseCategory: 'Catégorie du permis',
internationalLicenseNumber: 'N° de permis international (optionnel)',
pickupLocation: 'Lieu de départ',
returnLocation: 'Lieu de retour',
sameDropoff: 'Même retour',
@@ -68,10 +92,22 @@ const copy = {
},
ar: {
title: 'احجز هذه السيارة',
renterIdentity: 'بيانات المستأجر',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
dateOfBirth: 'تاريخ الميلاد',
nationality: 'الجنسية',
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
fullAddress: 'العنوان الكامل',
driverLicenseSection: 'رخصة القيادة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
driverLicense: 'رقم الرخصة',
licenseIssuedAt: 'تاريخ الإصدار',
licenseExpiry: 'تاريخ الانتهاء',
licenseCountry: 'بلد الإصدار',
licenseCategory: 'فئة الرخصة',
internationalLicenseNumber: 'رقم الرخصة الدولية (اختياري)',
pickupLocation: 'موقع الاستلام',
returnLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
@@ -110,8 +146,18 @@ export default function BookingForm({
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [dateOfBirth, setDateOfBirth] = useState('')
const [nationality, setNationality] = useState('')
const [identityDocumentNumber, setIdentityDocumentNumber] = useState('')
const [fullAddress, setFullAddress] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
const [licenseExpiry, setLicenseExpiry] = useState('')
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [internationalLicenseNumber, setInternationalLicenseNumber] = useState('')
const [pickupLocation, setPickupLocation] = useState(pickupOptions[0] ?? '')
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>('same')
const [returnLocation, setReturnLocation] = useState(dropoffOptions[0] ?? '')
@@ -131,7 +177,23 @@ export default function BookingForm({
e.preventDefault()
setError(null)
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !startDate || !endDate) {
if (
!firstName.trim() ||
!lastName.trim() ||
!dateOfBirth ||
!nationality.trim() ||
!identityDocumentNumber.trim() ||
!fullAddress.trim() ||
!email.trim() ||
!phone.trim() ||
!driverLicense.trim() ||
!licenseIssuedAt ||
!licenseExpiry ||
!licenseCountry.trim() ||
!licenseCategory.trim() ||
!startDate ||
!endDate
) {
setError(t.required)
return
}
@@ -154,8 +216,18 @@ export default function BookingForm({
companySlug,
firstName: firstName.trim(),
lastName: lastName.trim(),
dateOfBirth: new Date(dateOfBirth).toISOString(),
nationality: nationality.trim(),
identityDocumentNumber: identityDocumentNumber.trim(),
fullAddress: fullAddress.trim(),
email: email.trim(),
phone: phone.trim(),
driverLicense: driverLicense.trim(),
licenseIssuedAt: new Date(licenseIssuedAt).toISOString(),
licenseExpiry: new Date(licenseExpiry).toISOString(),
licenseCountry: licenseCountry.trim(),
licenseCategory: licenseCategory.trim(),
internationalLicenseNumber: internationalLicenseNumber.trim() || undefined,
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
@@ -190,22 +262,68 @@ export default function BookingForm({
</p>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.renterIdentity}</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.firstName} <span className="text-red-500">*</span></label>
<input
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.lastName} <span className="text-red-500">*</span></label>
<input
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.dateOfBirth} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.nationality} <span className="text-red-500">*</span></label>
<input
required
value={nationality}
onChange={(e) => setNationality(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.firstName} <span className="text-red-500">*</span></label>
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.identityDocumentNumber} <span className="text-red-500">*</span></label>
<input
required
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
value={identityDocumentNumber}
onChange={(e) => setIdentityDocumentNumber(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.lastName} <span className="text-red-500">*</span></label>
<input
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.fullAddress} <span className="text-red-500">*</span></label>
<textarea
required
value={lastName}
onChange={(e) => setLastName(e.target.value)}
value={fullAddress}
onChange={(e) => setFullAddress(e.target.value)}
rows={3}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
@@ -222,6 +340,72 @@ export default function BookingForm({
/>
</div>
<div className="space-y-4 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-[#0d1b38]">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.driverLicenseSection}</p>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.driverLicense} <span className="text-red-500">*</span></label>
<input
required
value={driverLicense}
onChange={(e) => setDriverLicense(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseIssuedAt} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={licenseIssuedAt}
onChange={(e) => setLicenseIssuedAt(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseExpiry} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={licenseExpiry}
onChange={(e) => setLicenseExpiry(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseCountry} <span className="text-red-500">*</span></label>
<input
required
value={licenseCountry}
onChange={(e) => setLicenseCountry(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.licenseCategory} <span className="text-red-500">*</span></label>
<input
required
value={licenseCategory}
onChange={(e) => setLicenseCategory(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.internationalLicenseNumber}</label>
<input
value={internationalLicenseNumber}
onChange={(e) => setInternationalLicenseNumber(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.phone} <span className="text-red-500">*</span></label>
<input
@@ -0,0 +1,26 @@
UPDATE "notification_templates"
SET
"body" = E'Hi {{firstName}},\n\nYour booking with {{companyName}} has been confirmed.\nPickup details: {{startDate}}\nPickup address: {{pickupLocation}}\nReturn: {{endDate}}\nVehicle: {{vehicleName}}\n\nThank you for choosing RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'en';
UPDATE "notification_templates"
SET
"body" = E'Bonjour {{firstName}},\n\nVotre reservation chez {{companyName}} a ete confirmee.\nDetails de prise en charge : {{startDate}}\nAdresse de prise en charge : {{pickupLocation}}\nRetour : {{endDate}}\nVehicule : {{vehicleName}}\n\nMerci d''avoir choisi RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'fr';
UPDATE "notification_templates"
SET
"body" = E'مرحباً {{firstName}}،\n\nتم تأكيد حجزك مع {{companyName}}.\nتفاصيل الاستلام: {{startDate}}\nعنوان الاستلام: {{pickupLocation}}\nالإرجاع: {{endDate}}\nالمركبة: {{vehicleName}}\n\nشكراً لاختيار RentalDriveGo.',
"requiredVariables" = '["firstName","companyName","startDate","pickupLocation","endDate","vehicleName"]'::jsonb,
"updatedAt" = CURRENT_TIMESTAMP
WHERE "templateKey" = 'booking.confirmed'
AND "channel" = 'EMAIL'
AND "locale" = 'ar';