update reservation
This commit is contained in:
@@ -5,12 +5,38 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { ADMIN_API_BASE } from '@/lib/api'
|
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 {
|
interface CompanyDetail {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
email: string
|
email: string
|
||||||
phone: string | null
|
phone: string | null
|
||||||
|
address: CompanyAddressProfile | string | null
|
||||||
status: string
|
status: string
|
||||||
subscriptionPaymentRef: string | null
|
subscriptionPaymentRef: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -100,6 +126,25 @@ interface FormState {
|
|||||||
status: string
|
status: string
|
||||||
subscriptionPaymentRef: 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: {
|
subscription: {
|
||||||
plan: string
|
plan: string
|
||||||
billingPeriod: string
|
billingPeriod: string
|
||||||
@@ -175,7 +220,13 @@ function toNullable(value: string) {
|
|||||||
return trimmed ? trimmed : null
|
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 {
|
function createFormState(company: CompanyDetail): FormState {
|
||||||
|
const address = normalizeAddressProfile(company.address)
|
||||||
return {
|
return {
|
||||||
company: {
|
company: {
|
||||||
name: company.name ?? '',
|
name: company.name ?? '',
|
||||||
@@ -185,6 +236,25 @@ function createFormState(company: CompanyDetail): FormState {
|
|||||||
status: company.status ?? 'TRIALING',
|
status: company.status ?? 'TRIALING',
|
||||||
subscriptionPaymentRef: company.subscriptionPaymentRef ?? '',
|
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: {
|
subscription: {
|
||||||
plan: company.subscription?.plan ?? 'STARTER',
|
plan: company.subscription?.plan ?? 'STARTER',
|
||||||
billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY',
|
billingPeriod: company.subscription?.billingPeriod ?? 'MONTHLY',
|
||||||
@@ -303,6 +373,30 @@ export default function AdminCompanyDetailPage() {
|
|||||||
email: form.company.email,
|
email: form.company.email,
|
||||||
phone: toNullable(form.company.phone),
|
phone: toNullable(form.company.phone),
|
||||||
subscriptionPaymentRef: toNullable(form.company.subscriptionPaymentRef),
|
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: {
|
subscription: {
|
||||||
plan: form.subscription.plan,
|
plan: form.subscription.plan,
|
||||||
@@ -637,6 +731,94 @@ export default function AdminCompanyDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="panel p-6">
|
||||||
<h2 className="text-base font-semibold">Operations and finance</h2>
|
<h2 className="text-base font-semibold">Operations and finance</h2>
|
||||||
<div className="mt-4 grid gap-6">
|
<div className="mt-4 grid gap-6">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface Company {
|
|||||||
slug: string
|
slug: string
|
||||||
status: string
|
status: string
|
||||||
email: string
|
email: string
|
||||||
|
contractSettings: { legalName: string | null } | null
|
||||||
subscription: { plan: string; status: string } | null
|
subscription: { plan: string; status: string } | null
|
||||||
_count: { employees: number; vehicles: number }
|
_count: { employees: number; vehicles: number }
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,7 @@ export default function AdminCompaniesPage() {
|
|||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
empty: 'No companies found',
|
empty: 'No companies found',
|
||||||
company: 'Company',
|
company: 'Company',
|
||||||
|
legalName: 'Legal name',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'Status',
|
status: 'Status',
|
||||||
plan: 'Plan',
|
plan: 'Plan',
|
||||||
@@ -47,6 +49,7 @@ export default function AdminCompaniesPage() {
|
|||||||
loading: 'Chargement…',
|
loading: 'Chargement…',
|
||||||
empty: 'Aucune entreprise trouvée',
|
empty: 'Aucune entreprise trouvée',
|
||||||
company: 'Entreprise',
|
company: 'Entreprise',
|
||||||
|
legalName: 'Raison sociale',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'Statut',
|
status: 'Statut',
|
||||||
plan: 'Plan',
|
plan: 'Plan',
|
||||||
@@ -62,6 +65,7 @@ export default function AdminCompaniesPage() {
|
|||||||
loading: 'جارٍ التحميل…',
|
loading: 'جارٍ التحميل…',
|
||||||
empty: 'لم يتم العثور على شركات',
|
empty: 'لم يتم العثور على شركات',
|
||||||
company: 'الشركة',
|
company: 'الشركة',
|
||||||
|
legalName: 'الاسم القانوني',
|
||||||
slug: 'Slug',
|
slug: 'Slug',
|
||||||
status: 'الحالة',
|
status: 'الحالة',
|
||||||
plan: 'الخطة',
|
plan: 'الخطة',
|
||||||
@@ -162,6 +166,9 @@ export default function AdminCompaniesPage() {
|
|||||||
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
<tr key={c.id} className="hover:bg-zinc-800/30 transition-colors">
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<p className="font-medium text-zinc-100">{c.name}</p>
|
<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>
|
<p className="text-xs text-zinc-500">{c.email}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-zinc-400 font-mono text-xs">{c.slug}</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 = {
|
const companyListInclude = {
|
||||||
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
brand: { select: { displayName: true, logoUrl: true, subdomain: true } },
|
||||||
|
contractSettings: { select: { legalName: true } },
|
||||||
subscription: { select: { plan: true, status: true } },
|
subscription: { select: { plan: true, status: true } },
|
||||||
_count: { select: { employees: true, vehicles: true } },
|
_count: { select: { employees: true, vehicles: true } },
|
||||||
} as const
|
} 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) => {
|
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) {
|
if (body.subscription) {
|
||||||
const sub = body.subscription
|
const sub = body.subscription
|
||||||
|
|||||||
@@ -112,6 +112,30 @@ export const adminCompanyUpdateSchema = z.object({
|
|||||||
email: z.string().email().optional(), phone: nullableString,
|
email: z.string().email().optional(), phone: nullableString,
|
||||||
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
status: z.enum(['PENDING', 'TRIALING', 'ACTIVE', 'PAST_DUE', 'SUSPENDED', 'CANCELLED']).optional(),
|
||||||
subscriptionPaymentRef: nullableString,
|
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(),
|
}).optional(),
|
||||||
subscription: z.object({
|
subscription: z.object({
|
||||||
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
|
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: {
|
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 } },
|
where: { companyId_email: { companyId, email: data.email } },
|
||||||
create: { companyId, firstName: data.firstName, lastName: data.lastName, email: data.email, phone: data.phone },
|
select: { id: true, address: true },
|
||||||
update: { firstName: data.firstName, lastName: data.lastName, ...(data.phone ? { phone: data.phone } : {}) },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 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: {
|
export async function createMarketplaceReservation(data: {
|
||||||
|
|||||||
@@ -30,7 +30,19 @@ export const marketplaceReservationSchema = z.object({
|
|||||||
firstName: z.string().min(1).max(100),
|
firstName: z.string().min(1).max(100),
|
||||||
lastName: z.string().min(1).max(100),
|
lastName: z.string().min(1).max(100),
|
||||||
email: z.string().email(),
|
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(),
|
startDate: z.string().datetime(),
|
||||||
endDate: z.string().datetime(),
|
endDate: z.string().datetime(),
|
||||||
pickupLocation: z.string().trim().max(100).optional(),
|
pickupLocation: z.string().trim().max(100).optional(),
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ export async function searchVehicles(params: {
|
|||||||
|
|
||||||
export async function createMarketplaceReservation(body: {
|
export async function createMarketplaceReservation(body: {
|
||||||
vehicleId: string; companySlug: string; firstName: string; lastName: string
|
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
|
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
|
||||||
}) {
|
}) {
|
||||||
const startDate = new Date(body.startDate)
|
const startDate = new Date(body.startDate)
|
||||||
@@ -160,7 +164,20 @@ export async function createMarketplaceReservation(body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customer = await repo.upsertMarketplaceCustomer(vehicle.companyId, {
|
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))
|
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
|
||||||
|
|||||||
@@ -121,6 +121,16 @@ describe('createMarketplaceReservation', () => {
|
|||||||
const baseBody = {
|
const baseBody = {
|
||||||
vehicleId: 'v-1', companySlug: SLUG,
|
vehicleId: 'v-1', companySlug: SLUG,
|
||||||
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
|
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',
|
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' })
|
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
|
||||||
expect(result.reservationId).toBe('r-1')
|
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({
|
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
pickupLocation: 'Casablanca',
|
pickupLocation: 'Casablanca',
|
||||||
returnLocation: 'Casablanca',
|
returnLocation: 'Casablanca',
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ import { coerceNotificationLocale } from '../../services/notificationLocalizatio
|
|||||||
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter'
|
||||||
import * as repo from './reservation.repo'
|
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) {
|
async function assertLicenseCompliance(reservationId: string, companyId: string) {
|
||||||
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
|
const reservation = await repo.findForLicenseCheck(reservationId, companyId)
|
||||||
|
|
||||||
@@ -46,6 +57,7 @@ export async function confirmReservation(id: string, companyId: string) {
|
|||||||
repo.findVehicle(reservation.vehicleId, companyId),
|
repo.findVehicle(reservation.vehicleId, companyId),
|
||||||
])
|
])
|
||||||
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
|
const fallbackLocale = coerceNotificationLocale(company?.brand?.defaultLocale)
|
||||||
|
const pickupLocation = reservation.pickupLocation?.trim() || buildPickupAddress(company?.address)
|
||||||
await sendNotification({
|
await sendNotification({
|
||||||
type: 'BOOKING_CONFIRMED',
|
type: 'BOOKING_CONFIRMED',
|
||||||
companyId,
|
companyId,
|
||||||
@@ -57,8 +69,29 @@ export async function confirmReservation(id: string, companyId: string) {
|
|||||||
templateVariables: {
|
templateVariables: {
|
||||||
firstName: customer.firstName,
|
firstName: customer.firstName,
|
||||||
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
|
companyName: company?.brand?.displayName ?? company?.name ?? 'RentalDriveGo',
|
||||||
startDate: reservation.startDate,
|
startDate: {
|
||||||
endDate: reservation.endDate,
|
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}`,
|
vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`,
|
||||||
},
|
},
|
||||||
}).catch(() => null)
|
}).catch(() => null)
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ import * as pricingService from './reservation.pricing.service'
|
|||||||
import * as insuranceService from './reservation.insurance.service'
|
import * as insuranceService from './reservation.insurance.service'
|
||||||
import * as additionalDriverService from './reservation.additional-driver.service'
|
import * as additionalDriverService from './reservation.additional-driver.service'
|
||||||
import { validateLicense } from '../../services/licenseValidationService'
|
import { validateLicense } from '../../services/licenseValidationService'
|
||||||
|
import { sendNotification } from '../../services/notificationService'
|
||||||
import { buildReservationWorkflow } from './reservation.presenter'
|
import { buildReservationWorkflow } from './reservation.presenter'
|
||||||
import { createReservation } from './reservation.service'
|
import { createReservation } from './reservation.service'
|
||||||
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
|
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
|
||||||
@@ -158,7 +159,7 @@ describe('createReservation', () => {
|
|||||||
// ────────────────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
describe('confirmReservation', () => {
|
describe('confirmReservation', () => {
|
||||||
it('confirms a DRAFT reservation', async () => {
|
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.findByIdSimple).mockResolvedValue(res as any)
|
||||||
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
vi.mocked(repo.findForLicenseCheck).mockResolvedValue({
|
||||||
...res,
|
...res,
|
||||||
@@ -174,6 +175,20 @@ describe('confirmReservation', () => {
|
|||||||
const result = await confirmReservation(RES_ID, COMPANY)
|
const result = await confirmReservation(RES_ID, COMPANY)
|
||||||
|
|
||||||
expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' })
|
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')
|
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: {
|
export async function upsertCustomer(companyId: string, data: {
|
||||||
email: string; firstName: string; lastName: string; phone?: string | null
|
email: string
|
||||||
driverLicense?: string | null; dateOfBirth?: string | null; licenseExpiry?: string | null
|
firstName: string
|
||||||
licenseIssuedAt?: string | null; nationality?: string | null
|
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 = {
|
const existing = await prisma.customer.findUnique({
|
||||||
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({
|
|
||||||
where: { companyId_email: { companyId, email: data.email } },
|
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 },
|
select: { id: true, address: true },
|
||||||
update: { firstName: data.firstName, lastName: data.lastName, phone: data.phone ?? null, driverLicense: data.driverLicense ?? null, nationality: data.nationality ?? null, ...parsed },
|
})
|
||||||
|
|
||||||
|
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),
|
firstName: z.string().min(1),
|
||||||
lastName: z.string().min(1),
|
lastName: z.string().min(1),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
phone: z.string().optional(),
|
phone: z.string().min(1).max(30),
|
||||||
driverLicense: z.string().optional(),
|
driverLicense: z.string().min(1).max(50),
|
||||||
dateOfBirth: z.string().datetime().optional(),
|
dateOfBirth: z.string().datetime(),
|
||||||
licenseExpiry: z.string().datetime().optional(),
|
licenseExpiry: z.string().datetime(),
|
||||||
licenseIssuedAt: z.string().datetime().optional(),
|
licenseIssuedAt: z.string().datetime(),
|
||||||
nationality: z.string().optional(),
|
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(),
|
offerId: z.string().cuid().optional(),
|
||||||
promoCodeUsed: z.string().optional(),
|
promoCodeUsed: z.string().optional(),
|
||||||
notes: 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: {
|
export async function createBooking(slug: string, body: {
|
||||||
vehicleId: string; startDate: string; endDate: string
|
vehicleId: string; startDate: string; endDate: string
|
||||||
firstName: string; lastName: string; email: string; phone?: string
|
firstName: string; lastName: string; email: string; phone: string
|
||||||
driverLicense?: string; dateOfBirth?: string; licenseExpiry?: string; licenseIssuedAt?: string; nationality?: 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
|
offerId?: string; promoCodeUsed?: string; notes?: string; source?: string
|
||||||
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
selectedInsurancePolicyIds?: string[]; additionalDrivers?: any[]
|
||||||
}) {
|
}) {
|
||||||
@@ -113,9 +114,21 @@ export async function createBooking(slug: string, body: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customer = await repo.upsertCustomer(company.id, {
|
const customer = await repo.upsertCustomer(company.id, {
|
||||||
email: body.email, firstName: body.firstName, lastName: body.lastName, phone: body.phone,
|
email: body.email,
|
||||||
driverLicense: body.driverLicense, dateOfBirth: body.dateOfBirth,
|
firstName: body.firstName,
|
||||||
licenseExpiry: body.licenseExpiry, licenseIssuedAt: body.licenseIssuedAt, nationality: body.nationality,
|
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))
|
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000))
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ type Customer = {
|
|||||||
email: string
|
email: string
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
driverLicense?: string | null
|
driverLicense?: string | null
|
||||||
|
dateOfBirth?: string | null
|
||||||
|
nationality?: string | null
|
||||||
|
address?: Record<string, unknown> | null
|
||||||
licenseExpiry?: string | null
|
licenseExpiry?: string | null
|
||||||
licenseIssuedAt?: string | null
|
licenseIssuedAt?: string | null
|
||||||
licenseCountry?: string | null
|
licenseCountry?: string | null
|
||||||
|
licenseNumber?: string | null
|
||||||
licenseCategory?: string | null
|
licenseCategory?: string | null
|
||||||
licenseImageUrl?: string | null
|
licenseImageUrl?: string | null
|
||||||
}
|
}
|
||||||
@@ -62,6 +66,11 @@ export default function NewReservationPage() {
|
|||||||
const [newCustomerEmail, setNewCustomerEmail] = useState('')
|
const [newCustomerEmail, setNewCustomerEmail] = useState('')
|
||||||
const [newCustomerPhone, setNewCustomerPhone] = useState('')
|
const [newCustomerPhone, setNewCustomerPhone] = useState('')
|
||||||
const [driverLicense, setDriverLicense] = 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 [licenseExpiry, setLicenseExpiry] = useState('')
|
||||||
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
const [licenseIssuedAt, setLicenseIssuedAt] = useState('')
|
||||||
const [licenseCountry, setLicenseCountry] = useState('')
|
const [licenseCountry, setLicenseCountry] = useState('')
|
||||||
@@ -94,12 +103,16 @@ export default function NewReservationPage() {
|
|||||||
return: 'Return location',
|
return: 'Return location',
|
||||||
deposit: 'Deposit amount (MAD)',
|
deposit: 'Deposit amount (MAD)',
|
||||||
paymentMode: 'Payment mode',
|
paymentMode: 'Payment mode',
|
||||||
|
renterIdentity: 'Renter identity',
|
||||||
driverLicenseInfo: 'Driver license information',
|
driverLicenseInfo: 'Driver license information',
|
||||||
driverLicense: 'Driver license number',
|
driverLicense: 'Driver license number',
|
||||||
licenseExpiry: 'License expiry',
|
licenseExpiry: 'License expiry',
|
||||||
licenseIssuedAt: 'License issued at',
|
licenseIssuedAt: 'License issued at',
|
||||||
licenseCountry: 'License country',
|
licenseCountry: 'License country',
|
||||||
licenseCategory: 'License category',
|
licenseCategory: 'License category',
|
||||||
|
fullAddress: 'Full address',
|
||||||
|
identityDocumentNumber: 'CIN / Passport number',
|
||||||
|
internationalLicenseNumber: 'International permit number',
|
||||||
licenseImage: 'Driver license image',
|
licenseImage: 'Driver license image',
|
||||||
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
|
licenseImageHint: 'Upload a photo or scan of the primary driver license.',
|
||||||
licenseImageSelected: 'Selected file',
|
licenseImageSelected: 'Selected file',
|
||||||
@@ -108,7 +121,7 @@ export default function NewReservationPage() {
|
|||||||
addAdditionalDriver: 'Add additional driver',
|
addAdditionalDriver: 'Add additional driver',
|
||||||
additionalDriverInfo: 'Additional driver',
|
additionalDriverInfo: 'Additional driver',
|
||||||
dateOfBirth: 'Date of birth',
|
dateOfBirth: 'Date of birth',
|
||||||
nationality: 'License country',
|
nationality: 'Nationality',
|
||||||
notes: 'Notes',
|
notes: 'Notes',
|
||||||
notesPlaceholder: 'Optional notes…',
|
notesPlaceholder: 'Optional notes…',
|
||||||
create: 'Create booking',
|
create: 'Create booking',
|
||||||
@@ -148,12 +161,16 @@ export default function NewReservationPage() {
|
|||||||
return: 'Lieu de retour',
|
return: 'Lieu de retour',
|
||||||
deposit: 'Montant du dépôt (MAD)',
|
deposit: 'Montant du dépôt (MAD)',
|
||||||
paymentMode: 'Mode de paiement',
|
paymentMode: 'Mode de paiement',
|
||||||
|
renterIdentity: 'Identité du locataire',
|
||||||
driverLicenseInfo: 'Informations du permis',
|
driverLicenseInfo: 'Informations du permis',
|
||||||
driverLicense: 'Numéro du permis',
|
driverLicense: 'Numéro du permis',
|
||||||
licenseExpiry: 'Expiration du permis',
|
licenseExpiry: 'Expiration du permis',
|
||||||
licenseIssuedAt: 'Permis délivré le',
|
licenseIssuedAt: 'Permis délivré le',
|
||||||
licenseCountry: 'Pays du permis',
|
licenseCountry: 'Pays du permis',
|
||||||
licenseCategory: 'Catégorie du permis',
|
licenseCategory: 'Catégorie du permis',
|
||||||
|
fullAddress: 'Adresse complète',
|
||||||
|
identityDocumentNumber: 'N° CIN / passeport',
|
||||||
|
internationalLicenseNumber: 'N° de permis international',
|
||||||
licenseImage: 'Image du permis',
|
licenseImage: 'Image du permis',
|
||||||
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
|
licenseImageHint: 'Téléversez une photo ou un scan du permis du conducteur principal.',
|
||||||
licenseImageSelected: 'Fichier sélectionné',
|
licenseImageSelected: 'Fichier sélectionné',
|
||||||
@@ -162,7 +179,7 @@ export default function NewReservationPage() {
|
|||||||
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
addAdditionalDriver: 'Ajouter un conducteur supplémentaire',
|
||||||
additionalDriverInfo: 'Conducteur supplémentaire',
|
additionalDriverInfo: 'Conducteur supplémentaire',
|
||||||
dateOfBirth: 'Date de naissance',
|
dateOfBirth: 'Date de naissance',
|
||||||
nationality: 'Pays du permis',
|
nationality: 'Nationalité',
|
||||||
notes: 'Notes',
|
notes: 'Notes',
|
||||||
notesPlaceholder: 'Notes optionnelles…',
|
notesPlaceholder: 'Notes optionnelles…',
|
||||||
create: 'Créer la réservation',
|
create: 'Créer la réservation',
|
||||||
@@ -202,12 +219,16 @@ export default function NewReservationPage() {
|
|||||||
return: 'موقع التسليم',
|
return: 'موقع التسليم',
|
||||||
deposit: 'مبلغ العربون (MAD)',
|
deposit: 'مبلغ العربون (MAD)',
|
||||||
paymentMode: 'طريقة الدفع',
|
paymentMode: 'طريقة الدفع',
|
||||||
|
renterIdentity: 'بيانات المستأجر',
|
||||||
driverLicenseInfo: 'معلومات رخصة القيادة',
|
driverLicenseInfo: 'معلومات رخصة القيادة',
|
||||||
driverLicense: 'رقم رخصة القيادة',
|
driverLicense: 'رقم رخصة القيادة',
|
||||||
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
licenseExpiry: 'تاريخ انتهاء الرخصة',
|
||||||
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
licenseIssuedAt: 'تاريخ إصدار الرخصة',
|
||||||
licenseCountry: 'بلد الرخصة',
|
licenseCountry: 'بلد الرخصة',
|
||||||
licenseCategory: 'فئة الرخصة',
|
licenseCategory: 'فئة الرخصة',
|
||||||
|
fullAddress: 'العنوان الكامل',
|
||||||
|
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
||||||
|
internationalLicenseNumber: 'رقم الرخصة الدولية',
|
||||||
licenseImage: 'صورة رخصة القيادة',
|
licenseImage: 'صورة رخصة القيادة',
|
||||||
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
licenseImageHint: 'ارفع صورة أو مسحًا لرخصة السائق الأساسي.',
|
||||||
licenseImageSelected: 'الملف المحدد',
|
licenseImageSelected: 'الملف المحدد',
|
||||||
@@ -216,7 +237,7 @@ export default function NewReservationPage() {
|
|||||||
addAdditionalDriver: 'إضافة سائق إضافي',
|
addAdditionalDriver: 'إضافة سائق إضافي',
|
||||||
additionalDriverInfo: 'السائق الإضافي',
|
additionalDriverInfo: 'السائق الإضافي',
|
||||||
dateOfBirth: 'تاريخ الميلاد',
|
dateOfBirth: 'تاريخ الميلاد',
|
||||||
nationality: 'بلد الرخصة',
|
nationality: 'الجنسية',
|
||||||
notes: 'ملاحظات',
|
notes: 'ملاحظات',
|
||||||
notesPlaceholder: 'ملاحظات اختيارية…',
|
notesPlaceholder: 'ملاحظات اختيارية…',
|
||||||
create: 'إنشاء الحجز',
|
create: 'إنشاء الحجز',
|
||||||
@@ -272,10 +293,22 @@ export default function NewReservationPage() {
|
|||||||
|
|
||||||
const canSubmit = !!customerId && !!vehicleId && !!startDate && !!endDate
|
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(() => {
|
useEffect(() => {
|
||||||
const selected = customers.find((customer) => customer.id === customerId)
|
const selected = customers.find((customer) => customer.id === customerId)
|
||||||
if (!selected) return
|
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) : '')
|
setLicenseExpiry(selected.licenseExpiry ? selected.licenseExpiry.slice(0, 10) : '')
|
||||||
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
setLicenseIssuedAt(selected.licenseIssuedAt ? selected.licenseIssuedAt.slice(0, 10) : '')
|
||||||
setLicenseCountry(selected.licenseCountry ?? '')
|
setLicenseCountry(selected.licenseCountry ?? '')
|
||||||
@@ -318,6 +351,20 @@ export default function NewReservationPage() {
|
|||||||
setError(copy.required)
|
setError(copy.required)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!driverLicense.trim() ||
|
||||||
|
!customerDateOfBirth ||
|
||||||
|
!customerNationality.trim() ||
|
||||||
|
!customerFullAddress.trim() ||
|
||||||
|
!customerIdentityDocumentNumber.trim() ||
|
||||||
|
!licenseExpiry ||
|
||||||
|
!licenseIssuedAt ||
|
||||||
|
!licenseCountry.trim() ||
|
||||||
|
!licenseCategory.trim()
|
||||||
|
) {
|
||||||
|
setError(copy.required)
|
||||||
|
return
|
||||||
|
}
|
||||||
setSavingCustomer(true)
|
setSavingCustomer(true)
|
||||||
try {
|
try {
|
||||||
const created = await apiFetch<Customer>('/customers', {
|
const created = await apiFetch<Customer>('/customers', {
|
||||||
@@ -329,11 +376,19 @@ export default function NewReservationPage() {
|
|||||||
lastNameAr: newCustomerLastName.ar.trim() || undefined,
|
lastNameAr: newCustomerLastName.ar.trim() || undefined,
|
||||||
email: newCustomerEmail.trim(),
|
email: newCustomerEmail.trim(),
|
||||||
phone: newCustomerPhone.trim(),
|
phone: newCustomerPhone.trim(),
|
||||||
driverLicense: driverLicense.trim() || undefined,
|
driverLicense: driverLicense.trim(),
|
||||||
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
|
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
|
||||||
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
|
nationality: customerNationality.trim(),
|
||||||
licenseCountry: licenseCountry.trim() || undefined,
|
address: {
|
||||||
licenseCategory: licenseCategory.trim() || undefined,
|
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])
|
setCustomers((prev) => [created, ...prev])
|
||||||
@@ -368,6 +423,20 @@ export default function NewReservationPage() {
|
|||||||
setError(copy.invalidDates)
|
setError(copy.invalidDates)
|
||||||
return
|
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())) {
|
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
|
||||||
setError(copy.required)
|
setError(copy.required)
|
||||||
return
|
return
|
||||||
@@ -378,11 +447,19 @@ export default function NewReservationPage() {
|
|||||||
await apiFetch(`/customers/${customerId}`, {
|
await apiFetch(`/customers/${customerId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
driverLicense: driverLicense.trim() || undefined,
|
driverLicense: driverLicense.trim(),
|
||||||
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
|
dateOfBirth: new Date(customerDateOfBirth).toISOString(),
|
||||||
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
|
nationality: customerNationality.trim(),
|
||||||
licenseCountry: licenseCountry.trim() || undefined,
|
address: {
|
||||||
licenseCategory: licenseCategory.trim() || undefined,
|
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>
|
</label>
|
||||||
</div>
|
</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">
|
<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>
|
<p className="text-sm font-semibold text-slate-900">{copy.driverLicenseInfo}</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label className="space-y-1">
|
<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" />
|
<input value={driverLicense} onChange={(e) => setDriverLicense(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<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" />
|
<input value={licenseCountry} onChange={(e) => setLicenseCountry(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<label className="space-y-1">
|
<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" />
|
<input type="date" value={licenseIssuedAt} onChange={(e) => setLicenseIssuedAt(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<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" />
|
<input type="date" value={licenseExpiry} onChange={(e) => setLicenseExpiry(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-1">
|
<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" />
|
<input value={licenseCategory} onChange={(e) => setLicenseCategory(e.target.value)} className="input-field" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type VehicleSheetPoint = {
|
|||||||
|
|
||||||
const SHEET_WIDTH = 573
|
const SHEET_WIDTH = 573
|
||||||
const SHEET_HEIGHT = 876
|
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> = {
|
const severityColors: Record<VehicleSheetPoint['severity'], string> = {
|
||||||
MINOR: '#f59e0b',
|
MINOR: '#f59e0b',
|
||||||
|
|||||||
@@ -16,10 +16,22 @@ type Props = {
|
|||||||
const copy = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
title: 'Reserve this vehicle',
|
title: 'Reserve this vehicle',
|
||||||
|
renterIdentity: 'Renter identity',
|
||||||
firstName: 'First name',
|
firstName: 'First name',
|
||||||
lastName: 'Last name',
|
lastName: 'Last name',
|
||||||
|
dateOfBirth: 'Date of birth',
|
||||||
|
nationality: 'Nationality',
|
||||||
|
identityDocumentNumber: 'CIN / Passport number',
|
||||||
|
fullAddress: 'Full address',
|
||||||
|
driverLicenseSection: 'Driver license',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
phone: 'Phone',
|
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',
|
pickupLocation: 'Pick-up location',
|
||||||
returnLocation: 'Drop-off location',
|
returnLocation: 'Drop-off location',
|
||||||
sameDropoff: 'Same drop-off',
|
sameDropoff: 'Same drop-off',
|
||||||
@@ -42,10 +54,22 @@ const copy = {
|
|||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: 'Réserver ce véhicule',
|
title: 'Réserver ce véhicule',
|
||||||
|
renterIdentity: 'Identité du locataire',
|
||||||
firstName: 'Prénom',
|
firstName: 'Prénom',
|
||||||
lastName: 'Nom',
|
lastName: 'Nom',
|
||||||
|
dateOfBirth: 'Date de naissance',
|
||||||
|
nationality: 'Nationalité',
|
||||||
|
identityDocumentNumber: 'N° CIN / passeport',
|
||||||
|
fullAddress: 'Adresse complète',
|
||||||
|
driverLicenseSection: 'Permis de conduire',
|
||||||
email: 'E-mail',
|
email: 'E-mail',
|
||||||
phone: 'Téléphone',
|
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',
|
pickupLocation: 'Lieu de départ',
|
||||||
returnLocation: 'Lieu de retour',
|
returnLocation: 'Lieu de retour',
|
||||||
sameDropoff: 'Même retour',
|
sameDropoff: 'Même retour',
|
||||||
@@ -68,10 +92,22 @@ const copy = {
|
|||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
title: 'احجز هذه السيارة',
|
title: 'احجز هذه السيارة',
|
||||||
|
renterIdentity: 'بيانات المستأجر',
|
||||||
firstName: 'الاسم الأول',
|
firstName: 'الاسم الأول',
|
||||||
lastName: 'اسم العائلة',
|
lastName: 'اسم العائلة',
|
||||||
|
dateOfBirth: 'تاريخ الميلاد',
|
||||||
|
nationality: 'الجنسية',
|
||||||
|
identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر',
|
||||||
|
fullAddress: 'العنوان الكامل',
|
||||||
|
driverLicenseSection: 'رخصة القيادة',
|
||||||
email: 'البريد الإلكتروني',
|
email: 'البريد الإلكتروني',
|
||||||
phone: 'الهاتف',
|
phone: 'الهاتف',
|
||||||
|
driverLicense: 'رقم الرخصة',
|
||||||
|
licenseIssuedAt: 'تاريخ الإصدار',
|
||||||
|
licenseExpiry: 'تاريخ الانتهاء',
|
||||||
|
licenseCountry: 'بلد الإصدار',
|
||||||
|
licenseCategory: 'فئة الرخصة',
|
||||||
|
internationalLicenseNumber: 'رقم الرخصة الدولية (اختياري)',
|
||||||
pickupLocation: 'موقع الاستلام',
|
pickupLocation: 'موقع الاستلام',
|
||||||
returnLocation: 'موقع الإرجاع',
|
returnLocation: 'موقع الإرجاع',
|
||||||
sameDropoff: 'نفس موقع الإرجاع',
|
sameDropoff: 'نفس موقع الإرجاع',
|
||||||
@@ -110,8 +146,18 @@ export default function BookingForm({
|
|||||||
|
|
||||||
const [firstName, setFirstName] = useState('')
|
const [firstName, setFirstName] = useState('')
|
||||||
const [lastName, setLastName] = 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 [email, setEmail] = useState('')
|
||||||
const [phone, setPhone] = 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 [pickupLocation, setPickupLocation] = useState(pickupOptions[0] ?? '')
|
||||||
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>('same')
|
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>('same')
|
||||||
const [returnLocation, setReturnLocation] = useState(dropoffOptions[0] ?? '')
|
const [returnLocation, setReturnLocation] = useState(dropoffOptions[0] ?? '')
|
||||||
@@ -131,7 +177,23 @@ export default function BookingForm({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError(null)
|
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)
|
setError(t.required)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,8 +216,18 @@ export default function BookingForm({
|
|||||||
companySlug,
|
companySlug,
|
||||||
firstName: firstName.trim(),
|
firstName: firstName.trim(),
|
||||||
lastName: lastName.trim(),
|
lastName: lastName.trim(),
|
||||||
|
dateOfBirth: new Date(dateOfBirth).toISOString(),
|
||||||
|
nationality: nationality.trim(),
|
||||||
|
identityDocumentNumber: identityDocumentNumber.trim(),
|
||||||
|
fullAddress: fullAddress.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
phone: phone.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(),
|
startDate: start.toISOString(),
|
||||||
endDate: end.toISOString(),
|
endDate: end.toISOString(),
|
||||||
pickupLocation: pickupLocation || undefined,
|
pickupLocation: pickupLocation || undefined,
|
||||||
@@ -190,22 +262,68 @@ export default function BookingForm({
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : 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">
|
<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
|
<input
|
||||||
required
|
required
|
||||||
value={firstName}
|
value={identityDocumentNumber}
|
||||||
onChange={(e) => setFirstName(e.target.value)}
|
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"
|
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">
|
<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>
|
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.fullAddress} <span className="text-red-500">*</span></label>
|
||||||
<input
|
<textarea
|
||||||
required
|
required
|
||||||
value={lastName}
|
value={fullAddress}
|
||||||
onChange={(e) => setLastName(e.target.value)}
|
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"
|
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>
|
||||||
@@ -222,6 +340,72 @@ export default function BookingForm({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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>
|
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.phone} <span className="text-red-500">*</span></label>
|
||||||
<input
|
<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