add car reservation feature
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
+35
-22
@@ -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
|
||||
|
||||
|
||||
@@ -112,18 +112,137 @@ router.get('/:id/availability', async (req, res, next) => {
|
||||
const { startDate, endDate } = req.query as Record<string, string>
|
||||
if (!startDate || !endDate) return res.status(400).json({ error: 'missing_params', message: 'startDate and endDate required', statusCode: 400 })
|
||||
|
||||
const conflicts = await prisma.reservation.findMany({
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
const [reservationConflicts, blockConflicts] = await Promise.all([
|
||||
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) },
|
||||
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) }
|
||||
})
|
||||
|
||||
res.json({ data: { available: conflicts.length === 0, conflicts } })
|
||||
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) }
|
||||
})
|
||||
|
||||
|
||||
@@ -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<VehicleAvailabilitySummary> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<VehicleDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
@@ -253,6 +257,33 @@ export default function FleetDetailPage() {
|
||||
<div className="px-4 py-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">{saveError}</div>
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-slate-200 dark:border-slate-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={[
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'details'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<Info className="w-4 h-4" /> Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('calendar')}
|
||||
className={[
|
||||
'flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors',
|
||||
activeTab === 'calendar'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<CalendarDays className="w-4 h-4" /> Calendar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'details' && (
|
||||
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||
{/* Photos */}
|
||||
<div className="card p-6">
|
||||
@@ -494,6 +525,11 @@ export default function FleetDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'calendar' && (
|
||||
<VehicleCalendar vehicleId={params.id} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
|
||||
const result = await apiFetch<OnlineReservation[]>('/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
|
||||
|
||||
@@ -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<ReportPeriod, string> = {
|
||||
ANNUAL: 'Annual',
|
||||
}
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
||||
|
||||
export default function ReportsPage() {
|
||||
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
|
||||
const [report, setReport] = useState<ReportData | null>(null)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<string | null>(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) {
|
||||
|
||||
@@ -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<EventType, { bg: string; text: string; border: string; dot: string }> = {
|
||||
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<CalendarEvent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||||
|
||||
// Day-click selection for quick create
|
||||
const [selectStart, setSelectStart] = useState<string | null>(null)
|
||||
|
||||
const fetchCalendar = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await apiFetch<CalendarEvent[]>(
|
||||
`/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 (
|
||||
<div className="card p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarDays className="w-5 h-5 text-slate-600" />
|
||||
<h3 className="text-base font-semibold text-slate-900">Availability Calendar</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[130px] text-center">{monthLabel}</span>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition-colors">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={openModal} className="ml-2 btn-primary flex items-center gap-1.5 text-xs py-1.5 px-3">
|
||||
<Plus className="w-3.5 h-3.5" /> Block dates
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectStart && (
|
||||
<div className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded-lg px-3 py-2">
|
||||
Click a second day to set the block end date (selected: {selectStart})
|
||||
<button className="ml-2 underline" onClick={() => setSelectStart(null)}>cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-4 text-xs text-slate-600">
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-blue-500 inline-block" />Reserved</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-amber-500 inline-block" />Maintenance</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-red-500 inline-block" />Blocked</span>
|
||||
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-emerald-500 inline-block" />Available</span>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{error}</div>}
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className={loading ? 'opacity-50 pointer-events-none' : ''}>
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day cells */}
|
||||
<div className="grid grid-cols-7 gap-px bg-slate-100 rounded-xl overflow-hidden border border-slate-100">
|
||||
{gridDays.map((day, i) => {
|
||||
if (!day) return <div key={`empty-${i}`} className="bg-white min-h-[72px]" />
|
||||
|
||||
const dayEvents = eventsOnDay(day)
|
||||
const isSelected = selectStart === toDateStr(day)
|
||||
const isPast = day < today && !isToday(day)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toDateStr(day)}
|
||||
onClick={() => 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(' ')}
|
||||
>
|
||||
<div className={[
|
||||
'text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full',
|
||||
isToday(day) ? 'bg-blue-500 text-white' : 'text-slate-700',
|
||||
].join(' ')}>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5">
|
||||
{dayEvents.slice(0, 2).map((ev) => {
|
||||
const s = EVENT_STYLES[ev.type]
|
||||
return (
|
||||
<div key={ev.id} className={`text-[10px] leading-tight px-1 py-0.5 rounded truncate ${s.bg} ${s.text}`}>
|
||||
{ev.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{dayEvents.length > 2 && (
|
||||
<div className="text-[10px] text-slate-400 pl-1">+{dayEvents.length - 2} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Events list for this month */}
|
||||
{(reservationEvents.length > 0 || blockEvents.length > 0) && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{reservationEvents.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Reservations this month</p>
|
||||
<div className="space-y-1.5">
|
||||
{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 (
|
||||
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${s.dot}`} />
|
||||
<span className={`font-medium ${s.text}`}>{e.label}</span>
|
||||
<span className={`text-xs ${s.text} opacity-70`}>{start} → {end} ({days}d)</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full bg-white/60 ${s.text}`}>{e.status}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blockEvents.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide mb-2">Blocks & maintenance</p>
|
||||
<div className="space-y-1.5">
|
||||
{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 (
|
||||
<div key={e.id} className={`flex items-center justify-between px-3 py-2 rounded-lg border text-sm ${s.bg} ${s.border}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`w-3.5 h-3.5 ${s.text}`} />
|
||||
<span className={`font-medium ${s.text}`}>{e.label}</span>
|
||||
<span className={`text-xs ${s.text} opacity-70`}>{start} → {end} ({days}d)</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(e.id)}
|
||||
className={`p-1 rounded hover:bg-white/60 transition-colors ${s.text}`}
|
||||
title="Remove block"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Block Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm" onClick={closeModal}>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 space-y-4" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-base font-semibold text-slate-900">Block vehicle dates</h4>
|
||||
<button onClick={closeModal} className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 hover:bg-slate-100">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Block type</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBlockType('MANUAL')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MANUAL' ? 'bg-red-50 border-red-300 text-red-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Ban className="w-3.5 h-3.5" /> Manual block
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBlockType('MAINTENANCE')}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg border text-sm font-medium transition-colors ${blockType === 'MAINTENANCE' ? 'bg-amber-50 border-amber-300 text-amber-700' : 'border-slate-200 text-slate-600 hover:bg-slate-50'}`}
|
||||
>
|
||||
<Wrench className="w-3.5 h-3.5" /> Maintenance
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Start date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field"
|
||||
value={blockStart}
|
||||
onChange={(e) => setBlockStart(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">End date (exclusive)</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input-field"
|
||||
value={blockEnd}
|
||||
min={blockStart}
|
||||
onChange={(e) => setBlockEnd(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1.5">Reason <span className="font-normal">(optional)</span></label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
placeholder={blockType === 'MAINTENANCE' ? 'e.g. Oil change, tire rotation…' : 'e.g. Reserved for VIP, company event…'}
|
||||
value={blockReason}
|
||||
onChange={(e) => setBlockReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveError && (
|
||||
<div className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">{saveError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button onClick={closeModal} className="flex-1 btn-secondary">Cancel</button>
|
||||
<button onClick={handleSaveBlock} disabled={saving} className="flex-1 btn-primary">
|
||||
{saving ? 'Saving…' : 'Save block'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<MarketplaceHomepageConfig>(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(() => {
|
||||
|
||||
@@ -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 <WorkspaceFrame target="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}/dashboard`)
|
||||
}
|
||||
|
||||
@@ -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<Vehicle | null>(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<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
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 (
|
||||
<>
|
||||
<section>
|
||||
@@ -155,9 +187,14 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${vehicle.availability === false ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{vehicle.availability === false ? dict.checkAvailability : dict.available}
|
||||
{getStatusCopy(vehicle)}
|
||||
</span>
|
||||
</div>
|
||||
{vehicle.availability === false && (
|
||||
<p className="mt-3 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${formatAvailabilityDate(vehicle.nextAvailableAt)}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
||||
@@ -217,6 +254,11 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
|
||||
{selected.availability === false && (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{selected.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(selected.nextAvailableAt)}.` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -225,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
||||
<input
|
||||
type="date"
|
||||
required
|
||||
min={today()}
|
||||
min={selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()}
|
||||
value={form.startDate}
|
||||
onChange={set('startDate')}
|
||||
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
|
||||
|
||||
@@ -16,7 +16,7 @@ interface CompanyProfile {
|
||||
whatsappNumber: string | null
|
||||
marketplaceRating: number | null
|
||||
} | null
|
||||
vehicles: Array<{ id: string; make: string; model: string; dailyRate: number; photos: string[]; category: string }>
|
||||
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<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
|
||||
@@ -133,6 +139,11 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
||||
<div className="p-5">
|
||||
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
||||
<p className="mt-1 text-sm text-stone-500">{vehicle.category}</p>
|
||||
{!vehicle.availability && (
|
||||
<p className="mt-2 text-sm text-stone-500">
|
||||
{vehicle.nextAvailableAt ? `${dict.availableFrom} ${new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })}` : dict.unavailableNoDate}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
||||
<Link href={`/explore/${params.slug}/vehicles/${vehicle.id}`} className="rounded-full bg-stone-900 px-5 py-2.5 text-sm font-semibold text-white">{dict.viewVehicle}</Link>
|
||||
|
||||
@@ -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<VehicleDetail | null>(`/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 (
|
||||
<main className="min-h-screen bg-stone-50 py-10">
|
||||
@@ -102,6 +114,11 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
||||
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
|
||||
{!vehicle.availability && (
|
||||
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
|
||||
|
||||
@@ -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: 'تصفح حسب الفئة',
|
||||
|
||||
@@ -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 <WorkspaceFrame target="admin" />
|
||||
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`)
|
||||
}
|
||||
|
||||
@@ -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<Currency>('MAD')
|
||||
const [billing, setBilling] = useState<Billing>('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
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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<MarketplaceLanguage>('en')
|
||||
const [theme, setTheme] = useState<Theme>('light')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
@@ -193,13 +195,13 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
||||
{dict.marketplace}
|
||||
</Link>
|
||||
<Link
|
||||
href="/company-workspace"
|
||||
href={`${dashboardUrl}/dashboard`}
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.companyWorkspace}
|
||||
</Link>
|
||||
<Link
|
||||
href="/platform-operations"
|
||||
href={`${adminUrl}/dashboard`}
|
||||
className="rounded-full px-4 py-2 text-sm font-medium text-stone-600 transition hover:bg-stone-100 hover:text-stone-900 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.platformOperations}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<T>(path: string): Promise<T> {
|
||||
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<T>(path: string, body: unknown): Promise<T
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Step1> = {}
|
||||
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
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{vehicle && !vehicle.availability && (
|
||||
<div className="mb-6 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{vehicle.nextAvailableAt ? dict.booking.vehicleAvailableFrom(formatAvailabilityDate(vehicle.nextAvailableAt) ?? vehicle.nextAvailableAt) : dict.booking.vehicleUnavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.startDate}</label>
|
||||
<input type="date" value={step1.startDate} min={new Date().toISOString().slice(0, 10)} onChange={(e) => setStep1((s) => ({ ...s, startDate: e.target.value }))} className={inputClass(step1Errors.startDate)} />
|
||||
<input type="date" value={step1.startDate} min={vehicle?.nextAvailableAt ? new Date(vehicle.nextAvailableAt).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10)} onChange={(e) => 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 && <p className="mt-1 text-xs text-red-600">{step1Errors.startDate}</p>}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -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 (
|
||||
<main className="min-h-screen bg-slate-50 py-10">
|
||||
<div className={`shell grid gap-8 ${pageSections.vehicleDetail.gallery && pageSections.vehicleDetail.summary ? 'lg:grid-cols-[1.2fr_0.8fr]' : ''}`}>
|
||||
@@ -68,6 +75,11 @@ export default async function VehiclePage({ params }: { params: { id: string } }
|
||||
<h1 className="text-4xl font-black text-slate-900">{vehicle.make} {vehicle.model}</h1>
|
||||
<p className="mt-3 text-slate-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
|
||||
<p className="mt-6 text-3xl font-black text-slate-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-slate-500"> {dict.vehicles.detail.perDay}</span></p>
|
||||
{!vehicle.availability && (
|
||||
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||
{availabilityDate ? `${dict.vehicles.detail.availableFrom} ${availabilityDate}` : dict.vehicles.detail.unavailableNoDate}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{vehicle.features.map((feature) => (
|
||||
<span key={feature} className="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700">{feature}</span>
|
||||
|
||||
@@ -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<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
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<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
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<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
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<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
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<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
perDay: '/ يوم',
|
||||
bookNow: 'احجز الآن',
|
||||
back: 'رجوع',
|
||||
availableFrom: 'متاح ابتداءً من',
|
||||
unavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
},
|
||||
},
|
||||
booking: {
|
||||
@@ -887,6 +901,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
||||
loadingVehicle: 'جاري تحميل المركبة...',
|
||||
vehicleNotFound: 'المركبة غير موجودة.',
|
||||
vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.',
|
||||
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
|
||||
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
|
||||
startDate: 'تاريخ البداية',
|
||||
endDate: 'تاريخ النهاية',
|
||||
|
||||
+25
@@ -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;
|
||||
@@ -315,6 +315,11 @@ enum NotificationStatus {
|
||||
READ
|
||||
}
|
||||
|
||||
enum CalendarBlockType {
|
||||
MANUAL
|
||||
MAINTENANCE
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// B2B — COMPANY SIDE
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -484,6 +489,7 @@ model Vehicle {
|
||||
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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user