diff --git a/apps/admin/src/app/dashboard/layout.tsx b/apps/admin/src/app/dashboard/layout.tsx index a14f6a9..7e7e33d 100644 --- a/apps/admin/src/app/dashboard/layout.tsx +++ b/apps/admin/src/app/dashboard/layout.tsx @@ -8,15 +8,15 @@ import { AdminThemeSwitcher, useAdminI18n, } from '@/components/I18nProvider' - -const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' +import { resolveBrowserAppUrl } from '@/lib/appUrls' function buildUnifiedLoginUrl(nextPath: string) { + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') const params = new URLSearchParams({ portal: 'admin', next: nextPath || '/dashboard', }) - return `${DASHBOARD_URL}/sign-in?${params.toString()}` + return `${dashboardUrl}/sign-in?${params.toString()}` } const navLinks = [ diff --git a/apps/admin/src/app/login/page.tsx b/apps/admin/src/app/login/page.tsx index da7fa4c..cc04c8b 100644 --- a/apps/admin/src/app/login/page.tsx +++ b/apps/admin/src/app/login/page.tsx @@ -1,7 +1,13 @@ +import { headers } from 'next/headers' import { redirect } from 'next/navigation' - -const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' +import { resolveServerAppUrl } from '@/lib/appUrls' export default function AdminLoginPage() { - redirect(`${DASHBOARD_URL}/sign-in?portal=admin&next=/dashboard`) + const requestHeaders = headers() + const dashboardUrl = resolveServerAppUrl( + process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001', + requestHeaders.get('host'), + requestHeaders.get('x-forwarded-proto'), + ) + redirect(`${dashboardUrl}/sign-in?portal=admin&next=/dashboard`) } diff --git a/apps/admin/src/lib/appUrls.ts b/apps/admin/src/lib/appUrls.ts new file mode 100644 index 0000000..16aac81 --- /dev/null +++ b/apps/admin/src/lib/appUrls.ts @@ -0,0 +1,25 @@ +export function resolveBrowserAppUrl(fallback: string): string { + if (typeof window === 'undefined') return fallback + + try { + const target = new URL(fallback) + target.protocol = window.location.protocol + target.hostname = window.location.hostname + return target.toString().replace(/\/$/, '') + } catch { + return fallback + } +} + +export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string { + if (!host) return fallback + + try { + const target = new URL(fallback) + target.protocol = `${proto || target.protocol.replace(':', '')}:` + target.host = host + return target.toString().replace(/\/$/, '') + } catch { + return fallback + } +} diff --git a/apps/api/src/routes/marketplace.ts b/apps/api/src/routes/marketplace.ts index 6d8d749..952572e 100644 --- a/apps/api/src/routes/marketplace.ts +++ b/apps/api/src/routes/marketplace.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { prisma } from '../lib/prisma' import { optionalRenterAuth } from '../middleware/requireRenterAuth' import { sendNotification, sendTransactionalEmail } from '../services/notificationService' +import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService' const router = Router() router.use(optionalRenterAuth) @@ -96,22 +97,25 @@ router.get('/search', async (req, res, next) => { const typedVehicles = vehicles as Array<(typeof vehicles)[number]> - const unavailableVehicleIds = startDate && endDate - ? new Set((await prisma.reservation.findMany({ - where: { - status: { in: ['CONFIRMED', 'ACTIVE'] }, - vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) }, - startDate: { lt: new Date(endDate) }, - endDate: { gt: new Date(startDate) }, - }, - select: { vehicleId: true }, - })).map((reservation: { vehicleId: string }) => reservation.vehicleId)) - : null + const availabilityByVehicleId = new Map( + await Promise.all( + typedVehicles.map(async (vehicle) => { + const availability = await getVehicleAvailabilitySummary(vehicle.id, { + range: startDate && endDate + ? { startDate: new Date(startDate), endDate: new Date(endDate) } + : undefined, + }) + return [vehicle.id, availability] as const + }), + ), + ) res.json({ data: typedVehicles.map((vehicle) => ({ ...vehicle, - availability: unavailableVehicleIds ? !unavailableVehicleIds.has(vehicle.id) : null, + availability: availabilityByVehicleId.get(vehicle.id)?.available ?? null, + availabilityStatus: availabilityByVehicleId.get(vehicle.id)?.status ?? 'AVAILABLE', + nextAvailableAt: availabilityByVehicleId.get(vehicle.id)?.nextAvailableAt ?? null, })), }) } catch (err) { @@ -148,15 +152,17 @@ router.post('/reservations', async (req, res, next) => { }) if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 }) - const conflict = await prisma.reservation.findFirst({ - where: { - vehicleId: body.vehicleId, - status: { in: ['CONFIRMED', 'ACTIVE'] }, - startDate: { lt: endDate }, - endDate: { gt: startDate }, - }, + const availability = await getVehicleAvailabilitySummary(body.vehicleId, { + range: { startDate, endDate }, }) - if (conflict) return res.status(409).json({ error: 'unavailable', message: 'Vehicle is not available for the selected dates', statusCode: 409 }) + if (!availability.available) { + return res.status(409).json({ + error: 'unavailable', + message: 'Vehicle is not available for the selected dates', + statusCode: 409, + nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null, + }) + } const customer = await prisma.customer.upsert({ where: { companyId_email: { companyId: vehicle.companyId, email: body.email } }, @@ -248,7 +254,18 @@ router.get('/:slug', async (req, res, next) => { }, }) if (!company) return res.status(404).json({ error: 'not_found', message: 'Company not found', statusCode: 404 }) - res.json({ data: company }) + const vehicles = await Promise.all( + company.vehicles.map(async (vehicle) => { + const availability = await getVehicleAvailabilitySummary(vehicle.id) + return { + ...vehicle, + availability: availability.available, + availabilityStatus: availability.status, + nextAvailableAt: availability.nextAvailableAt, + } + }), + ) + res.json({ data: { ...company, vehicles } }) } catch (err) { if (isDatabaseUnavailableError(err)) { return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 }) @@ -294,13 +311,10 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => { where: { id: req.params.id, companyId: company.id, isPublished: true }, include: { company: { include: { brand: true } }, - reservations: { - where: { status: { in: ['CONFIRMED', 'ACTIVE'] } }, - select: { startDate: true, endDate: true }, - }, }, }) - res.json({ data: vehicle }) + const availability = await getVehicleAvailabilitySummary(vehicle.id) + res.json({ data: { ...vehicle, availabilityStatus: availability.status, availability: availability.available, nextAvailableAt: availability.nextAvailableAt } }) } catch (err) { if (isDatabaseUnavailableError(err)) { return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 }) diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index ab61a01..68c5900 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -8,6 +8,7 @@ import { applyPricingRules } from '../services/pricingRuleService' import { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService' import * as paypal from '../services/paypalService' import { getMarketplaceHomepageContent } from '../services/platformContentService' +import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService' const router = Router() @@ -67,7 +68,18 @@ router.get('/:slug/vehicles', async (req, res, next) => { where: { companyId: company.id, isPublished: true }, orderBy: { createdAt: 'desc' }, }) - res.json({ data: vehicles }) + const enrichedVehicles = await Promise.all( + vehicles.map(async (vehicle) => { + const availability = await getVehicleAvailabilitySummary(vehicle.id) + return { + ...vehicle, + availability: availability.available, + availabilityStatus: availability.status, + nextAvailableAt: availability.nextAvailableAt, + } + }), + ) + res.json({ data: enrichedVehicles }) } catch (err) { if (isDatabaseUnavailableError(err)) return res.json({ data: [] }) next(err) @@ -79,14 +91,9 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => { const company = await findCompanyBySlug(req.params.slug) const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: company.id, isPublished: true }, - include: { - reservations: { - where: { status: { in: ['CONFIRMED', 'ACTIVE'] } }, - select: { startDate: true, endDate: true, status: true }, - }, - }, }) - res.json({ data: vehicle }) + const availability = await getVehicleAvailabilitySummary(vehicle.id) + res.json({ data: { ...vehicle, availability: availability.available, availabilityStatus: availability.status, nextAvailableAt: availability.nextAvailableAt } }) } catch (err) { if (isDatabaseUnavailableError(err)) { return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 }) @@ -143,18 +150,10 @@ router.post('/:slug/availability', async (req, res, next) => { endDate: z.string().datetime(), }).parse(req.body) - const conflicts = await prisma.reservation.findMany({ - where: { - companyId: company.id, - vehicleId, - status: { in: ['CONFIRMED', 'ACTIVE'] }, - startDate: { lt: new Date(endDate) }, - endDate: { gt: new Date(startDate) }, - }, - select: { startDate: true, endDate: true }, + const availability = await getVehicleAvailabilitySummary(vehicleId, { + range: { startDate: new Date(startDate), endDate: new Date(endDate) }, }) - - res.json({ data: { available: conflicts.length === 0, conflicts } }) + res.json({ data: { available: availability.available, nextAvailableAt: availability.nextAvailableAt } }) } catch (err) { if (isDatabaseUnavailableError(err)) { return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 }) @@ -225,6 +224,23 @@ router.post('/:slug/book', async (req, res, next) => { const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: body.vehicleId, companyId: company.id, isPublished: true }, }) + const start = new Date(body.startDate) + const end = new Date(body.endDate) + if (end <= start) { + return res.status(400).json({ error: 'invalid_dates', message: 'End date must be after start date', statusCode: 400 }) + } + + const availability = await getVehicleAvailabilitySummary(vehicle.id, { + range: { startDate: start, endDate: end }, + }) + if (!availability.available) { + return res.status(409).json({ + error: 'unavailable', + message: 'Vehicle is not available for the selected dates', + statusCode: 409, + nextAvailableAt: availability.nextAvailableAt?.toISOString() ?? null, + }) + } const customer = await prisma.customer.upsert({ where: { companyId_email: { companyId: company.id, email: body.email } }, @@ -251,9 +267,6 @@ router.post('/:slug/book', async (req, res, next) => { nationality: body.nationality ?? null, }, }) - - const start = new Date(body.startDate) - const end = new Date(body.endDate) const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000)) const baseAmount = vehicle.dailyRate * totalDays diff --git a/apps/api/src/routes/vehicles.ts b/apps/api/src/routes/vehicles.ts index ceb4910..161dc69 100644 --- a/apps/api/src/routes/vehicles.ts +++ b/apps/api/src/routes/vehicles.ts @@ -112,18 +112,137 @@ router.get('/:id/availability', async (req, res, next) => { const { startDate, endDate } = req.query as Record if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 }) - const conflicts = await prisma.reservation.findMany({ - where: { - vehicleId: req.params.id, - companyId: req.companyId, - status: { in: ['CONFIRMED', 'ACTIVE'] }, - startDate: { lt: new Date(endDate) }, - endDate: { gt: new Date(startDate) }, - }, - select: { id: true, startDate: true, endDate: true, status: true }, - }) + const start = new Date(startDate) + const end = new Date(endDate) - res.json({ data: { available: conflicts.length === 0, conflicts } }) + const [reservationConflicts, blockConflicts] = await Promise.all([ + prisma.reservation.findMany({ + where: { + vehicleId: req.params.id, + companyId: req.companyId, + status: { in: ['CONFIRMED', 'ACTIVE'] }, + startDate: { lt: end }, + endDate: { gt: start }, + }, + select: { id: true, startDate: true, endDate: true, status: true }, + }), + prisma.vehicleCalendarBlock.findMany({ + where: { + vehicleId: req.params.id, + startDate: { lt: end }, + endDate: { gt: start }, + }, + select: { id: true, startDate: true, endDate: true, type: true, reason: true }, + }), + ]) + + res.json({ + data: { + available: reservationConflicts.length === 0 && blockConflicts.length === 0, + conflicts: reservationConflicts, + blocks: blockConflicts, + }, + }) + } catch (err) { next(err) } +}) + +router.get('/:id/calendar', async (req, res, next) => { + try { + const year = parseInt(req.query.year as string) + const month = parseInt(req.query.month as string) + if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { + return res.status(400).json({ error: 'invalid_params', message: 'year and month (1-12) required', statusCode: 400 }) + } + + // Validate vehicle belongs to company + await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + + const rangeStart = new Date(year, month - 1, 1) + const rangeEnd = new Date(year, month, 1) + + const [reservations, blocks] = await Promise.all([ + prisma.reservation.findMany({ + where: { + vehicleId: req.params.id, + companyId: req.companyId, + status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] }, + startDate: { lt: rangeEnd }, + endDate: { gt: rangeStart }, + }, + select: { + id: true, startDate: true, endDate: true, status: true, + customer: { select: { firstName: true, lastName: true } }, + }, + orderBy: { startDate: 'asc' }, + }), + prisma.vehicleCalendarBlock.findMany({ + where: { + vehicleId: req.params.id, + startDate: { lt: rangeEnd }, + endDate: { gt: rangeStart }, + }, + orderBy: { startDate: 'asc' }, + }), + ]) + + const events = [ + ...reservations.map((r) => ({ + id: r.id, + type: 'RESERVATION' as const, + startDate: r.startDate, + endDate: r.endDate, + status: r.status, + label: r.customer ? `${r.customer.firstName} ${r.customer.lastName}` : 'Reserved', + })), + ...blocks.map((b) => ({ + id: b.id, + type: b.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const, + startDate: b.startDate, + endDate: b.endDate, + status: null, + label: b.reason ?? (b.type === 'MAINTENANCE' ? 'Maintenance' : 'Blocked'), + })), + ] + + res.json({ data: events }) + } catch (err) { next(err) } +}) + +router.post('/:id/calendar/blocks', async (req, res, next) => { + try { + const body = z.object({ + startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), + endDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)), + reason: z.string().optional(), + type: z.enum(['MANUAL', 'MAINTENANCE']).default('MANUAL'), + }).parse(req.body) + + const start = new Date(body.startDate) + const end = new Date(body.endDate) + if (end <= start) return res.status(400).json({ error: 'invalid_range', message: 'endDate must be after startDate', statusCode: 400 }) + + await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + + const block = await prisma.vehicleCalendarBlock.create({ + data: { + vehicleId: req.params.id, + startDate: start, + endDate: end, + reason: body.reason, + type: body.type, + }, + }) + res.status(201).json({ data: block }) + } catch (err) { next(err) } +}) + +router.delete('/:id/calendar/blocks/:blockId', async (req, res, next) => { + try { + await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } }) + await prisma.vehicleCalendarBlock.deleteMany({ + where: { id: req.params.blockId, vehicleId: req.params.id }, + }) + res.json({ data: { success: true } }) } catch (err) { next(err) } }) diff --git a/apps/api/src/services/vehicleAvailabilityService.ts b/apps/api/src/services/vehicleAvailabilityService.ts new file mode 100644 index 0000000..a49e09e --- /dev/null +++ b/apps/api/src/services/vehicleAvailabilityService.ts @@ -0,0 +1,150 @@ +import { prisma } from '../lib/prisma' + +const BLOCKING_RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE'] as const + +type AvailabilityStatus = 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + +type DateRange = { + startDate: Date + endDate: Date +} + +type AvailabilitySource = { + startDate: Date + endDate: Date + kind: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' +} + +export interface VehicleAvailabilitySummary { + available: boolean + status: AvailabilityStatus + nextAvailableAt: Date | null + blockingReason: 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' | 'STATUS' | null +} + +function startOfTodayUtc() { + const now = new Date() + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())) +} + +function overlaps(range: DateRange, source: AvailabilitySource) { + return source.startDate < range.endDate && source.endDate > range.startDate +} + +function getNextAvailableAt(anchor: Date, sources: AvailabilitySource[]) { + const sorted = sources + .filter((source) => source.endDate > anchor) + .sort((a, b) => { + const startDelta = a.startDate.getTime() - b.startDate.getTime() + if (startDelta !== 0) return startDelta + return a.endDate.getTime() - b.endDate.getTime() + }) + + let candidate = anchor + let advanced = false + + for (const source of sorted) { + if (source.endDate <= candidate) continue + if (source.startDate > candidate) break + candidate = new Date(Math.max(candidate.getTime(), source.endDate.getTime())) + advanced = true + } + + return advanced ? candidate : null +} + +export async function getVehicleAvailabilitySummary( + vehicleId: string, + { + range, + includeDraftReservations = true, + }: { + range?: DateRange + includeDraftReservations?: boolean + } = {}, +): Promise { + const vehicle = await prisma.vehicle.findUniqueOrThrow({ + where: { id: vehicleId }, + select: { status: true }, + }) + + if (vehicle.status === 'OUT_OF_SERVICE') { + return { + available: false, + status: 'UNAVAILABLE', + nextAvailableAt: null, + blockingReason: 'STATUS', + } + } + + const anchor = range?.startDate ?? startOfTodayUtc() + const reservationStatuses = includeDraftReservations + ? [...BLOCKING_RESERVATION_STATUSES] + : BLOCKING_RESERVATION_STATUSES.filter((status) => status !== 'DRAFT') + + const [reservations, blocks] = await Promise.all([ + prisma.reservation.findMany({ + where: { + vehicleId, + status: { in: reservationStatuses }, + endDate: { gt: anchor }, + }, + select: { startDate: true, endDate: true }, + orderBy: { startDate: 'asc' }, + }), + prisma.vehicleCalendarBlock.findMany({ + where: { + vehicleId, + endDate: { gt: anchor }, + }, + select: { startDate: true, endDate: true, type: true }, + orderBy: { startDate: 'asc' }, + }), + ]) + + const sources: AvailabilitySource[] = [ + ...reservations.map((reservation) => ({ + startDate: reservation.startDate, + endDate: reservation.endDate, + kind: 'RESERVATION' as const, + })), + ...blocks.map((block) => ({ + startDate: block.startDate, + endDate: block.endDate, + kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const, + })), + ] + + const overlappingSources = range ? sources.filter((source) => overlaps(range, source)) : sources.filter((source) => source.startDate <= anchor && source.endDate > anchor) + const currentSource = overlappingSources.sort((a, b) => { + const priority = { MAINTENANCE: 0, BLOCK: 1, RESERVATION: 2 } + return priority[a.kind] - priority[b.kind] + })[0] + + if (vehicle.status === 'MAINTENANCE') { + return { + available: false, + status: 'MAINTENANCE', + nextAvailableAt: getNextAvailableAt(anchor, sources) ?? null, + blockingReason: currentSource?.kind ?? 'STATUS', + } + } + + if (!currentSource) { + return { + available: true, + status: 'AVAILABLE', + nextAvailableAt: null, + blockingReason: null, + } + } + + const nextAvailableAnchor = currentSource.startDate > anchor ? currentSource.startDate : anchor + + return { + available: false, + status: currentSource.kind === 'RESERVATION' ? 'RESERVED' : currentSource.kind === 'MAINTENANCE' ? 'MAINTENANCE' : 'UNAVAILABLE', + nextAvailableAt: getNextAvailableAt(nextAvailableAnchor, sources), + blockingReason: currentSource.kind, + } +} diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index 7901df5..e049ccb 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -34,6 +34,15 @@ const nextConfig = { ], }, transpilePackages: ['@rentaldrivego/types'], + async rewrites() { + const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') + return [ + { + source: '/api/:path*', + destination: `${apiOrigin}/api/:path*`, + }, + ] + }, } module.exports = nextConfig diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx index fe573e3..121fc54 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/fleet/[id]/page.tsx @@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react' import { useParams, useRouter } from 'next/navigation' import Image from 'next/image' -import { ArrowLeft, Edit, X, Check, Upload, Trash2 } from 'lucide-react' +import { ArrowLeft, Edit, X, Check, Upload, Trash2, CalendarDays, Info } from 'lucide-react' import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' +import VehicleCalendar from '@/components/VehicleCalendar' interface VehicleDetail { id: string @@ -65,11 +66,14 @@ function initColorSelect(color: string) { return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '') } +type Tab = 'details' | 'calendar' + export default function FleetDetailPage() { const params = useParams<{ id: string }>() const router = useRouter() const [vehicle, setVehicle] = useState(null) const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState('details') const [editing, setEditing] = useState(false) const [saving, setSaving] = useState(false) const [saveError, setSaveError] = useState(null) @@ -253,7 +257,34 @@ export default function FleetDetailPage() {
{saveError}
)} -
+ {/* Tab bar */} +
+ + +
+ + {activeTab === 'details' && ( +
{/* Photos */}

Photos

@@ -493,7 +524,12 @@ export default function FleetDetailPage() {
)}
-
+ + )} + + {activeTab === 'calendar' && ( + + )} ) } diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx index 6fa55bd..ed4d105 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/online-reservations/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState, useCallback } from 'react' import dayjs from 'dayjs' import Link from 'next/link' -import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types' +import { formatCurrency } from '@rentaldrivego/types' import { apiFetch } from '@/lib/api' import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react' @@ -81,9 +81,9 @@ export default function OnlineReservationsPage() { const load = useCallback(async () => { try { setLoading(true) - const result = await apiFetch>('/reservations?source=MARKETPLACE&pageSize=100') + const result = await apiFetch('/reservations?source=MARKETPLACE&pageSize=100') setRows( - (result.data ?? []).sort((a, b) => { + (result ?? []).sort((a, b) => { // DRAFT first, then by createdAt desc if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1 if (a.status !== 'DRAFT' && b.status === 'DRAFT') return 1 diff --git a/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx index 065dc1e..73247fb 100644 --- a/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/dashboard/reports/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { formatCurrency } from '@rentaldrivego/types' -import { apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api' +import { API_BASE, apiFetch, EMPLOYEE_TOKEN_KEY } from '@/lib/api' interface ReportRow { reservationId: string @@ -37,8 +37,6 @@ const periodLabels: Record = { ANNUAL: 'Annual', } -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' - export default function ReportsPage() { const [period, setPeriod] = useState('MONTHLY') const [report, setReport] = useState(null) diff --git a/apps/dashboard/src/app/forgot-password/page.tsx b/apps/dashboard/src/app/forgot-password/page.tsx index ddb0462..2f217d9 100644 --- a/apps/dashboard/src/app/forgot-password/page.tsx +++ b/apps/dashboard/src/app/forgot-password/page.tsx @@ -3,10 +3,9 @@ import Link from 'next/link' import { useState } from 'react' import { useDashboardI18n } from '@/components/I18nProvider' +import { API_BASE } from '@/lib/api' import { marketplaceUrl } from '@/lib/urls' -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' - export default function ForgotPasswordPage() { const { language } = useDashboardI18n() const dict = { diff --git a/apps/dashboard/src/app/reset-password/page.tsx b/apps/dashboard/src/app/reset-password/page.tsx index 3548c09..bcc578b 100644 --- a/apps/dashboard/src/app/reset-password/page.tsx +++ b/apps/dashboard/src/app/reset-password/page.tsx @@ -4,8 +4,7 @@ import Link from 'next/link' import { useState, Suspense } from 'react' import { useSearchParams } from 'next/navigation' import { useDashboardI18n } from '@/components/I18nProvider' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' +import { API_BASE } from '@/lib/api' export default function ResetPasswordPage() { return ( diff --git a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx index 4a30b74..616ecf5 100644 --- a/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx +++ b/apps/dashboard/src/app/sign-in/[[...sign-in]]/page.tsx @@ -4,11 +4,8 @@ import Link from 'next/link' import { useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useDashboardI18n } from '@/components/I18nProvider' -import { marketplaceUrl } from '@/lib/urls' -import { EMPLOYEE_TOKEN_KEY } from '@/lib/api' - -const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' -const ADMIN_URL = process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002' +import { adminUrl, marketplaceUrl } from '@/lib/urls' +import { API_BASE, EMPLOYEE_TOKEN_KEY } from '@/lib/api' export default function SignInPage() { const { language } = useDashboardI18n() @@ -141,11 +138,11 @@ function LocalSignInForm({ dict, portal }: { const [error, setError] = useState(null) const adminNext = searchParams.get('next') || '/dashboard' const employeeRedirect = searchParams.get('redirect') || '/dashboard' - const forgotPasswordHref = portal === 'admin' ? `${ADMIN_URL}/forgot-password` : '/forgot-password' + const forgotPasswordHref = portal === 'admin' ? `${adminUrl}/forgot-password` : '/forgot-password' function redirectAdmin(token: string) { const hash = new URLSearchParams({ token, next: adminNext }).toString() - window.location.href = `${ADMIN_URL}/auth-redirect#${hash}` + window.location.href = `${adminUrl}/auth-redirect#${hash}` } async function handleCredentials(e: React.FormEvent) { diff --git a/apps/dashboard/src/components/VehicleCalendar.tsx b/apps/dashboard/src/components/VehicleCalendar.tsx new file mode 100644 index 0000000..9675785 --- /dev/null +++ b/apps/dashboard/src/components/VehicleCalendar.tsx @@ -0,0 +1,412 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { ChevronLeft, ChevronRight, Plus, X, Wrench, Ban, CalendarDays } from 'lucide-react' +import { apiFetch } from '@/lib/api' + +type EventType = 'RESERVATION' | 'MAINTENANCE' | 'BLOCK' + +interface CalendarEvent { + id: string + type: EventType + startDate: string + endDate: string + status: string | null + label: string +} + +interface Props { + vehicleId: string +} + +const EVENT_STYLES: Record = { + RESERVATION: { bg: 'bg-blue-100', text: 'text-blue-800', border: 'border-blue-300', dot: 'bg-blue-500' }, + MAINTENANCE: { bg: 'bg-amber-100', text: 'text-amber-800', border: 'border-amber-300', dot: 'bg-amber-500' }, + BLOCK: { bg: 'bg-red-100', text: 'text-red-800', border: 'border-red-300', dot: 'bg-red-500' }, +} + +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10) +} + +function parseDateStr(s: string): Date { + const [y, m, day] = s.split('-').map(Number) + return new Date(y, m - 1, day) +} + +function isSameDay(a: Date, b: Date): boolean { + return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate() +} + +function daysBetween(start: Date, end: Date): number { + return Math.round((end.getTime() - start.getTime()) / 86400000) +} + +export default function VehicleCalendar({ vehicleId }: Props) { + const today = new Date() + const [year, setYear] = useState(today.getFullYear()) + const [month, setMonth] = useState(today.getMonth() + 1) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Block-creation modal + const [showModal, setShowModal] = useState(false) + const [blockStart, setBlockStart] = useState('') + const [blockEnd, setBlockEnd] = useState('') + const [blockReason, setBlockReason] = useState('') + const [blockType, setBlockType] = useState<'MANUAL' | 'MAINTENANCE'>('MANUAL') + const [saving, setSaving] = useState(false) + const [saveError, setSaveError] = useState(null) + + // Day-click selection for quick create + const [selectStart, setSelectStart] = useState(null) + + const fetchCalendar = useCallback(async () => { + setLoading(true) + setError(null) + try { + const data = await apiFetch( + `/vehicles/${vehicleId}/calendar?year=${year}&month=${month}` + ) + setEvents(data) + } catch (e: any) { + setError(e.message ?? 'Failed to load calendar') + } finally { + setLoading(false) + } + }, [vehicleId, year, month]) + + useEffect(() => { fetchCalendar() }, [fetchCalendar]) + + const prevMonth = () => { + if (month === 1) { setYear(y => y - 1); setMonth(12) } + else setMonth(m => m - 1) + } + const nextMonth = () => { + if (month === 12) { setYear(y => y + 1); setMonth(1) } + else setMonth(m => m + 1) + } + + // Build calendar grid (6 rows × 7 cols) + const firstDay = new Date(year, month - 1, 1) + const daysInMonth = new Date(year, month, 0).getDate() + const startOffset = firstDay.getDay() // 0=Sun + + const gridDays: (Date | null)[] = [] + for (let i = 0; i < startOffset; i++) gridDays.push(null) + for (let d = 1; d <= daysInMonth; d++) gridDays.push(new Date(year, month - 1, d)) + while (gridDays.length % 7 !== 0) gridDays.push(null) + + // Map events to date strings for fast lookup + const eventsOnDay = (day: Date): CalendarEvent[] => { + const dayStr = toDateStr(day) + return events.filter((e) => { + const s = e.startDate.slice(0, 10) + const end = e.endDate.slice(0, 10) + return s <= dayStr && dayStr < end + }) + } + + const isToday = (day: Date) => isSameDay(day, today) + + const handleDayClick = (day: Date) => { + const str = toDateStr(day) + if (!selectStart) { + setSelectStart(str) + } else { + const start = selectStart < str ? selectStart : str + const end = selectStart < str ? str : selectStart + // end date in the modal is the last night, so add one day + const endDate = toDateStr(new Date(parseDateStr(end).getTime() + 86400000)) + setBlockStart(start) + setBlockEnd(endDate) + setSelectStart(null) + setShowModal(true) + } + } + + const openModal = () => { + setBlockStart('') + setBlockEnd('') + setBlockReason('') + setBlockType('MANUAL') + setSaveError(null) + setShowModal(true) + } + + const closeModal = () => { + setShowModal(false) + setSaveError(null) + setSelectStart(null) + } + + const handleSaveBlock = async () => { + if (!blockStart || !blockEnd) { setSaveError('Start and end dates are required.'); return } + if (blockEnd <= blockStart) { setSaveError('End date must be after start date.'); return } + setSaving(true) + setSaveError(null) + try { + await apiFetch(`/vehicles/${vehicleId}/calendar/blocks`, { + method: 'POST', + body: JSON.stringify({ startDate: blockStart, endDate: blockEnd, reason: blockReason || undefined, type: blockType }), + }) + setShowModal(false) + fetchCalendar() + } catch (e: any) { + setSaveError(e.message ?? 'Failed to create block') + } finally { + setSaving(false) + } + } + + const handleDeleteBlock = async (id: string) => { + if (!confirm('Remove this block?')) return + try { + await apiFetch(`/vehicles/${vehicleId}/calendar/blocks/${id}`, { method: 'DELETE' }) + fetchCalendar() + } catch (e: any) { + alert(e.message ?? 'Failed to delete block') + } + } + + const monthLabel = new Date(year, month - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + + // Separate reservations vs blocks for the legend/list below + const reservationEvents = events.filter((e) => e.type === 'RESERVATION') + const blockEvents = events.filter((e) => e.type !== 'RESERVATION') + + return ( +
+ {/* Header */} +
+
+ +

Availability Calendar

+
+
+ + {monthLabel} + + +
+
+ + {selectStart && ( +
+ Click a second day to set the block end date (selected: {selectStart}) + +
+ )} + + {/* Legend */} +
+ Reserved + Maintenance + Blocked + Available +
+ + {error &&
{error}
} + + {/* Calendar grid */} +
+ {/* Day headers */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => ( +
{d}
+ ))} +
+ + {/* Day cells */} +
+ {gridDays.map((day, i) => { + if (!day) return
+ + const dayEvents = eventsOnDay(day) + const isSelected = selectStart === toDateStr(day) + const isPast = day < today && !isToday(day) + + return ( +
handleDayClick(day)} + className={[ + 'bg-white min-h-[72px] p-1.5 cursor-pointer transition-colors', + isToday(day) ? 'ring-2 ring-inset ring-blue-500' : '', + isSelected ? 'bg-blue-50' : 'hover:bg-slate-50', + isPast ? 'opacity-60' : '', + ].filter(Boolean).join(' ')} + > +
+ {day.getDate()} +
+ +
+ {dayEvents.slice(0, 2).map((ev) => { + const s = EVENT_STYLES[ev.type] + return ( +
+ {ev.label} +
+ ) + })} + {dayEvents.length > 2 && ( +
+{dayEvents.length - 2} more
+ )} +
+
+ ) + })} +
+
+ + {/* Events list for this month */} + {(reservationEvents.length > 0 || blockEvents.length > 0) && ( +
+ {reservationEvents.length > 0 && ( +
+

Reservations this month

+
+ {reservationEvents.map((e) => { + const s = EVENT_STYLES[e.type] + const start = e.startDate.slice(0, 10) + const end = e.endDate.slice(0, 10) + const days = daysBetween(parseDateStr(start), parseDateStr(end)) + return ( +
+
+ + {e.label} + {start} → {end} ({days}d) +
+ {e.status} +
+ ) + })} +
+
+ )} + + {blockEvents.length > 0 && ( +
+

Blocks & maintenance

+
+ {blockEvents.map((e) => { + const s = EVENT_STYLES[e.type] + const start = e.startDate.slice(0, 10) + const end = e.endDate.slice(0, 10) + const days = daysBetween(parseDateStr(start), parseDateStr(end)) + const Icon = e.type === 'MAINTENANCE' ? Wrench : Ban + return ( +
+
+ + {e.label} + {start} → {end} ({days}d) +
+ +
+ ) + })} +
+
+ )} +
+ )} + + {/* Add Block Modal */} + {showModal && ( +
+
e.stopPropagation()}> +
+

Block vehicle dates

+ +
+ +
+
+ +
+ + +
+
+ +
+
+ + setBlockStart(e.target.value)} + /> +
+
+ + setBlockEnd(e.target.value)} + /> +
+
+ +
+ + setBlockReason(e.target.value)} + /> +
+
+ + {saveError && ( +
{saveError}
+ )} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 5ae43cb..c0d001d 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -1,5 +1,5 @@ -const API_BASE = - (typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL) +export const API_BASE = + (typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1') ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' diff --git a/apps/dashboard/src/lib/appUrls.ts b/apps/dashboard/src/lib/appUrls.ts new file mode 100644 index 0000000..93f5e77 --- /dev/null +++ b/apps/dashboard/src/lib/appUrls.ts @@ -0,0 +1,12 @@ +export function resolveBrowserAppUrl(fallback: string): string { + if (typeof window === 'undefined') return fallback + + try { + const target = new URL(fallback) + target.protocol = window.location.protocol + target.hostname = window.location.hostname + return target.toString().replace(/\/$/, '') + } catch { + return fallback + } +} diff --git a/apps/dashboard/src/lib/urls.ts b/apps/dashboard/src/lib/urls.ts index d25521e..a48793f 100644 --- a/apps/dashboard/src/lib/urls.ts +++ b/apps/dashboard/src/lib/urls.ts @@ -1 +1,4 @@ -export const marketplaceUrl = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000' +import { resolveBrowserAppUrl } from '@/lib/appUrls' + +export const marketplaceUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000') +export const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002') diff --git a/apps/marketplace/src/app/HomeContent.tsx b/apps/marketplace/src/app/HomeContent.tsx index ef85459..047ae23 100644 --- a/apps/marketplace/src/app/HomeContent.tsx +++ b/apps/marketplace/src/app/HomeContent.tsx @@ -8,6 +8,7 @@ import { type MarketplaceHomepageConfig, type MarketplaceHomepageSectionType, } from '@rentaldrivego/types' +import { resolveBrowserAppUrl } from '@/lib/appUrls' const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1' @@ -16,7 +17,7 @@ export default function HomeContent() { const [content, setContent] = useState(cloneMarketplaceHomepageContent()) const dict = content[language] const sections = resolveMarketplaceHomepageSections(dict.sections) - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD` useEffect(() => { diff --git a/apps/marketplace/src/app/company-workspace/page.tsx b/apps/marketplace/src/app/company-workspace/page.tsx index 1127db0..203fc96 100644 --- a/apps/marketplace/src/app/company-workspace/page.tsx +++ b/apps/marketplace/src/app/company-workspace/page.tsx @@ -1,5 +1,14 @@ -import WorkspaceFrame from '@/components/WorkspaceFrame' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { resolveServerAppUrl } from '@/lib/appUrls' export default function CompanyWorkspacePage() { - return + const requestHeaders = headers() + const dashboardUrl = resolveServerAppUrl( + process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001', + requestHeaders.get('host'), + requestHeaders.get('x-forwarded-proto'), + ) + + redirect(`${dashboardUrl}/dashboard`) } diff --git a/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx b/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx index 2c8415d..e1a2489 100644 --- a/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx +++ b/apps/marketplace/src/app/explore/ExploreVehicleGrid.tsx @@ -12,6 +12,8 @@ interface Vehicle { dailyRate: number photos: string[] availability?: boolean | null + availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + nextAvailableAt?: string | null company: { slug: string brand: { @@ -27,8 +29,14 @@ interface Dict { rentalCompany: string checkAvailability: string available: string + reserved: string + maintenance: string + unavailableStatus: string from: string bookNow: string + availableFrom: string + reserveAfterDate: string + unavailableNoDate: string details: string vehicleUnavailable: string listings: string @@ -61,13 +69,14 @@ function daysBetween(start: string, end: string) { return Math.max(1, Math.ceil(ms / 86_400_000)) } -const today = () => new Date().toISOString().slice(0, 10) -const tomorrow = () => { - const d = new Date() - d.setDate(d.getDate() + 1) - return d.toISOString().slice(0, 10) +function formatAvailabilityDate(value?: string | null) { + if (!value) return null + return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) } +const today = () => new Date().toISOString().slice(0, 10) +const tomorrow = () => new Date(Date.now() + 86_400_000).toISOString().slice(0, 10) + export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) { const [selected, setSelected] = useState(null) const [form, setForm] = useState({ @@ -83,10 +92,12 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic const [errorMsg, setErrorMsg] = useState('') function openModal(vehicle: Vehicle) { + const minStart = vehicle.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : today() + const minEnd = new Date(new Date(minStart).getTime() + 86_400_000).toISOString().slice(0, 10) setSelected(vehicle) setStatus('idle') setErrorMsg('') - setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: today(), endDate: tomorrow(), notes: '' }) + setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' }) } function closeModal() { @@ -97,12 +108,25 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic function set(field: keyof typeof form) { return (e: React.ChangeEvent) => - setForm((prev) => ({ ...prev, [field]: e.target.value })) + setForm((prev) => { + if (field !== 'startDate') return { ...prev, [field]: e.target.value } + const nextStart = e.target.value + const nextEnd = !prev.endDate || prev.endDate <= nextStart + ? new Date(new Date(nextStart).getTime() + 86_400_000).toISOString().slice(0, 10) + : prev.endDate + return { ...prev, startDate: nextStart, endDate: nextEnd } + }) } async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!selected) return + const minStart = selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today() + if (form.startDate < minStart) { + setStatus('error') + setErrorMsg(`${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt) ?? minStart}.`) + return + } setStatus('submitting') setErrorMsg('') try { @@ -121,7 +145,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic } catch (err: any) { setStatus('error') if (err?.code === 'unavailable') { - setErrorMsg(dict.errorUnavailable) + setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable) } else { setErrorMsg(err?.message ?? dict.errorGeneric) } @@ -131,6 +155,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic const days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0 const estimatedTotal = selected ? selected.dailyRate * days : 0 + function getStatusCopy(vehicle: Vehicle) { + if (vehicle.availabilityStatus === 'RESERVED') return dict.reserved + if (vehicle.availabilityStatus === 'MAINTENANCE') return dict.maintenance + if (vehicle.availabilityStatus === 'UNAVAILABLE') return dict.unavailableStatus + if (vehicle.availability === false) return dict.checkAvailability + return dict.available + } + return ( <>
@@ -155,9 +187,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic

{vehicle.year} · {vehicle.category}

- {vehicle.availability === false ? dict.checkAvailability : dict.available} + {getStatusCopy(vehicle)} + {vehicle.availability === false && ( +

+ {vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate} +

+ )}

{dict.from}

@@ -217,6 +254,11 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic ) : (

{dict.reserveVehicle}

+ {selected.availability === false && ( +
+ {selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate} +
+ )} {/* Dates */}
@@ -225,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic + vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string; availability: boolean; nextAvailableAt: string | null }> offers: Array<{ id: string; title: string; discountValue: number; validUntil: string }> } @@ -38,6 +38,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s noOffers: 'No active offers right now.', fleet: 'Fleet', viewVehicle: 'View vehicle', + availableFrom: 'Available from', + unavailableNoDate: 'Temporarily unavailable for new reservations.', }, fr: { unavailable: 'Marketplace indisponible', @@ -54,6 +56,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s noOffers: 'Aucune offre active pour le moment.', fleet: 'Flotte', viewVehicle: 'Voir le véhicule', + availableFrom: 'Disponible à partir du', + unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.', }, ar: { unavailable: 'السوق غير متاح', @@ -70,6 +74,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s noOffers: 'لا توجد عروض نشطة حالياً.', fleet: 'الأسطول', viewVehicle: 'عرض السيارة', + availableFrom: 'متاح ابتداءً من', + unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.', }, }[language] const company = await marketplaceFetchOrDefault(`/marketplace/${params.slug}`, null) @@ -133,6 +139,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s

{vehicle.make} {vehicle.model}

{vehicle.category}

+ {!vehicle.availability && ( +

+ {vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate} +

+ )}

{formatCurrency(vehicle.dailyRate, 'MAD')}

{dict.viewVehicle} diff --git a/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx b/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx index 4c86cf4..cc05eec 100644 --- a/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx +++ b/apps/marketplace/src/app/explore/[slug]/vehicles/[id]/page.tsx @@ -15,6 +15,9 @@ interface VehicleDetail { features: string[] dailyRate: number photos: string[] + availability: boolean + availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + nextAvailableAt: string | null company: { brand: { displayName: string @@ -38,6 +41,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st perDay: '/ day', continueTo: 'Continue to', bookingSite: 'booking site', + availableFrom: 'Available from', + unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', }, fr: { unavailable: 'Marketplace indisponible', @@ -49,6 +54,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st perDay: '/ jour', continueTo: 'Continuer vers le site de réservation de', bookingSite: '', + availableFrom: 'Disponible à partir du', + unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', }, ar: { unavailable: 'السوق غير متاح', @@ -60,6 +67,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st perDay: '/ يوم', continueTo: 'الانتقال إلى موقع حجز', bookingSite: '', + availableFrom: 'متاح ابتداءً من', + unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.', }, }[language] const vehicle = await marketplaceFetchOrDefault(`/marketplace/${params.slug}/vehicles/${params.id}`, null) @@ -82,6 +91,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st } const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace` + const availabilityDate = vehicle.nextAvailableAt + ? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) + : null return (
@@ -102,6 +114,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st

{vehicle.make} {vehicle.model}

{vehicle.year} · {vehicle.category} · {vehicle.transmission}

{formatCurrency(vehicle.dailyRate, 'MAD')} {dict.perDay}

+ {!vehicle.availability && ( +
+ {availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate} +
+ )}
{vehicle.features.map((feature) => ( {feature} diff --git a/apps/marketplace/src/app/explore/page.tsx b/apps/marketplace/src/app/explore/page.tsx index 41ffcde..4a3d6e0 100644 --- a/apps/marketplace/src/app/explore/page.tsx +++ b/apps/marketplace/src/app/explore/page.tsx @@ -12,6 +12,8 @@ interface Vehicle { dailyRate: number photos: string[] availability?: boolean | null + availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + nextAvailableAt?: string | null company: { slug: string brand: { @@ -72,8 +74,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec rentalCompany: 'Rental company', checkAvailability: 'Check availability', available: 'Available', + reserved: 'Reserved', + maintenance: 'In maintenance', + unavailableStatus: 'Unavailable', from: 'From', bookNow: 'Reserve', + availableFrom: 'Available from', + reserveAfterDate: 'This vehicle can only be reserved starting', + unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', details: 'Details', vehicleUnavailable: 'Vehicle listings are unavailable right now.', browseByCategory: 'Browse by category', @@ -123,8 +131,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec rentalCompany: 'Société de location', checkAvailability: 'Vérifier la disponibilité', available: 'Disponible', + reserved: 'Réservé', + maintenance: 'En maintenance', + unavailableStatus: 'Indisponible', from: 'À partir de', bookNow: 'Réserver', + availableFrom: 'Disponible à partir du', + reserveAfterDate: 'Ce véhicule ne peut être réservé qu’à partir du', + unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', details: 'Détails', vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.', browseByCategory: 'Parcourir par catégorie', @@ -173,8 +187,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec rentalCompany: 'شركة تأجير', checkAvailability: 'تحقق من التوفر', available: 'متاح', + reserved: 'محجوز', + maintenance: 'قيد الصيانة', + unavailableStatus: 'غير متاح', from: 'ابتداءً من', bookNow: 'احجز', + availableFrom: 'متاح ابتداءً من', + reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من', + unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.', details: 'التفاصيل', vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.', browseByCategory: 'تصفح حسب الفئة', diff --git a/apps/marketplace/src/app/platform-operations/page.tsx b/apps/marketplace/src/app/platform-operations/page.tsx index 87e72c1..af536c0 100644 --- a/apps/marketplace/src/app/platform-operations/page.tsx +++ b/apps/marketplace/src/app/platform-operations/page.tsx @@ -1,5 +1,14 @@ -import WorkspaceFrame from '@/components/WorkspaceFrame' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { resolveServerAppUrl } from '@/lib/appUrls' export default function PlatformOperationsPage() { - return + const requestHeaders = headers() + const adminUrl = resolveServerAppUrl( + process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002', + requestHeaders.get('host'), + requestHeaders.get('x-forwarded-proto'), + ) + + redirect(`${adminUrl}/dashboard`) } diff --git a/apps/marketplace/src/app/pricing/PricingClient.tsx b/apps/marketplace/src/app/pricing/PricingClient.tsx index ce5e3ca..29fb510 100644 --- a/apps/marketplace/src/app/pricing/PricingClient.tsx +++ b/apps/marketplace/src/app/pricing/PricingClient.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import Link from 'next/link' import { useMarketplacePreferences } from '@/components/MarketplaceShell' +import { resolveBrowserAppUrl } from '@/lib/appUrls' type Currency = 'MAD' | 'USD' | 'EUR' type Billing = 'monthly' | 'annual' @@ -124,7 +125,7 @@ export default function PricingClient() { const dict = copy[language] const [currency, setCurrency] = useState('MAD') const [billing, setBilling] = useState('monthly') - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') const savings = annualSavingsPct('STARTER', currency) // same % for all plans diff --git a/apps/marketplace/src/app/renter/sign-in/page.tsx b/apps/marketplace/src/app/renter/sign-in/page.tsx index 1651759..59bd691 100644 --- a/apps/marketplace/src/app/renter/sign-in/page.tsx +++ b/apps/marketplace/src/app/renter/sign-in/page.tsx @@ -1,6 +1,13 @@ +import { headers } from 'next/headers' import { redirect } from 'next/navigation' +import { resolveServerAppUrl } from '@/lib/appUrls' export default function RenterSignInPage() { - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const requestHeaders = headers() + const dashboardUrl = resolveServerAppUrl( + process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001', + requestHeaders.get('host'), + requestHeaders.get('x-forwarded-proto'), + ) redirect(`${dashboardUrl}/sign-in`) } diff --git a/apps/marketplace/src/app/renter/sign-up/page.tsx b/apps/marketplace/src/app/renter/sign-up/page.tsx index e2f25e5..d18b717 100644 --- a/apps/marketplace/src/app/renter/sign-up/page.tsx +++ b/apps/marketplace/src/app/renter/sign-up/page.tsx @@ -1,6 +1,13 @@ +import { headers } from 'next/headers' import { redirect } from 'next/navigation' +import { resolveServerAppUrl } from '@/lib/appUrls' export default function RenterSignUpPage() { - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const requestHeaders = headers() + const dashboardUrl = resolveServerAppUrl( + process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001', + requestHeaders.get('host'), + requestHeaders.get('x-forwarded-proto'), + ) redirect(`${dashboardUrl}/sign-in`) } diff --git a/apps/marketplace/src/components/MarketplaceShell.tsx b/apps/marketplace/src/components/MarketplaceShell.tsx index b4ad80a..cbc0ca7 100644 --- a/apps/marketplace/src/components/MarketplaceShell.tsx +++ b/apps/marketplace/src/components/MarketplaceShell.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' import { usePathname, useRouter } from 'next/navigation' import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n' +import { resolveBrowserAppUrl } from '@/lib/appUrls' type Theme = 'light' | 'dark' @@ -118,7 +119,8 @@ export function useMarketplacePreferences() { export default function MarketplaceShell({ children }: { children: React.ReactNode }) { const pathname = usePathname() const router = useRouter() - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') + const adminUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_ADMIN_URL ?? 'http://localhost:3002') const [language, setLanguage] = useState('en') const [theme, setTheme] = useState('light') const [hydrated, setHydrated] = useState(false) @@ -193,13 +195,13 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo {dict.marketplace} {dict.companyWorkspace} {dict.platformOperations} diff --git a/apps/marketplace/src/components/WorkspaceTabs.tsx b/apps/marketplace/src/components/WorkspaceTabs.tsx index e8544cf..e24342c 100644 --- a/apps/marketplace/src/components/WorkspaceTabs.tsx +++ b/apps/marketplace/src/components/WorkspaceTabs.tsx @@ -3,10 +3,11 @@ import { useMarketplacePreferences } from '@/components/MarketplaceShell' import Image from 'next/image' import Link from 'next/link' +import { resolveBrowserAppUrl } from '@/lib/appUrls' export default function WorkspaceTabs() { const { language, theme } = useMarketplacePreferences() - const dashboardUrl = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001' + const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001') const copy = { en: { diff --git a/apps/marketplace/src/lib/api.ts b/apps/marketplace/src/lib/api.ts index b6c17c6..507093b 100644 --- a/apps/marketplace/src/lib/api.ts +++ b/apps/marketplace/src/lib/api.ts @@ -6,19 +6,21 @@ const API_BASE = export class MarketplaceApiError extends Error { status: number code?: string + nextAvailableAt?: string | null - constructor(message: string, status: number, code?: string) { + constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) { super(message) this.name = 'MarketplaceApiError' this.status = status this.code = code + this.nextAvailableAt = nextAvailableAt } } export async function marketplaceFetch(path: string): Promise { const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' }) const json = await res.json().catch(() => null) - if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error) + if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt) return json.data as T } @@ -38,6 +40,6 @@ export async function marketplacePost(path: string, body: unknown): Promise null) - if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error) + if (!res.ok) throw new MarketplaceApiError(json?.message ?? 'Request failed', res.status, json?.error, json?.nextAvailableAt) return json.data as T } diff --git a/apps/marketplace/src/lib/appUrls.ts b/apps/marketplace/src/lib/appUrls.ts new file mode 100644 index 0000000..16aac81 --- /dev/null +++ b/apps/marketplace/src/lib/appUrls.ts @@ -0,0 +1,25 @@ +export function resolveBrowserAppUrl(fallback: string): string { + if (typeof window === 'undefined') return fallback + + try { + const target = new URL(fallback) + target.protocol = window.location.protocol + target.hostname = window.location.hostname + return target.toString().replace(/\/$/, '') + } catch { + return fallback + } +} + +export function resolveServerAppUrl(fallback: string, host: string | null, proto?: string | null): string { + if (!host) return fallback + + try { + const target = new URL(fallback) + target.protocol = `${proto || target.protocol.replace(':', '')}:` + target.host = host + return target.toString().replace(/\/$/, '') + } catch { + return fallback + } +} diff --git a/apps/public-site/src/app/book/BookClient.tsx b/apps/public-site/src/app/book/BookClient.tsx index fba85fe..eff279a 100644 --- a/apps/public-site/src/app/book/BookClient.tsx +++ b/apps/public-site/src/app/book/BookClient.tsx @@ -15,6 +15,9 @@ interface VehicleDetail { seats: number transmission: string photos: string[] + availability: boolean + availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + nextAvailableAt: string | null } interface PromoOffer { @@ -94,6 +97,11 @@ function inputClass(error?: string) { ].join(' ') } +function formatAvailabilityDate(value?: string | null) { + if (!value) return null + return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) +} + function insuranceCharge(policy: InsurancePolicy, days: number, baseTotal: number) { if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days if (policy.chargeType === 'PERCENTAGE_OF_RENTAL') return Math.round(baseTotal * policy.chargeValue / 100) @@ -238,6 +246,19 @@ export default function BookClient({ language }: { language: PublicSiteLanguage .finally(() => setVehicleLoading(false)) }, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug]) + useEffect(() => { + if (!vehicle?.nextAvailableAt) return + const minStart = new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) + const minEnd = new Date(new Date(minStart).getTime() + 86400000).toISOString().slice(0, 10) + + setStep1((current) => { + const nextStart = !current.startDate || current.startDate < minStart ? minStart : current.startDate + const nextEnd = !current.endDate || current.endDate <= nextStart ? minEnd : current.endDate + if (nextStart === current.startDate && nextEnd === current.endDate) return current + return { startDate: nextStart, endDate: nextEnd } + }) + }, [vehicle?.nextAvailableAt]) + useEffect(() => { fetch(`${API_BASE}/site/${slug}/booking-options`) .then((r) => r.json()) @@ -261,8 +282,10 @@ export default function BookClient({ language }: { language: PublicSiteLanguage function validateStep1() { const errs: Partial = {} + const minStart = vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : '' if (!step1.startDate) errs.startDate = dict.booking.validation.pickStartDate if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate + if (minStart && step1.startDate && step1.startDate < minStart) errs.startDate = dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle?.nextAvailableAt) ?? minStart) if (step1.startDate && step1.endDate && new Date(step1.endDate) <= new Date(step1.startDate)) errs.endDate = dict.booking.validation.endDateAfterStart setStep1Errors(errs) return Object.keys(errs).length === 0 @@ -346,7 +369,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage }) const json = await res.json() if (!res.ok) { - setSubmitError(json?.message ?? dict.booking.submit.failed) + setSubmitError(json?.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(json.nextAvailableAt) ?? json.nextAvailableAt) : (json?.message ?? dict.booking.submit.failed)) return } setReservationId(json.data.id) @@ -420,13 +443,18 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
)} + {vehicle && !vehicle.availability && ( +
+ {vehicle.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle.nextAvailableAt) ?? vehicle.nextAvailableAt) : dict.booking.vehicleUnavailableNoDate} +
+ )} {step === 1 && (
- setStep1((s) => ({ ...s, startDate: e.target.value }))} className={inputClass(step1Errors.startDate)} /> + setStep1((s) => ({ ...s, startDate: e.target.value, endDate: !s.endDate || s.endDate <= e.target.value ? new Date(new Date(e.target.value).getTime() + 86400000).toISOString().slice(0, 10) : s.endDate }))} className={inputClass(step1Errors.startDate)} /> {step1Errors.startDate &&

{step1Errors.startDate}

}
diff --git a/apps/public-site/src/app/vehicles/[id]/page.tsx b/apps/public-site/src/app/vehicles/[id]/page.tsx index 3ec5a74..4cb56f0 100644 --- a/apps/public-site/src/app/vehicles/[id]/page.tsx +++ b/apps/public-site/src/app/vehicles/[id]/page.tsx @@ -18,6 +18,9 @@ interface VehicleDetail { fuelType: string features: string[] photos: string[] + availability: boolean + availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE' + nextAvailableAt: string | null } interface BrandResponse { @@ -53,6 +56,10 @@ export default async function VehiclePage({ params }: { params: { id: string } } ) } + const availabilityDate = vehicle.nextAvailableAt + ? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) + : null + return (
@@ -68,6 +75,11 @@ export default async function VehiclePage({ params }: { params: { id: string } }

{vehicle.make} {vehicle.model}

{vehicle.year} · {vehicle.category} · {vehicle.transmission}

{formatCurrency(vehicle.dailyRate, 'MAD')} {dict.vehicles.detail.perDay}

+ {!vehicle.availability && ( +
+ {availabilityDate ? `${dict.vehicles.detail.availableFrom} ${availabilityDate}` : dict.vehicles.detail.unavailableNoDate} +
+ )}
{vehicle.features.map((feature) => ( {feature} diff --git a/apps/public-site/src/lib/i18n.ts b/apps/public-site/src/lib/i18n.ts index 7f8ae87..f4a0957 100644 --- a/apps/public-site/src/lib/i18n.ts +++ b/apps/public-site/src/lib/i18n.ts @@ -129,6 +129,8 @@ export type PublicSiteDictionary = { perDay: string bookNow: string back: string + availableFrom: string + unavailableNoDate: string } } booking: { @@ -144,6 +146,8 @@ export type PublicSiteDictionary = { loadingVehicle: string vehicleNotFound: string vehicleLoadError: string + vehicleAvailableFrom: (date: string) => string + vehicleUnavailableNoDate: string bookingOptionsLoadError: string startDate: string endDate: string @@ -378,6 +382,8 @@ const dictionaries: Record = { perDay: '/ day', bookNow: 'Book now', back: 'Back', + availableFrom: 'Available from', + unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', }, }, booking: { @@ -393,6 +399,8 @@ const dictionaries: Record = { loadingVehicle: 'Loading vehicle...', vehicleNotFound: 'Vehicle not found.', vehicleLoadError: 'Could not load vehicle details.', + vehicleAvailableFrom: (date) => `This vehicle can only be reserved starting ${date}.`, + vehicleUnavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.', bookingOptionsLoadError: 'Could not load booking options.', startDate: 'Start date', endDate: 'End date', @@ -625,6 +633,8 @@ const dictionaries: Record = { perDay: '/ jour', bookNow: 'Réserver maintenant', back: 'Retour', + availableFrom: 'Disponible à partir du', + unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', }, }, booking: { @@ -640,6 +650,8 @@ const dictionaries: Record = { loadingVehicle: 'Chargement du véhicule...', vehicleNotFound: 'Véhicule introuvable.', vehicleLoadError: 'Impossible de charger les détails du véhicule.', + vehicleAvailableFrom: (date) => `Ce véhicule ne peut être réservé qu’à partir du ${date}.`, + vehicleUnavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.', bookingOptionsLoadError: 'Impossible de charger les options de réservation.', startDate: 'Date de début', endDate: 'Date de fin', @@ -872,6 +884,8 @@ const dictionaries: Record = { perDay: '/ يوم', bookNow: 'احجز الآن', back: 'رجوع', + availableFrom: 'متاح ابتداءً من', + unavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.', }, }, booking: { @@ -887,6 +901,8 @@ const dictionaries: Record = { loadingVehicle: 'جاري تحميل المركبة...', vehicleNotFound: 'المركبة غير موجودة.', vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.', + vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`, + vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.', bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.', startDate: 'تاريخ البداية', endDate: 'تاريخ النهاية', diff --git a/packages/database/prisma/migrations/20260509000000_add_vehicle_calendar_blocks/migration.sql b/packages/database/prisma/migrations/20260509000000_add_vehicle_calendar_blocks/migration.sql new file mode 100644 index 0000000..da9ff89 --- /dev/null +++ b/packages/database/prisma/migrations/20260509000000_add_vehicle_calendar_blocks/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "CalendarBlockType" AS ENUM ('MANUAL', 'MAINTENANCE'); + +-- CreateTable +CREATE TABLE "vehicle_calendar_blocks" ( + "id" TEXT NOT NULL, + "vehicleId" TEXT NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + "reason" TEXT, + "type" "CalendarBlockType" NOT NULL DEFAULT 'MANUAL', + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL, + + CONSTRAINT "vehicle_calendar_blocks_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "vehicle_calendar_blocks_vehicleId_idx" ON "vehicle_calendar_blocks"("vehicleId"); + +-- CreateIndex +CREATE INDEX "vehicle_calendar_blocks_vehicleId_startDate_endDate_idx" ON "vehicle_calendar_blocks"("vehicleId", "startDate", "endDate"); + +-- AddForeignKey +ALTER TABLE "vehicle_calendar_blocks" ADD CONSTRAINT "vehicle_calendar_blocks_vehicleId_fkey" FOREIGN KEY ("vehicleId") REFERENCES "vehicles"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 8b26cdd..5d85b64 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -315,6 +315,11 @@ enum NotificationStatus { READ } +enum CalendarBlockType { + MANUAL + MAINTENANCE +} + // ═══════════════════════════════════════════════════════════════ // B2B — COMPANY SIDE // ═══════════════════════════════════════════════════════════════ @@ -481,9 +486,10 @@ model Vehicle { notes String? isPublished Boolean @default(true) - reservations Reservation[] - maintenance MaintenanceLog[] - offerVehicles OfferVehicle[] + reservations Reservation[] + maintenance MaintenanceLog[] + offerVehicles OfferVehicle[] + calendarBlocks VehicleCalendarBlock[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -796,6 +802,23 @@ model MaintenanceLog { @@map("maintenance_logs") } +model VehicleCalendarBlock { + id String @id @default(cuid()) + vehicleId String + vehicle Vehicle @relation(fields: [vehicleId], references: [id], onDelete: Cascade) + startDate DateTime @db.Date + endDate DateTime @db.Date + reason String? + type CalendarBlockType @default(MANUAL) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([vehicleId]) + @@index([vehicleId, startDate, endDate]) + @@map("vehicle_calendar_blocks") +} + // ═══════════════════════════════════════════════════════════════ // DOCUMENTS — CONTRACT SETTINGS // ═══════════════════════════════════════════════════════════════