update reservation
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 d’expiration',
|
||||
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
|
||||
|
||||
+26
@@ -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';
|
||||
Reference in New Issue
Block a user