diff --git a/apps/api/src/lib/inputValidation.ts b/apps/api/src/lib/inputValidation.ts new file mode 100644 index 0000000..aeef482 --- /dev/null +++ b/apps/api/src/lib/inputValidation.ts @@ -0,0 +1,161 @@ +/** + * User Input Validation and Formatting Library + * + * Implements the rules defined in docs/input_validation_plan.md. + * Provides formatting functions and a Zod-integrated pipeline that: + * 1. Removes disallowed characters + * 2. Trims leading/trailing whitespace + * 3. Validates format (email) + * 4. Applies case transformation + * 5. Truncates to max length + */ + +// ─── Character class definitions ─────────────────────────────── + +const LETTERS_NUMBERS = 'a-zA-Z0-9' + +/** Letters, numbers, spaces, hyphen, slash */ +const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/` + +/** Letters, spaces, hyphen */ +const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-' + +/** Letters, numbers, spaces, hyphen, slash, comma (full address / company names) */ +const EXTENDED_TEXT = `${BASE_TEXT},` + +/** Letters only */ +const LETTERS_ONLY = 'a-zA-Z' + +// ─── Field type definitions ──────────────────────────────────── + +type FieldType = + | 'name' + | 'nationality' + | 'streetAddress' + | 'city' + | 'pickupLocation' + | 'returnLocation' + | 'country' + | 'fullAddress' + | 'commercialName' + | 'legalCompanyName' + | 'email' + | 'licensePlate' + | 'vin' + | 'cin' + | 'passportNumber' + | 'internationalPermitNumber' + | 'driverLicenseNumber' + | 'licenseCategory' + | 'iceNumber' + | 'commercialRegistry' + | 'carMark' + | 'carModel' + | 'carColor' + | 'zipCode' + +interface FieldConfig { + maxLength: number + allowedPattern: RegExp + transform: (value: string) => string +} + +// ─── Formatting functions ────────────────────────────────────── + +/** Uppercase first letter of each word, lowercase the rest */ +export function toTitleCase(str: string): string { + return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) +} + +/** Uppercase first letter of every word (same logic as toTitleCase at word boundaries) */ +export function toTitleCaseAll(str: string): string { + return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) +} + +export function toLowerCase(str: string): string { + return str.toLowerCase() +} + +/** Uppercase letters only, leave numbers unchanged */ +export function toUpperCase(str: string): string { + return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase()) +} + +/** Identity — no transformation */ +export function preserveOriginal(str: string): string { + return str +} + +// ─── Field configuration ─────────────────────────────────────── + +const FIELD_CONFIGS: Record = { + // Group 1: Title Case + name: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + nationality: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + streetAddress: { maxLength: 100, allowedPattern: new RegExp(`^[${BASE_TEXT}]*$`), transform: toTitleCase }, + city: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + pickupLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + returnLocation: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + country: { maxLength: 50, allowedPattern: new RegExp(`^[${LETTERS_SPACES_HYPHEN}]*$`), transform: toTitleCase }, + + // Group 2: Title Case All + fullAddress: { maxLength: 200, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, + commercialName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, + legalCompanyName: { maxLength: 100, allowedPattern: new RegExp(`^[${EXTENDED_TEXT}]*$`), transform: toTitleCaseAll }, + + // Group 3: Lowercase — email handled separately with format validation + email: { maxLength: 100, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}@._%\\+\\-]*$`), transform: toLowerCase }, + + // Group 4: Uppercase (letters and numbers only, no spaces/hyphens) + licensePlate: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + vin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + cin: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + passportNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + licenseCategory: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + iceNumber: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + commercialRegistry: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}]*$`), transform: toUpperCase }, + + // Group 5: Car fields + carMark: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, + carModel: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, + carColor: { maxLength: 30, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\s\\-]*$`), transform: toTitleCase }, + + // Group 6: Preserved + zipCode: { maxLength: 10, allowedPattern: new RegExp(`^[${LETTERS_NUMBERS}\\-]*$`), transform: preserveOriginal }, +} + +// ─── Public API ──────────────────────────────────────────────── + +/** Full processing pipeline: sanitize → trim → format → truncate */ +export function sanitizeAndFormat(value: string, fieldType: FieldType): string { + const config = FIELD_CONFIGS[fieldType] + + // Step 1: Remove disallowed characters + let sanitized = '' + for (const char of value) { + if (config.allowedPattern.test(char)) { + sanitized += char + } + } + + // Step 2: Trim + sanitized = sanitized.trim() + + // Step 3: Apply case transformation + const formatted = config.transform(sanitized) + + // Step 4: Truncate + if (formatted.length > config.maxLength) { + return formatted.slice(0, config.maxLength) + } + + return formatted +} + +/** Get the max length for a given field type */ +export function getMaxLength(fieldType: FieldType): number { + return FIELD_CONFIGS[fieldType].maxLength +} + diff --git a/apps/api/src/lib/zodValidation.ts b/apps/api/src/lib/zodValidation.ts new file mode 100644 index 0000000..cb1370b --- /dev/null +++ b/apps/api/src/lib/zodValidation.ts @@ -0,0 +1,158 @@ +/** + * Zod integration helpers for the input validation/formatting library. + * + * Provides pre-built Zod refinements and transforms that can be + * chained onto `.string()` schemas throughout the API. + * + * Usage: + * import { textField, emailField } from '../lib/zodValidation' + * const schema = z.object({ + * firstName: textField('name'), + * email: emailField(), + * }) + */ + +import { z } from 'zod' +import { sanitizeAndFormat, getMaxLength } from './inputValidation' +import type { FieldType } from './inputValidation' + +// ─── Character validation (inline refine) ────────────────────── + +/** Global allowed character pattern from the validation plan */ +const GLOBAL_ALLOWED = /^[a-zA-Z0-9@\-/\s]*$/ + +export const globalCharRefine = (val: string) => + GLOBAL_ALLOWED.test(val) || { message: 'Only letters, numbers, @, -, and / are allowed' } + +// ─── Email validation ────────────────────────────────────────── + +const EMAIL_REGEX = /^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/ + +export const emailFormatRefine = (val: string) => + EMAIL_REGEX.test(val) || { message: 'Please enter a valid email address' } + +// ─── Pre-built Zod string wrappers ───────────────────────────── + +/** + * Returns the base Zod chain: .string() → .min(1) → character refine → trim → transform + * If optional is true, wraps in .optional() at the end. + */ +function baseTextField(fieldType: FieldType, optional = false) { + const maxLength = getMaxLength(fieldType) + let schema = z + .string() + .min(1, { message: 'This field is required' }) + .refine(globalCharRefine) + .trim() + .transform((val) => sanitizeAndFormat(val, fieldType)) + .refine( + (val) => val.length <= maxLength, + { message: `Maximum ${maxLength} characters allowed` } + ) + + if (optional) { + // For optional fields: allow undefined input, but if a value is provided, apply all rules + return z + .string() + .optional() + .transform((val) => { + if (val === undefined || val === null) return undefined + return sanitizeAndFormat(val, fieldType) + }) + .refine( + (val) => val === undefined || val.length === 0 || (globalCharRefine(val as string) === true), + { message: 'Only letters, numbers, @, -, and / are allowed' } + ) + } + + return schema +} + +/** + * Text field with title case formatting. + * fieldType must be one of: name, nationality, streetAddress, city, + * pickupLocation, returnLocation, country + */ +export function textField(fieldType: FieldType) { + return baseTextField(fieldType, false) +} + +/** Optional version of textField */ +export function optionalTextField(fieldType: FieldType) { + return baseTextField(fieldType, true) +} + +/** + * Title-case-all fields: fullAddress, commercialName, legalCompanyName + */ +export function titleCaseAllField(fieldType: FieldType) { + return baseTextField(fieldType, false) +} + +export function optionalTitleCaseAllField(fieldType: FieldType) { + return baseTextField(fieldType, true) +} + +/** + * Uppercase fields (letters only in allowed chars): licensePlate, vin, cin, + * passportNumber, internationalPermitNumber, driverLicenseNumber, + * licenseCategory, iceNumber, commercialRegistry + */ +export function upperField(fieldType: FieldType) { + return baseTextField(fieldType, false) +} + +export function optionalUpperField(fieldType: FieldType) { + return baseTextField(fieldType, true) +} + +/** + * Car fields (title case, letters/numbers/spaces/hyphen allowed): + * carMark, carModel, carColor + */ +export function carTextField(fieldType: FieldType) { + return baseTextField(fieldType, false) +} + +export function optionalCarTextField(fieldType: FieldType) { + return baseTextField(fieldType, true) +} + +/** + * Preserved format field: zipCode + */ +export function zipField() { + return baseTextField('zipCode', false) +} + +export function optionalZipField() { + return baseTextField('zipCode', true) +} + +/** + * Email field with full validation chain + */ +export function emailField() { + return z + .string() + .min(1, { message: 'Email is required' }) + .refine(globalCharRefine) + .trim() + .transform((val) => sanitizeAndFormat(val, 'email')) + .refine(emailFormatRefine) +} + +export function optionalEmailField() { + return z + .string() + .optional() + .transform((val) => { + if (val === undefined || val === null || val.trim() === '') return undefined + return sanitizeAndFormat(val, 'email') + }) + .refine( + (val) => val === undefined || emailFormatRefine(val as string) === true, + { message: 'Please enter a valid email address' } + ) +} + diff --git a/apps/api/src/modules/marketplace/marketplace.repo.ts b/apps/api/src/modules/marketplace/marketplace.repo.ts index bd56e64..66fb7e0 100644 --- a/apps/api/src/modules/marketplace/marketplace.repo.ts +++ b/apps/api/src/modules/marketplace/marketplace.repo.ts @@ -48,6 +48,17 @@ export async function findVehicleForMarketplace(vehicleId: string, companySlug: }) } +export async function findVehicleForMarketplaceById(vehicleId: string) { + return prisma.vehicle.findFirst({ + where: { + id: vehicleId, + isPublished: true, + company: { status: { in: ['ACTIVE', 'TRIALING'] }, brand: { isListedOnMarketplace: true } }, + }, + include: { company: { include: { brand: true } } }, + }) +} + export async function upsertMarketplaceCustomer(companyId: string, data: { email: string firstName: string @@ -107,13 +118,45 @@ export async function upsertMarketplaceCustomer(companyId: string, data: { export async function createMarketplaceReservation(data: { companyId: string; vehicleId: string; customerId: string startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null - dailyRate: number; totalDays: number; totalAmount: number; notes?: string + dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string }) { return prisma.reservation.create({ data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' }, }) } +export async function createMarketplaceFunnelEvent(data: { + eventName: string + companySlug: string + vehicleId: string + renterId?: string | null + sessionId?: string + path?: string + metadata?: Record +}) { + const vehicle = await prisma.vehicle.findFirst({ + where: { + id: data.vehicleId, + isPublished: true, + company: { slug: data.companySlug, status: { in: ['ACTIVE', 'TRIALING'] } }, + }, + select: { companyId: true }, + }) + + return prisma.marketplaceFunnelEvent.create({ + data: { + eventName: data.eventName, + companyId: vehicle?.companyId ?? null, + companySlug: data.companySlug, + vehicleId: data.vehicleId, + renterId: data.renterId ?? null, + sessionId: data.sessionId, + path: data.path, + metadata: (data.metadata ?? {}) as any, + }, + }) +} + export async function findCompanyPage(slug: string) { return prisma.company.findFirst({ where: { slug, status: { in: ['ACTIVE', 'TRIALING'] } }, diff --git a/apps/api/src/modules/marketplace/marketplace.routes.ts b/apps/api/src/modules/marketplace/marketplace.routes.ts index f6e3ad1..9c55cb2 100644 --- a/apps/api/src/modules/marketplace/marketplace.routes.ts +++ b/apps/api/src/modules/marketplace/marketplace.routes.ts @@ -7,6 +7,7 @@ import * as service from './marketplace.service' import { paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema, reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema, + vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema, } from './marketplace.schemas' const router = Router() @@ -65,6 +66,18 @@ router.post('/reservations', async (req, res, next) => { } }) +router.post('/events', async (req, res, next) => { + try { + const body = parseBody(marketplaceFunnelEventSchema, req) + ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId)) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + router.get('/review/:token', async (req, res, next) => { try { const { token } = parseParams(reviewTokenSchema, req) @@ -137,6 +150,19 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => { } }) +router.get('/:slug/vehicles/:id/availability', async (req, res, next) => { + try { + const { slug, id } = parseParams(vehicleParamSchema, req) + const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req) + ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate })) + } catch (err) { + if (isDatabaseUnavailableError(err)) { + return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 }) + } + next(err) + } +}) + router.get('/:slug/offers', async (req, res, next) => { try { const { slug } = parseParams(slugParamSchema, req) diff --git a/apps/api/src/modules/marketplace/marketplace.schemas.ts b/apps/api/src/modules/marketplace/marketplace.schemas.ts index 4cb52ac..be2b7fa 100644 --- a/apps/api/src/modules/marketplace/marketplace.schemas.ts +++ b/apps/api/src/modules/marketplace/marketplace.schemas.ts @@ -24,9 +24,14 @@ export const companiesQuerySchema = z.object({ hasOffer: z.string().optional(), }) +export const vehicleAvailabilityQuerySchema = z.object({ + startDate: z.string().datetime(), + endDate: z.string().datetime(), +}) + export const marketplaceReservationSchema = z.object({ vehicleId: z.string().cuid(), - companySlug: z.string().trim().max(100), + companySlug: z.string().trim().max(100).optional(), firstName: z.string().min(1).max(100), lastName: z.string().min(1).max(100), email: z.string().email(), @@ -51,6 +56,23 @@ export const marketplaceReservationSchema = z.object({ language: z.enum(['en', 'fr', 'ar']).default('fr'), }) +export const marketplaceFunnelEventSchema = z.object({ + eventName: z.enum([ + 'booking_form_viewed', + 'trip_dates_selected', + 'contact_details_started', + 'driver_details_expanded', + 'booking_request_submitted', + 'booking_request_failed', + 'booking_request_success', + ]), + companySlug: z.string().trim().max(100), + vehicleId: z.string().cuid(), + sessionId: z.string().trim().max(100).optional(), + path: z.string().trim().max(500).optional(), + metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()])).optional(), +}) + export const reviewBodySchema = z.object({ overallRating: z.number().int().min(1).max(5), vehicleRating: z.number().int().min(1).max(5).optional(), diff --git a/apps/api/src/modules/marketplace/marketplace.service.ts b/apps/api/src/modules/marketplace/marketplace.service.ts index 252874e..f5e4a0a 100644 --- a/apps/api/src/modules/marketplace/marketplace.service.ts +++ b/apps/api/src/modules/marketplace/marketplace.service.ts @@ -120,8 +120,42 @@ export async function searchVehicles(params: { })) } +export async function getMarketplaceVehicleAvailability( + slug: string, + vehicleId: string, + params: { startDate: string; endDate: string }, +) { + const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug) + if (!vehicle) throw new NotFoundError('Vehicle not found') + + const startDate = new Date(params.startDate) + const endDate = new Date(params.endDate) + + if (endDate <= startDate) { + throw new AppError('End date must be after start date', 400, 'invalid_dates') + } + + const availability = await getVehicleAvailabilitySummary(vehicle.id, { + companyId: vehicle.companyId, + range: { startDate, endDate }, + }) + + return { + available: availability.available, + availabilityStatus: availability.status, + nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null, + blockingReason: availability.blockingReason, + } +} + +function generateBookingReference() { + const year = new Date().getUTCFullYear() + const random = Math.random().toString(36).slice(2, 8).toUpperCase() + return `BK-${year}-${random}` +} + 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; dateOfBirth?: string; nationality?: string identityDocumentNumber?: string; fullAddress?: string driverLicense?: string; licenseExpiry?: string; licenseIssuedAt?: string @@ -136,7 +170,7 @@ export async function createMarketplaceReservation(body: { throw new AppError('End date must be after start date', 400, 'invalid_dates') } - const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug) + const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId) if (!vehicle) throw new NotFoundError('Vehicle not found') const pickupLocation = normalizeLocation(body.pickupLocation) @@ -185,8 +219,18 @@ export async function createMarketplaceReservation(body: { const totalAmount = vehicle.dailyRate * totalDays const reservation = await repo.createMarketplaceReservation({ - companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id, - startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes, + companyId: vehicle.companyId, + vehicleId: body.vehicleId, + customerId: customer.id, + startDate, + endDate, + pickupLocation, + returnLocation, + dailyRate: vehicle.dailyRate, + totalDays, + totalAmount, + notes: body.notes, + bookingReference: generateBookingReference(), }) const lang = (body.language ?? 'fr') as Lang @@ -214,7 +258,32 @@ export async function createMarketplaceReservation(body: { channels: ['IN_APP'], }).catch(() => null) - return { reservationId: reservation.id } + return { + reservationId: reservation.id, + bookingReference: (reservation as any).bookingReference ?? null, + companyName, + vehicleName: `${vehicle.year} ${vehicle.make} ${vehicle.model}`, + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + status: 'PENDING', + message: 'The company will review your request.', + } +} + +export async function trackMarketplaceFunnelEvent(body: { + eventName: string + companySlug: string + vehicleId: string + sessionId?: string + path?: string + metadata?: Record +}, renterId?: string) { + await repo.createMarketplaceFunnelEvent({ + ...body, + renterId: renterId ?? null, + }) + + return { success: true } } export async function getCompanyPage(slug: string) { diff --git a/apps/api/src/modules/marketplace/marketplace.test.ts b/apps/api/src/modules/marketplace/marketplace.test.ts index 36e4c90..8cac703 100644 --- a/apps/api/src/modules/marketplace/marketplace.test.ts +++ b/apps/api/src/modules/marketplace/marketplace.test.ts @@ -7,6 +7,7 @@ vi.mock('./marketplace.repo', () => ({ findListedCompanies: vi.fn(), findPublishedVehicles: vi.fn(), findVehicleForMarketplace: vi.fn(), + findVehicleForMarketplaceById: vi.fn(), upsertMarketplaceCustomer: vi.fn(), createMarketplaceReservation: vi.fn(), findCompanyPage: vi.fn(), @@ -136,13 +137,14 @@ describe('createMarketplaceReservation', () => { } it('creates reservation and returns reservationId', async () => { - vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any) + vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null }) vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any) - vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any) + vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any) const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' }) expect(result.reservationId).toBe('r-1') + expect(result.bookingReference).toBe('BK-2026-ABC123') expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({ identityDocumentNumber: 'CIN123456', fullAddress: '123 Avenue Hassan II, Casablanca', @@ -155,20 +157,20 @@ describe('createMarketplaceReservation', () => { }) it('throws NotFoundError when vehicle not found', async () => { - vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null) + vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null) await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError) }) it('throws AppError when vehicle is unavailable', async () => { - vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any) + vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' }) await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' }) }) it('rejects different return locations when the vehicle requires same drop-off', async () => { - vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any) + vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any) await expect( createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }), diff --git a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts index f99e14d..01163f2 100644 --- a/apps/api/src/modules/reservations/reservation.lifecycle.service.ts +++ b/apps/api/src/modules/reservations/reservation.lifecycle.service.ts @@ -5,7 +5,7 @@ import { validateLicense } from '../../services/licenseValidationService' import { sendNotification, sendTransactionalEmail } from '../../services/notificationService' import { reviewRequestEmail, type Lang } from '../../lib/emailTranslations' import { coerceNotificationLocale } from '../../services/notificationLocalizationService' -import { buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' +import { buildBookingRequestProgress, buildReservationWorkflow, parseReservationExtras, serializeReservationForDashboard } from './reservation.presenter' import * as repo from './reservation.repo' function buildPickupAddress(value: unknown) { @@ -47,7 +47,6 @@ async function assertLicenseCompliance(reservationId: string, companyId: string) export async function confirmReservation(id: string, companyId: string) { const reservation = await repo.findByIdSimple(id, companyId) if (reservation.status !== 'DRAFT') throw new AppError('Only DRAFT reservations can be confirmed', 400, 'invalid_status') - await assertLicenseCompliance(reservation.id, companyId) const updated = await repo.updateById(id, { status: 'CONFIRMED' }) await repo.updateVehicleStatus(reservation.vehicleId, 'RESERVED') @@ -97,6 +96,44 @@ export async function confirmReservation(id: string, companyId: string) { }, }).catch(() => null) + if (reservation.source === 'MARKETPLACE') { + const progress = buildBookingRequestProgress({ + source: reservation.source, + status: 'CONFIRMED', + paymentStatus: updated.paymentStatus, + customer, + }) + const reminderChannels = reservation.renterId ? ['EMAIL', 'IN_APP'] as const : ['EMAIL'] as const + + if (progress.documentsRequired) { + await sendNotification({ + type: 'DOCUMENTS_REQUIRED', + companyId, + renterId: reservation.renterId ?? undefined, + email: customer.email, + channels: [...reminderChannels], + locale: reservation.renterId ? undefined : fallbackLocale, + title: 'Driver documents still needed', + body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} confirmed availability. Please send the remaining driver documents so the booking can move forward.`, + data: { reservationId: reservation.id, missingItems: progress.documentsMissing }, + }).catch(() => null) + } + + if (progress.paymentRequired) { + await sendNotification({ + type: 'PAYMENT_REQUIRED', + companyId, + renterId: reservation.renterId ?? undefined, + email: customer.email, + channels: [...reminderChannels], + locale: reservation.renterId ? undefined : fallbackLocale, + title: 'Payment or deposit is still pending', + body: `${company?.brand?.displayName ?? company?.name ?? 'The rental company'} accepted your request. Complete any required payment or deposit to finalize the booking.`, + data: { reservationId: reservation.id, paymentStatus: updated.paymentStatus }, + }).catch(() => null) + } + } + return updated } diff --git a/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts b/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts index ef9369d..a45a79e 100644 --- a/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts +++ b/apps/api/src/modules/reservations/reservation.presenter.boundary.test.ts @@ -60,10 +60,19 @@ describe('reservation.presenter boundary behavior', () => { const result = serializeReservationForDashboard({ id: 'reservation_1', status: 'DRAFT', + source: 'MARKETPLACE', contractNumber: null, invoiceNumber: null, + paymentStatus: 'UNPAID', extras: { paymentMode: 'CARD' }, - customer: { id: 'customer_1', licenseImageUrl: '/storage/raw-license.jpg' }, + customer: { + id: 'customer_1', + driverLicense: null, + dateOfBirth: null, + address: {}, + licenseImageUrl: '/storage/raw-license.jpg', + licenseValidationStatus: 'PENDING', + }, }) expect(result.paymentMode).toBe('CARD') @@ -75,10 +84,19 @@ describe('reservation.presenter boundary behavior', () => { const result = serializeReservationForDashboard({ id: 'reservation_1', status: 'DRAFT', + source: 'MARKETPLACE', contractNumber: null, invoiceNumber: null, + paymentStatus: 'UNPAID', extras: { paymentMode: 42 }, - customer: { id: 'customer_1', licenseImageUrl: null }, + customer: { + id: 'customer_1', + driverLicense: null, + dateOfBirth: null, + address: {}, + licenseImageUrl: null, + licenseValidationStatus: 'PENDING', + }, }) expect(result.paymentMode).toBeNull() diff --git a/apps/api/src/modules/reservations/reservation.presenter.test.ts b/apps/api/src/modules/reservations/reservation.presenter.test.ts index 584a626..b5a43b5 100644 --- a/apps/api/src/modules/reservations/reservation.presenter.test.ts +++ b/apps/api/src/modules/reservations/reservation.presenter.test.ts @@ -19,12 +19,18 @@ describe('serializeReservationForDashboard', () => { const result = serializeReservationForDashboard({ id: 'reservation-1', status: 'CONFIRMED', + source: 'MARKETPLACE', contractNumber: null, invoiceNumber: null, + paymentStatus: 'UNPAID', extras: {}, customer: { id: 'customer-1', + driverLicense: 'DL-1', + dateOfBirth: new Date('1990-01-01T00:00:00.000Z'), + address: { identityDocumentNumber: 'CIN-1', fullAddress: 'Casablanca' }, licenseImageUrl: 'http://localhost:4000/storage/companies/company-1/customers/customer-1/license.jpg', + licenseValidationStatus: 'APPROVED', }, }) diff --git a/apps/api/src/modules/reservations/reservation.presenter.ts b/apps/api/src/modules/reservations/reservation.presenter.ts index 20c3c2c..3d87d9f 100644 --- a/apps/api/src/modules/reservations/reservation.presenter.ts +++ b/apps/api/src/modules/reservations/reservation.presenter.ts @@ -10,6 +10,12 @@ export function normalizeOptionalString(value: string | null | undefined): strin return trimmed || null } +function readAddressField(address: unknown, key: string): string | null { + const extras = parseReservationExtras(address) + const value = extras[key] + return typeof value === 'string' && value.trim() ? value.trim() : null +} + export function buildReservationWorkflow(reservation: { status: string contractNumber: string | null @@ -33,16 +39,95 @@ export function buildReservationWorkflow(reservation: { } } +export function buildBookingRequestProgress(reservation: { + source: string + status: string + paymentStatus?: string | null + customer?: { + driverLicense?: string | null + dateOfBirth?: Date | null + address?: unknown + licenseImageUrl?: string | null + licenseValidationStatus?: string | null + } | null +}) { + const customer = reservation.customer + const paymentStatus = reservation.paymentStatus ?? 'UNPAID' + const identityDocumentNumber = readAddressField(customer?.address, 'identityDocumentNumber') + const fullAddress = readAddressField(customer?.address, 'fullAddress') + + const documentsMissing: string[] = [] + + if (!customer?.dateOfBirth) documentsMissing.push('DATE_OF_BIRTH') + if (!identityDocumentNumber) documentsMissing.push('IDENTITY_DOCUMENT') + if (!fullAddress) documentsMissing.push('FULL_ADDRESS') + if (!customer?.driverLicense) documentsMissing.push('DRIVER_LICENSE') + if (!customer?.licenseImageUrl) documentsMissing.push('LICENSE_IMAGE') + if ((customer?.licenseValidationStatus ?? 'PENDING') !== 'APPROVED') { + documentsMissing.push('LICENSE_APPROVAL') + } + + const companyConfirmed = !['DRAFT', 'CANCELLED', 'NO_SHOW'].includes(reservation.status) + const documentsRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && documentsMissing.length > 0 + const paymentRequired = reservation.source === 'MARKETPLACE' && companyConfirmed && !['PAID', 'COMPLETED'].includes(paymentStatus) + + let stage: 'REQUEST_SENT' | 'COMPANY_CONFIRMED' | 'DOCUMENTS_REQUIRED' | 'PAYMENT_REQUIRED' | 'BOOKING_CONFIRMED' | 'CANCELLED' + if (['CANCELLED', 'NO_SHOW'].includes(reservation.status)) stage = 'CANCELLED' + else if (!companyConfirmed) stage = 'REQUEST_SENT' + else if (documentsRequired) stage = 'DOCUMENTS_REQUIRED' + else if (paymentRequired) stage = 'PAYMENT_REQUIRED' + else if (companyConfirmed) stage = 'BOOKING_CONFIRMED' + else stage = 'COMPANY_CONFIRMED' + + const activeStepKey = + stage === 'REQUEST_SENT' + ? 'REQUEST_SENT' + : stage === 'CANCELLED' + ? 'BOOKING_CONFIRMED' + : stage === 'DOCUMENTS_REQUIRED' + ? 'DOCUMENTS_COMPLETED' + : stage === 'PAYMENT_REQUIRED' + ? 'PAYMENT_COMPLETED' + : 'BOOKING_CONFIRMED' + + const steps = [ + { key: 'REQUEST_SENT', completed: true, active: activeStepKey === 'REQUEST_SENT' }, + { key: 'AVAILABILITY_CONFIRMED', completed: companyConfirmed, active: companyConfirmed && activeStepKey === 'REQUEST_SENT' }, + { key: 'DOCUMENTS_COMPLETED', completed: companyConfirmed && !documentsRequired, active: activeStepKey === 'DOCUMENTS_COMPLETED' }, + { key: 'PAYMENT_COMPLETED', completed: companyConfirmed && !paymentRequired, active: activeStepKey === 'PAYMENT_COMPLETED' }, + { key: 'BOOKING_CONFIRMED', completed: companyConfirmed && !documentsRequired && !paymentRequired, active: activeStepKey === 'BOOKING_CONFIRMED' }, + ] + + return { + stage, + companyConfirmed, + documentsRequired, + paymentRequired, + documentsMissing, + steps, + } +} + export function serializeReservationForDashboard(reservation: T): T & { paymentMode: string | null; workflow: ReturnType } { +}>(reservation: T): T & { + paymentMode: string | null + workflow: ReturnType + bookingRequest: ReturnType +} { const extras = parseReservationExtras(reservation.extras) const customer = reservation.customer @@ -59,5 +144,6 @@ export function serializeReservationForDashboard - : {}), - fullAddress: data.fullAddress, - identityDocumentNumber: data.identityDocumentNumber, - internationalLicenseNumber: data.internationalLicenseNumber ?? null, + const addressPatch = { + ...(data.fullAddress ? { fullAddress: data.fullAddress } : {}), + ...(data.identityDocumentNumber ? { identityDocumentNumber: data.identityDocumentNumber } : {}), + ...(data.internationalLicenseNumber !== undefined ? { internationalLicenseNumber: data.internationalLicenseNumber ?? null } : {}), } + const address = Object.keys(addressPatch).length > 0 + ? { + ...(existing?.address && typeof existing.address === 'object' && !Array.isArray(existing.address) + ? existing.address as Record + : {}), + ...addressPatch, + } + : undefined + const payload = { firstName: data.firstName, lastName: data.lastName, - email: data.email, + email: normalizedEmail, 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, + ...(data.driverLicense ? { driverLicense: data.driverLicense } : {}), + ...(data.dateOfBirth ? { dateOfBirth: new Date(data.dateOfBirth) } : {}), + ...(data.nationality ? { nationality: data.nationality } : {}), + ...(address ? { address } : {}), + ...(data.licenseExpiry ? { licenseExpiry: new Date(data.licenseExpiry) } : {}), + ...(data.licenseIssuedAt ? { licenseIssuedAt: new Date(data.licenseIssuedAt) } : {}), + ...(data.licenseCountry ? { licenseCountry: data.licenseCountry } : {}), + ...((data.licenseNumber ?? data.driverLicense) ? { licenseNumber: data.licenseNumber ?? data.driverLicense } : {}), + ...(data.licenseCategory ? { licenseCategory: data.licenseCategory } : {}), } if (!existing) { @@ -106,14 +113,14 @@ export async function createReservation(data: object) { export async function findReservationWithDetails(reservationId: string) { return prisma.reservation.findUniqueOrThrow({ where: { id: reservationId }, - include: { insurances: true, additionalDrivers: true }, + include: { insurances: true, additionalDrivers: true, vehicle: true, customer: true, company: { include: { brand: true } } }, }) } export async function findBooking(reservationId: string, companyId: string) { return prisma.reservation.findFirstOrThrow({ where: { id: reservationId, companyId }, - include: { vehicle: true, customer: true }, + include: { vehicle: true, customer: true, company: { include: { brand: true } } }, }) } diff --git a/apps/api/src/modules/site/site.schemas.test.ts b/apps/api/src/modules/site/site.schemas.test.ts index 4b2eac0..08a7f32 100644 --- a/apps/api/src/modules/site/site.schemas.test.ts +++ b/apps/api/src/modules/site/site.schemas.test.ts @@ -10,15 +10,8 @@ describe('site.schemas', () => { lastName: 'Benali', email: 'aya@example.com', phone: '+212600000000', - driverLicense: 'DL-123', - dateOfBirth: '1995-01-01T00:00:00.000Z', - licenseExpiry: '2028-01-01T00:00:00.000Z', - licenseIssuedAt: '2020-01-01T00:00:00.000Z', - nationality: 'Moroccan', - identityDocumentNumber: 'ID-123', - fullAddress: '12 Main Street', - licenseCountry: 'MA', - licenseCategory: 'B', + pickupLocation: 'Casablanca Airport', + returnLocation: 'Casablanca Airport', } it('defaults optional public booking arrays and source', () => { @@ -53,7 +46,7 @@ describe('site.schemas', () => { expect(paySchema.parse({ provider: 'PAYPAL', successUrl: 'https://ok.test', failureUrl: 'https://fail.test', accessToken: 'booking-access-token-123' }).currency).toBe('MAD') }) - it('keeps contact, PayPal capture, and required identity fields strict', () => { + it('keeps contact, PayPal capture, and guest booking essentials strict', () => { expect(contactSchema.parse({ name: 'Aya', email: 'aya@example.com', message: 'Hello' })).toEqual({ name: 'Aya', email: 'aya@example.com', message: 'Hello', }) diff --git a/apps/api/src/modules/site/site.schemas.ts b/apps/api/src/modules/site/site.schemas.ts index 64438a8..55d5b50 100644 --- a/apps/api/src/modules/site/site.schemas.ts +++ b/apps/api/src/modules/site/site.schemas.ts @@ -19,16 +19,18 @@ export const bookSchema = z.object({ lastName: z.string().min(1), email: z.string().email(), 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), + pickupLocation: z.string().trim().min(1).max(100), + returnLocation: z.string().trim().min(1).max(100), + driverLicense: z.string().min(1).max(50).optional(), + dateOfBirth: z.string().datetime().optional(), + licenseExpiry: z.string().datetime().optional(), + licenseIssuedAt: z.string().datetime().optional(), + nationality: z.string().min(1).max(100).optional(), + identityDocumentNumber: z.string().min(1).max(100).optional(), + fullAddress: z.string().min(1).max(500).optional(), + licenseCountry: z.string().min(1).max(100).optional(), licenseNumber: z.string().min(1).max(50).optional(), - licenseCategory: z.string().min(1).max(20), + licenseCategory: z.string().min(1).max(20).optional(), internationalLicenseNumber: z.string().trim().max(100).optional(), offerId: z.string().cuid().optional(), promoCodeUsed: z.string().optional(), diff --git a/apps/api/src/modules/site/site.service.boundary.test.ts b/apps/api/src/modules/site/site.service.boundary.test.ts index a958099..4809cb6 100644 --- a/apps/api/src/modules/site/site.service.boundary.test.ts +++ b/apps/api/src/modules/site/site.service.boundary.test.ts @@ -18,7 +18,13 @@ vi.mock('../../services/pricingRuleService', () => ({ applyPricingRules: vi.fn() vi.mock('../../services/licenseValidationService', () => ({ validateLicense: vi.fn(), validateAndFlagLicense: vi.fn().mockResolvedValue(undefined) })) vi.mock('../../services/amanpayService', () => ({ isConfigured: vi.fn(), createCheckout: vi.fn(), verifyWebhookSignature: vi.fn(), findTransactionFromWebhook: vi.fn() })) vi.mock('../../services/paypalService', () => ({ createOrder: vi.fn(), captureOrder: vi.fn() })) -vi.mock('./site.presenter', () => ({ presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })) })) +vi.mock('./site.presenter', () => ({ + presentBrand: vi.fn((company: any) => ({ company: { id: company.id, slug: company.slug }, brand: company.brand ?? null })), + presentPublicBooking: vi.fn((reservation: any) => ({ + bookingReference: reservation.bookingReference ?? null, + status: reservation.status === 'DRAFT' ? 'PENDING' : reservation.status, + })), +})) vi.mock('./site.repo', () => ({ findCompanyBySlug: vi.fn(), findPublishedVehicles: vi.fn(), @@ -62,15 +68,8 @@ function bookingBody(overrides: Record = {}) { lastName: 'Renter', email: 'nora@example.test', phone: '+212600000000', - driverLicense: 'DL-1', - dateOfBirth: '1992-01-01', - licenseExpiry: '2027-01-01', - licenseIssuedAt: '2022-01-01', - nationality: 'MA', - identityDocumentNumber: 'ID-1', - fullAddress: 'Casablanca', - licenseCountry: 'MA', - licenseCategory: 'B', + pickupLocation: 'Casablanca', + returnLocation: 'Casablanca', ...overrides, } as any } @@ -108,7 +107,23 @@ describe('site.service public booking/payment boundaries', () => { vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'customer_1' } as never) vi.mocked(repo.findOfferByPromoCode).mockResolvedValue({ id: 'offer_1', type: 'FREE_DAY', discountValue: 1 } as never) vi.mocked(repo.createReservation).mockResolvedValue({ id: 'reservation_1' } as never) - vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'reservation_1', additionalDrivers: [{ requiresApproval: true }] } as never) + vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ + id: 'reservation_1', + bookingReference: 'BK-2026-AB12CD', + status: 'DRAFT', + startDate: new Date('2026-07-01T10:00:00.000Z'), + endDate: new Date('2026-07-04T10:00:00.000Z'), + pickupLocation: 'Casablanca', + returnLocation: 'Casablanca', + vehicle: { make: 'Dacia', model: 'Duster', year: 2024 }, + customer: { firstName: 'Aya', lastName: 'Benali', email: 'aya@example.test', phone: '+212600000000' }, + company: { name: 'Atlas Cars', brand: { displayName: 'Atlas Cars' } }, + additionalDrivers: [], + insurances: [], + totalAmount: 1090, + paidAmount: 0, + paymentStatus: 'UNPAID', + } as never) const result = await service.createBooking('atlas', bookingBody({ promoCodeUsed: 'FREEDAY', @@ -122,13 +137,16 @@ describe('site.service public booking/payment boundaries', () => { discountAmount: 500, pricingRulesTotal: 90, totalAmount: 1090, + pickupLocation: 'Casablanca', + returnLocation: 'Casablanca', status: 'DRAFT', source: 'PUBLIC_SITE', })) - expect(applyInsurancesToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', ['insurance_1'], 3, 1500) - expect(applyAdditionalDriversToReservation).toHaveBeenCalledWith('reservation_1', 'company_1', [{ firstName: 'Second' }], 3) - expect(validateAndFlagLicense).toHaveBeenCalledWith('customer_1') - expect(result.requiresManualApproval).toBe(true) + expect(applyInsurancesToReservation).not.toHaveBeenCalled() + expect(applyAdditionalDriversToReservation).not.toHaveBeenCalled() + expect(validateAndFlagLicense).not.toHaveBeenCalled() + expect(result.status).toBe('PENDING') + expect(result.bookingReference).toBe('BK-2026-AB12CD') }) it('rejects payment initialization for already-paid reservations before provider calls', async () => { diff --git a/apps/api/src/modules/site/site.service.ts b/apps/api/src/modules/site/site.service.ts index d09ac57..098e732 100644 --- a/apps/api/src/modules/site/site.service.ts +++ b/apps/api/src/modules/site/site.service.ts @@ -1,10 +1,8 @@ import { PLAN_FEATURES, PLAN_PRICES } from '@rentaldrivego/types' import { AppError, NotFoundError } from '../../http/errors' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' -import { applyInsurancesToReservation } from '../../services/insuranceService' -import { applyAdditionalDriversToReservation } from '../../services/additionalDriverService' import { applyPricingRules } from '../../services/pricingRuleService' -import { validateLicense, validateAndFlagLicense } from '../../services/licenseValidationService' +import { validateLicense } from '../../services/licenseValidationService' import { getMarketplaceHomepageContent } from '../../services/platformContentService' import { prisma } from '../../lib/prisma' import * as amanpay from '../../services/amanpayService' @@ -48,6 +46,12 @@ function assertAllowedPaymentRedirect(urlValue: string, company: any) { } } +function generateBookingReference() { + const year = new Date().getUTCFullYear() + const random = Math.random().toString(36).slice(2, 8).toUpperCase() + return `BK-${year}-${random}` +} + export async function getPlatformHomepage() { return getMarketplaceHomepageContent() } @@ -132,8 +136,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 - identityDocumentNumber: string; fullAddress: string; licenseCountry: string; licenseNumber?: string; licenseCategory: string; internationalLicenseNumber?: string + pickupLocation: string; returnLocation: 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[] }) { @@ -143,6 +148,7 @@ export async function createBooking(slug: string, body: { const start = new Date(body.startDate) const end = new Date(body.endDate) if (end <= start) throw new AppError('End date must be after start date', 400, 'invalid_dates') + if (start < new Date()) throw new AppError('Start date cannot be in the past', 400, 'invalid_dates') const availability = await getVehicleAvailabilitySummary(vehicle.id, { companyId: company.id, range: { startDate: start, endDate: end } }) if (!availability.available) { @@ -182,16 +188,10 @@ export async function createBooking(slug: string, body: { } } - const additionalDriversList = body.additionalDrivers ?? [] const { applied, total: pricingRulesTotal } = await applyPricingRules( - company.id, customer.id, additionalDriversList as any[], vehicle.dailyRate, totalDays, + company.id, customer.id, [], vehicle.dailyRate, totalDays, ) - const primaryLicenseResult = validateLicense(body.licenseExpiry ? new Date(body.licenseExpiry) : null) - if (primaryLicenseResult.status === 'EXPIRED') { - throw new AppError('The primary driver license is expired', 400, 'license_expired') - } - const reservation = await repo.createReservation({ companyId: company.id, vehicleId: vehicle.id, @@ -202,6 +202,8 @@ export async function createBooking(slug: string, body: { source: body.source ?? 'PUBLIC_SITE', startDate: start, endDate: end, + pickupLocation: body.pickupLocation, + returnLocation: body.returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount: baseAmount - discountAmount + pricingRulesTotal, @@ -209,20 +211,10 @@ export async function createBooking(slug: string, body: { pricingRulesApplied: applied, pricingRulesTotal, notes: body.notes ?? null, + bookingReference: generateBookingReference(), status: 'DRAFT', }) - const insuranceIds = body.selectedInsurancePolicyIds ?? [] - if (insuranceIds.length > 0) { - await applyInsurancesToReservation(reservation.id, company.id, insuranceIds, totalDays, baseAmount) - } - if (additionalDriversList.length > 0) { - await applyAdditionalDriversToReservation(reservation.id, company.id, additionalDriversList, totalDays) - } - if (body.licenseExpiry) { - await validateAndFlagLicense(customer.id).catch(() => null) - } - const publicAccess = generatePublicAccessToken() await repo.createReservationPublicAccess( reservation.id, @@ -232,11 +224,10 @@ export async function createBooking(slug: string, body: { const refreshed = await repo.findReservationWithDetails(reservation.id) return { - ...refreshed, + ...presentPublicBooking({ ...refreshed, company }), publicAccessToken: publicAccess.token, - requiresManualApproval: - primaryLicenseResult.requiresApproval || - refreshed.additionalDrivers.some((d: any) => d.requiresApproval), + bookingReference: (refreshed as any).bookingReference, + message: 'The company will review your request.', } } diff --git a/apps/api/src/modules/site/site.test.ts b/apps/api/src/modules/site/site.test.ts index 5d82ac0..75503f0 100644 --- a/apps/api/src/modules/site/site.test.ts +++ b/apps/api/src/modules/site/site.test.ts @@ -175,9 +175,12 @@ describe('validatePromoCode', () => { describe('createBooking', () => { const baseBody = { vehicleId: 'v-1', - startDate: '2025-06-01T00:00:00.000Z', - endDate: '2025-06-04T00:00:00.000Z', + startDate: '2026-07-01T00:00:00.000Z', + endDate: '2026-07-04T00:00:00.000Z', firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', + phone: '+212600000000', + pickupLocation: 'Casablanca', + returnLocation: 'Casablanca', } beforeEach(() => { @@ -186,20 +189,36 @@ describe('createBooking', () => { vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null }) vi.mocked(repo.upsertCustomer).mockResolvedValue({ id: 'c-1' } as any) vi.mocked(applyPricingRules).mockResolvedValue({ applied: [], total: 0 }) - vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) vi.mocked(repo.createReservation).mockResolvedValue({ id: 'r-1' } as any) - vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ id: 'r-1', additionalDrivers: [], insurances: [] } as any) + vi.mocked(repo.findReservationWithDetails).mockResolvedValue({ + id: 'r-1', + bookingReference: 'BK-2026-ABC123', + status: 'DRAFT', + startDate: new Date('2026-07-01T00:00:00.000Z'), + endDate: new Date('2026-07-04T00:00:00.000Z'), + pickupLocation: 'Casablanca', + returnLocation: 'Casablanca', + vehicle: { make: 'Toyota', model: 'Camry', year: 2022 }, + customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', phone: '+212600000000' }, + company: { name: 'Test Co', brand: { displayName: 'Test Co' } }, + additionalDrivers: [], + insurances: [], + totalAmount: 30000, + paidAmount: 0, + paymentStatus: 'UNPAID', + } as any) }) - it('creates a booking and returns reservation with requiresManualApproval flag', async () => { + it('creates a booking and returns a pending guest confirmation', async () => { const result = await createBooking(SLUG, baseBody) expect(repo.createReservation).toHaveBeenCalledOnce() - expect((result as any).requiresManualApproval).toBe(false) + expect((result as any).status).toBe('PENDING') + expect((result as any).bookingReference).toBe('BK-2026-ABC123') }) it('throws AppError for invalid dates', async () => { await expect( - createBooking(SLUG, { ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }), + createBooking(SLUG, { ...baseBody, startDate: '2026-07-04T00:00:00.000Z', endDate: '2026-07-01T00:00:00.000Z' }), ).rejects.toMatchObject({ error: 'invalid_dates' }) }) @@ -207,11 +226,6 @@ describe('createBooking', () => { vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' }) await expect(createBooking(SLUG, baseBody)).rejects.toMatchObject({ error: 'unavailable' }) }) - - it('throws AppError when primary driver license is expired', async () => { - vi.mocked(validateLicense).mockReturnValue({ status: 'EXPIRED', requiresApproval: false } as any) - await expect(createBooking(SLUG, { ...baseBody, licenseExpiry: '2020-01-01T00:00:00.000Z' })).rejects.toMatchObject({ error: 'license_expired' }) - }) }) // ──────────────────────────────────────────────────────────────────────────── diff --git a/apps/dashboard/src/app/(dashboard)/online-reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/online-reservations/page.tsx index 91ff9a0..7f4ce7c 100644 --- a/apps/dashboard/src/app/(dashboard)/online-reservations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/online-reservations/page.tsx @@ -22,6 +22,12 @@ interface OnlineReservation { createdAt: string vehicle: { make: string; model: string; year: number; licensePlate: string } customer: { firstName: string; lastName: string; email: string; phone: string | null } + bookingRequest: { + stage: string + documentsRequired: boolean + paymentRequired: boolean + documentsMissing: string[] + } } const STATUS_STYLE: Record = { @@ -42,6 +48,33 @@ const STATUS_LABEL: Record = { NO_SHOW: 'No show', } +const REQUEST_STAGE_STYLE: Record = { + REQUEST_SENT: 'bg-orange-100 text-orange-800', + COMPANY_CONFIRMED: 'bg-blue-100 text-blue-800', + DOCUMENTS_REQUIRED: 'bg-amber-100 text-amber-800', + PAYMENT_REQUIRED: 'bg-purple-100 text-purple-800', + BOOKING_CONFIRMED: 'bg-emerald-100 text-emerald-700', + CANCELLED: 'bg-rose-100 text-rose-700', +} + +const REQUEST_STAGE_LABEL: Record = { + REQUEST_SENT: 'Waiting for company review', + COMPANY_CONFIRMED: 'Availability confirmed', + DOCUMENTS_REQUIRED: 'Driver documents missing', + PAYMENT_REQUIRED: 'Payment or deposit pending', + BOOKING_CONFIRMED: 'Ready to proceed', + CANCELLED: 'Request closed', +} + +const MISSING_ITEM_LABEL: Record = { + DATE_OF_BIRTH: 'Date of birth', + IDENTITY_DOCUMENT: 'Identity document', + FULL_ADDRESS: 'Full address', + DRIVER_LICENSE: 'Driver license number', + LICENSE_IMAGE: 'License image', + LICENSE_APPROVAL: 'License approval', +} + function DeclineModal({ onConfirm, onCancel, loading }: { onConfirm: (reason: string) => void; onCancel: () => void; loading: boolean }) { const [reason, setReason] = useState('') return ( @@ -237,6 +270,8 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: { onDecline: () => void }) { const days = r.totalDays + const missingItems = r.bookingRequest.documentsMissing.map((item) => MISSING_ITEM_LABEL[item] ?? item) + return (
@@ -267,6 +302,27 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {

"{r.notes}"

)} +
+
+ + {REQUEST_STAGE_LABEL[r.bookingRequest.stage] ?? r.bookingRequest.stage} + + {r.bookingRequest.paymentRequired ? ( + + Payment pending + + ) : null} +
+ {missingItems.length > 0 ? ( +
+ {missingItems.map((item) => ( + + {item} + + ))} +
+ ) : null} +
{/* Right: total + actions */} diff --git a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx index bd18934..58f8528 100644 --- a/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/reservations/[id]/page.tsx @@ -66,6 +66,13 @@ interface ReservationDetail { checkInInspectionEditable: boolean checkOutInspectionEditable: boolean } + bookingRequest: { + stage: string + companyConfirmed: boolean + documentsRequired: boolean + paymentRequired: boolean + documentsMissing: string[] + } } type EditMode = 'booking' | 'return' | null @@ -118,6 +125,24 @@ const detailCopy = { checkOutReadOnly: 'Check-out inspection becomes editable once the vehicle is returned.', inspectionClosed: 'This reservation is closed. Inspection edits are disabled.', licenseImageLabel: 'License image', + bookingProgress: 'Booking progress', + paymentPending: 'Payment or deposit still pending', + stageLabels: { + REQUEST_SENT: 'Waiting for company review', + COMPANY_CONFIRMED: 'Availability confirmed', + DOCUMENTS_REQUIRED: 'Driver documents missing', + PAYMENT_REQUIRED: 'Payment or deposit pending', + BOOKING_CONFIRMED: 'Ready to proceed', + CANCELLED: 'Request closed', + }, + missingItems: { + DATE_OF_BIRTH: 'Date of birth', + IDENTITY_DOCUMENT: 'Identity document', + FULL_ADDRESS: 'Full address', + DRIVER_LICENSE: 'Driver license number', + LICENSE_IMAGE: 'License image', + LICENSE_APPROVAL: 'License approval', + }, noLicenseImage: 'No license image uploaded.', pickupPhotosTitle: 'Pickup photos', dropoffPhotosTitle: 'Return photos', @@ -167,6 +192,24 @@ const detailCopy = { checkOutReadOnly: 'L’inspection de retour devient modifiable une fois le véhicule retourné.', inspectionClosed: 'Cette réservation est clôturée. Les modifications d’inspection sont désactivées.', licenseImageLabel: 'Image du permis', + bookingProgress: 'Progression de la réservation', + paymentPending: 'Paiement ou dépôt encore en attente', + stageLabels: { + REQUEST_SENT: 'En attente de validation', + COMPANY_CONFIRMED: 'Disponibilité confirmée', + DOCUMENTS_REQUIRED: 'Documents conducteur manquants', + PAYMENT_REQUIRED: 'Paiement ou dépôt en attente', + BOOKING_CONFIRMED: 'Prêt à avancer', + CANCELLED: 'Demande clôturée', + }, + missingItems: { + DATE_OF_BIRTH: 'Date de naissance', + IDENTITY_DOCUMENT: 'Pièce d’identité', + FULL_ADDRESS: 'Adresse complète', + DRIVER_LICENSE: 'Numéro du permis', + LICENSE_IMAGE: 'Image du permis', + LICENSE_APPROVAL: 'Validation du permis', + }, noLicenseImage: 'Aucune image de permis téléversée.', pickupPhotosTitle: 'Photos de départ', dropoffPhotosTitle: 'Photos de retour', @@ -216,6 +259,24 @@ const detailCopy = { checkOutReadOnly: 'يصبح فحص الإرجاع قابلاً للتعديل بعد عودة السيارة.', inspectionClosed: 'هذا الحجز مغلق. تم تعطيل تعديل الفحص.', licenseImageLabel: 'صورة الرخصة', + bookingProgress: 'تقدم الحجز', + paymentPending: 'الدفع أو العربون ما زال معلقاً', + stageLabels: { + REQUEST_SENT: 'بانتظار مراجعة الشركة', + COMPANY_CONFIRMED: 'تم تأكيد التوفر', + DOCUMENTS_REQUIRED: 'وثائق السائق ناقصة', + PAYMENT_REQUIRED: 'الدفع أو العربون معلق', + BOOKING_CONFIRMED: 'جاهز للمتابعة', + CANCELLED: 'تم إغلاق الطلب', + }, + missingItems: { + DATE_OF_BIRTH: 'تاريخ الميلاد', + IDENTITY_DOCUMENT: 'وثيقة الهوية', + FULL_ADDRESS: 'العنوان الكامل', + DRIVER_LICENSE: 'رقم الرخصة', + LICENSE_IMAGE: 'صورة الرخصة', + LICENSE_APPROVAL: 'اعتماد الرخصة', + }, noLicenseImage: 'لم يتم رفع صورة الرخصة.', pickupPhotosTitle: 'صور الاستلام', dropoffPhotosTitle: 'صور الإرجاع', @@ -534,6 +595,31 @@ export default function ReservationDetailPage() { {actionError &&
{actionError}
} + {reservation.source === 'MARKETPLACE' ? ( +
+

{copy.bookingProgress}

+
+ + {copy.stageLabels[reservation.bookingRequest.stage as keyof typeof copy.stageLabels] ?? reservation.bookingRequest.stage} + + {reservation.bookingRequest.paymentRequired ? ( + + {copy.paymentPending} + + ) : null} +
+ {reservation.bookingRequest.documentsMissing.length > 0 ? ( +
+ {reservation.bookingRequest.documentsMissing.map((item) => ( + + {copy.missingItems[item as keyof typeof copy.missingItems] ?? item} + + ))} +
+ ) : null} +
+ ) : null} +

{r.sectionCustomer}

diff --git a/apps/marketplace/src/components/BookingForm.tsx b/apps/marketplace/src/components/BookingForm.tsx index d5a05db..11818e0 100644 --- a/apps/marketplace/src/components/BookingForm.tsx +++ b/apps/marketplace/src/components/BookingForm.tsx @@ -1,8 +1,8 @@ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { formatCurrency } from '@rentaldrivego/types' -import { marketplacePost } from '@/lib/api' +import { MarketplaceApiError, marketplaceFetch, marketplacePost } from '@/lib/api' import { useMarketplacePreferences } from '@/components/MarketplaceShell' type Props = { @@ -14,25 +14,87 @@ type Props = { dropoffLocations: string[] } +type DropoffMode = 'same' | 'different' + +type BookingFields = { + firstName: string + lastName: string + email: string + phone: string + pickupLocation: string + dropoffMode: DropoffMode + returnLocation: string + startDate: string + endDate: string + notes: string + dateOfBirth: string + nationality: string + identityDocumentNumber: string + fullAddress: string + driverLicense: string + licenseIssuedAt: string + licenseExpiry: string + licenseCountry: string + licenseCategory: string + internationalLicenseNumber: string +} + +type AvailabilityState = { + status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error' + nextAvailableAt: string | null +} + +type AvailabilityResponse = { + available: boolean + availabilityStatus: string + nextAvailableAt: string | null + blockingReason: string | null +} + +type BookingResponse = { + reservationId: string + bookingReference?: string | null + companyName?: string + vehicleName?: string + startDate?: string + endDate?: string + status?: string + message?: string +} + +type FunnelEventName = + | 'booking_form_viewed' + | 'trip_dates_selected' + | 'contact_details_started' + | 'driver_details_expanded' + | 'booking_request_submitted' + | 'booking_request_failed' + | 'booking_request_success' + const copy = { en: { - title: 'Reserve this vehicle', - renterIdentity: 'Renter identity', + title: 'Request this car in 1 minute', + subtitle: 'Send the essentials now. Driver documents can be added later if the company needs them.', + tripDetails: 'Trip details', + contactDetails: 'Contact details', + optionalDetails: 'Driver details, optional now', + optionalDetailsHint: 'Skip this unless you want to speed up final approval. The company can collect it after confirming availability.', + showDriverDetails: 'Add driver details now', + hideDriverDetails: 'Hide driver details', firstName: 'First name', lastName: 'Last name', + email: 'Email', + phone: 'Phone', 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)', + internationalLicenseNumber: 'International permit number', pickupLocation: 'Pick-up location', returnLocation: 'Drop-off location', sameDropoff: 'Same drop-off', @@ -40,37 +102,59 @@ const copy = { sameAsPickup: 'Return to the same location', startDate: 'Pick-up date', endDate: 'Return date', - notes: 'Notes (optional)', - submit: 'Request reservation', + notes: 'Anything the company should know? Optional.', + submit: 'Send booking request', submitting: 'Sending…', - successTitle: 'Request received!', - successBody: 'Your reservation request has been sent. The company will review it and contact you shortly.', + successTitle: 'Request sent.', + successBody: 'This first step is a request, not an instant booking. Here is what happens next.', + bookingReference: 'Booking reference', + pendingStatus: 'Pending', invalidDates: 'Return date must be after pick-up date.', - required: 'Please fill in all required fields.', + required: 'Add your trip dates, name, email, and phone.', perDay: '/ day', estimatedTotal: 'Estimated total', days: 'day(s)', genericError: 'Something went wrong. Please try again.', - locationRequired: 'Please select the required pick-up and drop-off locations.', + locationRequired: 'Select the required pick-up and drop-off locations.', + checkingAvailability: 'Checking availability for these dates…', + likelyAvailable: 'Likely available for these dates. Final confirmation still depends on the rental company.', + unavailableWithDate: 'Unavailable for these dates. Next likely availability: {date}.', + unavailableWithoutDate: 'Unavailable for these dates. Choose different trip dates to continue.', + availabilityError: 'Availability could not be checked right now. You can still try again in a moment.', + nextStepRequestSent: 'Request sent', + nextStepRequestSentBody: 'The company receives your dates and contact details first.', + nextStepAvailability: 'Company confirms availability', + nextStepAvailabilityBody: 'They review the request and accept it if the car is still open.', + nextStepDocuments: 'Driver documents if needed', + nextStepDocumentsBody: 'License and identity details can be collected after the company accepts the request.', + nextStepPayment: 'Payment or deposit', + nextStepPaymentBody: 'Any required payment is handled only after availability is confirmed.', + nextStepConfirmed: 'Booking confirmed', + nextStepConfirmedBody: 'The booking is final once availability, documents, and payment are all cleared.', }, fr: { - title: 'Réserver ce véhicule', - renterIdentity: 'Identité du locataire', + title: 'Demander cette voiture en 1 minute', + subtitle: 'Envoyez l’essentiel maintenant. Les documents conducteur peuvent être ajoutés plus tard si l’agence les demande.', + tripDetails: 'Détails du trajet', + contactDetails: 'Coordonnées', + optionalDetails: 'Détails conducteur, optionnels maintenant', + optionalDetailsHint: 'Ignorez cette partie sauf si vous voulez accélérer la validation finale. L’agence pourra les demander après confirmation de disponibilité.', + showDriverDetails: 'Ajouter les détails conducteur', + hideDriverDetails: 'Masquer les détails conducteur', firstName: 'Prénom', lastName: 'Nom', + email: 'E-mail', + phone: 'Téléphone', 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)', + internationalLicenseNumber: 'N° de permis international', pickupLocation: 'Lieu de départ', returnLocation: 'Lieu de retour', sameDropoff: 'Même retour', @@ -78,37 +162,59 @@ const copy = { sameAsPickup: 'Retour au même lieu', startDate: 'Date de départ', endDate: 'Date de retour', - notes: 'Notes (optionnelles)', - submit: 'Demander une réservation', + notes: 'Une information utile pour l’agence ? Optionnel.', + submit: 'Envoyer la demande', submitting: 'Envoi…', - successTitle: 'Demande reçue !', - successBody: "Votre demande de réservation a été envoyée. L’entreprise va l’examiner et vous contactera prochainement.", + successTitle: 'Demande envoyée.', + successBody: 'Cette première étape reste une demande, pas une réservation instantanée. Voici la suite.', + bookingReference: 'Référence de réservation', + pendingStatus: 'En attente', invalidDates: 'La date de retour doit être après la date de départ.', - required: 'Veuillez remplir tous les champs obligatoires.', + required: 'Ajoutez vos dates, votre nom, votre e-mail et votre téléphone.', perDay: '/ jour', estimatedTotal: 'Total estimé', days: 'jour(s)', genericError: 'Une erreur est survenue. Veuillez réessayer.', - locationRequired: 'Veuillez sélectionner les lieux de départ et de retour requis.', + locationRequired: 'Sélectionnez les lieux de départ et de retour requis.', + checkingAvailability: 'Vérification de la disponibilité pour ces dates…', + likelyAvailable: 'Probablement disponible pour ces dates. La confirmation finale dépend encore de l’agence.', + unavailableWithDate: 'Indisponible pour ces dates. Prochaine disponibilité probable : {date}.', + unavailableWithoutDate: 'Indisponible pour ces dates. Choisissez d’autres dates pour continuer.', + availabilityError: 'La disponibilité ne peut pas être vérifiée pour le moment. Réessayez dans un instant.', + nextStepRequestSent: 'Demande envoyée', + nextStepRequestSentBody: 'L’agence reçoit d’abord vos dates et vos coordonnées.', + nextStepAvailability: 'Confirmation de disponibilité', + nextStepAvailabilityBody: 'L’agence vérifie la demande et l’accepte si la voiture est toujours libre.', + nextStepDocuments: 'Documents conducteur si nécessaires', + nextStepDocumentsBody: 'Le permis et les pièces d’identité peuvent être demandés après l’acceptation de la demande.', + nextStepPayment: 'Paiement ou dépôt', + nextStepPaymentBody: 'Le paiement demandé intervient seulement après la confirmation de disponibilité.', + nextStepConfirmed: 'Réservation confirmée', + nextStepConfirmedBody: 'La réservation devient finale une fois la disponibilité, les documents et le paiement validés.', }, ar: { - title: 'احجز هذه السيارة', - renterIdentity: 'بيانات المستأجر', + title: 'اطلب هذه السيارة خلال دقيقة', + subtitle: 'أرسل المعلومات الأساسية الآن. يمكن إضافة وثائق السائق لاحقاً إذا احتاجت الشركة إليها.', + tripDetails: 'تفاصيل الرحلة', + contactDetails: 'بيانات التواصل', + optionalDetails: 'بيانات السائق، اختيارية الآن', + optionalDetailsHint: 'تجاوز هذا الجزء إلا إذا أردت تسريع الموافقة النهائية. يمكن للشركة طلبه بعد تأكيد التوفر.', + showDriverDetails: 'إضافة بيانات السائق الآن', + hideDriverDetails: 'إخفاء بيانات السائق', firstName: 'الاسم الأول', lastName: 'اسم العائلة', + email: 'البريد الإلكتروني', + phone: 'الهاتف', dateOfBirth: 'تاريخ الميلاد', nationality: 'الجنسية', identityDocumentNumber: 'رقم البطاقة الوطنية / جواز السفر', fullAddress: 'العنوان الكامل', - driverLicenseSection: 'رخصة القيادة', - email: 'البريد الإلكتروني', - phone: 'الهاتف', driverLicense: 'رقم الرخصة', licenseIssuedAt: 'تاريخ الإصدار', licenseExpiry: 'تاريخ الانتهاء', licenseCountry: 'بلد الإصدار', licenseCategory: 'فئة الرخصة', - internationalLicenseNumber: 'رقم الرخصة الدولية (اختياري)', + internationalLicenseNumber: 'رقم الرخصة الدولية', pickupLocation: 'موقع الاستلام', returnLocation: 'موقع الإرجاع', sameDropoff: 'نفس موقع الإرجاع', @@ -116,21 +222,60 @@ const copy = { sameAsPickup: 'الإرجاع في نفس موقع الاستلام', startDate: 'تاريخ الاستلام', endDate: 'تاريخ الإرجاع', - notes: 'ملاحظات (اختيارية)', - submit: 'طلب حجز', + notes: 'أي معلومة تريد إبلاغ الشركة بها؟ اختياري.', + submit: 'إرسال طلب الحجز', submitting: 'جارٍ الإرسال…', - successTitle: 'تم استلام الطلب!', - successBody: 'تم إرسال طلب الحجز. ستراجعه الشركة وستتواصل معك قريباً.', - invalidDates: 'يجب أن يكون تاريخ الإعادة بعد تاريخ الاستلام.', - required: 'يرجى تعبئة جميع الحقول المطلوبة.', + successTitle: 'تم إرسال الطلب.', + successBody: 'هذه الخطوة الأولى عبارة عن طلب وليست حجزاً فورياً. هذه هي الخطوات التالية.', + bookingReference: 'مرجع الحجز', + pendingStatus: 'قيد المراجعة', + invalidDates: 'يجب أن يكون تاريخ الإرجاع بعد تاريخ الاستلام.', + required: 'أضف تواريخ الرحلة والاسم والبريد والهاتف.', perDay: '/ يوم', estimatedTotal: 'الإجمالي التقديري', days: 'يوم', genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', - locationRequired: 'يرجى اختيار مواقع الاستلام والإرجاع المطلوبة.', + locationRequired: 'اختر مواقع الاستلام والإرجاع المطلوبة.', + checkingAvailability: 'جارٍ التحقق من التوفر لهذه التواريخ…', + likelyAvailable: 'السيارة متاحة غالباً لهذه التواريخ، لكن التأكيد النهائي يعود إلى شركة التأجير.', + unavailableWithDate: 'غير متاحة لهذه التواريخ. أقرب توفر متوقع: {date}.', + unavailableWithoutDate: 'غير متاحة لهذه التواريخ. اختر تواريخ مختلفة للمتابعة.', + availabilityError: 'تعذر التحقق من التوفر الآن. حاول مرة أخرى بعد قليل.', + nextStepRequestSent: 'تم إرسال الطلب', + nextStepRequestSentBody: 'تتلقى الشركة أولاً التواريخ وبيانات التواصل.', + nextStepAvailability: 'تأكيد التوفر', + nextStepAvailabilityBody: 'تراجع الشركة الطلب وتقبله إذا كانت السيارة ما زالت متاحة.', + nextStepDocuments: 'وثائق السائق عند الحاجة', + nextStepDocumentsBody: 'يمكن طلب الرخصة ووثائق الهوية بعد قبول الطلب.', + nextStepPayment: 'الدفع أو العربون', + nextStepPaymentBody: 'أي دفعة مطلوبة تتم فقط بعد تأكيد التوفر.', + nextStepConfirmed: 'تأكيد الحجز', + nextStepConfirmedBody: 'يصبح الحجز نهائياً بعد اعتماد التوفر والوثائق والدفع.', }, } as const +const emptyFields: BookingFields = { + firstName: '', + lastName: '', + email: '', + phone: '', + pickupLocation: '', + dropoffMode: 'same', + returnLocation: '', + startDate: '', + endDate: '', + notes: '', + dateOfBirth: '', + nationality: '', + identityDocumentNumber: '', + fullAddress: '', + driverLicense: '', + licenseIssuedAt: '', + licenseExpiry: '', + licenseCountry: '', + licenseCategory: '', + internationalLicenseNumber: '', +} export function calculateBookingTotalDays(startDate: string, endDate: string): number { if (!startDate || !endDate) return 0 @@ -142,7 +287,7 @@ export function resolveReturnLocation({ returnLocation, pickupLocation, }: { - dropoffMode: 'same' | 'different' + dropoffMode: DropoffMode returnLocation: string pickupLocation: string }): string | undefined { @@ -155,6 +300,62 @@ export function normalizeOptionalText(value: string): string | undefined { return trimmed || undefined } +function interpolate(template: string, replacements: Record) { + return Object.entries(replacements).reduce( + (current, [key, value]) => current.replace(`{${key}}`, value), + template, + ) +} + +function formatLocalizedDate(value: string, language: 'en' | 'fr' | 'ar') { + return new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value)) +} + +function getBookingSessionId() { + if (typeof window === 'undefined') return 'server' + + const existing = window.sessionStorage.getItem('marketplace-booking-session-id') + if (existing) return existing + + const created = + typeof window.crypto?.randomUUID === 'function' + ? window.crypto.randomUUID() + : `booking-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + + window.sessionStorage.setItem('marketplace-booking-session-id', created) + return created +} + +function RequiredMark() { + return * +} + +type FieldProps = { + label: string + value: string + onChange: (value: string) => void + required?: boolean + type?: string + textarea?: boolean +} + +function Field({ label, value, onChange, required = false, type = 'text', textarea = false }: FieldProps) { + const 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-blue-900/30 dark:text-stone-100 dark:focus:border-stone-500' + + return ( +
+ + {textarea ? ( +