add car reservation feature
This commit is contained in:
@@ -8,15 +8,15 @@ import {
|
|||||||
AdminThemeSwitcher,
|
AdminThemeSwitcher,
|
||||||
useAdminI18n,
|
useAdminI18n,
|
||||||
} from '@/components/I18nProvider'
|
} from '@/components/I18nProvider'
|
||||||
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
|
||||||
|
|
||||||
function buildUnifiedLoginUrl(nextPath: string) {
|
function buildUnifiedLoginUrl(nextPath: string) {
|
||||||
|
const dashboardUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001')
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
portal: 'admin',
|
portal: 'admin',
|
||||||
next: nextPath || '/dashboard',
|
next: nextPath || '/dashboard',
|
||||||
})
|
})
|
||||||
return `${DASHBOARD_URL}/sign-in?${params.toString()}`
|
return `${dashboardUrl}/sign-in?${params.toString()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||||
const DASHBOARD_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3001'
|
|
||||||
|
|
||||||
export default function AdminLoginPage() {
|
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 { prisma } from '../lib/prisma'
|
||||||
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
import { optionalRenterAuth } from '../middleware/requireRenterAuth'
|
||||||
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
import { sendNotification, sendTransactionalEmail } from '../services/notificationService'
|
||||||
|
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
router.use(optionalRenterAuth)
|
router.use(optionalRenterAuth)
|
||||||
@@ -96,22 +97,25 @@ router.get('/search', async (req, res, next) => {
|
|||||||
|
|
||||||
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
|
const typedVehicles = vehicles as Array<(typeof vehicles)[number]>
|
||||||
|
|
||||||
const unavailableVehicleIds = startDate && endDate
|
const availabilityByVehicleId = new Map(
|
||||||
? new Set((await prisma.reservation.findMany({
|
await Promise.all(
|
||||||
where: {
|
typedVehicles.map(async (vehicle) => {
|
||||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
const availability = await getVehicleAvailabilitySummary(vehicle.id, {
|
||||||
vehicleId: { in: typedVehicles.map((vehicle) => vehicle.id) },
|
range: startDate && endDate
|
||||||
startDate: { lt: new Date(endDate) },
|
? { startDate: new Date(startDate), endDate: new Date(endDate) }
|
||||||
endDate: { gt: new Date(startDate) },
|
: undefined,
|
||||||
},
|
})
|
||||||
select: { vehicleId: true },
|
return [vehicle.id, availability] as const
|
||||||
})).map((reservation: { vehicleId: string }) => reservation.vehicleId))
|
}),
|
||||||
: null
|
),
|
||||||
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
data: typedVehicles.map((vehicle) => ({
|
data: typedVehicles.map((vehicle) => ({
|
||||||
...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) {
|
} 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 })
|
if (!vehicle) return res.status(404).json({ error: 'not_found', message: 'Vehicle not found', statusCode: 404 })
|
||||||
|
|
||||||
const conflict = await prisma.reservation.findFirst({
|
const availability = await getVehicleAvailabilitySummary(body.vehicleId, {
|
||||||
where: {
|
range: { startDate, endDate },
|
||||||
vehicleId: body.vehicleId,
|
|
||||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
|
||||||
startDate: { lt: endDate },
|
|
||||||
endDate: { gt: startDate },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
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({
|
const customer = await prisma.customer.upsert({
|
||||||
where: { companyId_email: { companyId: vehicle.companyId, email: body.email } },
|
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 })
|
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) {
|
} catch (err) {
|
||||||
if (isDatabaseUnavailableError(err)) {
|
if (isDatabaseUnavailableError(err)) {
|
||||||
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
|
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 },
|
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
||||||
include: {
|
include: {
|
||||||
company: { include: { brand: true } },
|
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) {
|
} catch (err) {
|
||||||
if (isDatabaseUnavailableError(err)) {
|
if (isDatabaseUnavailableError(err)) {
|
||||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
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 { validateAndFlagLicense, validateLicense } from '../services/licenseValidationService'
|
||||||
import * as paypal from '../services/paypalService'
|
import * as paypal from '../services/paypalService'
|
||||||
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
import { getMarketplaceHomepageContent } from '../services/platformContentService'
|
||||||
|
import { getVehicleAvailabilitySummary } from '../services/vehicleAvailabilityService'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -67,7 +68,18 @@ router.get('/:slug/vehicles', async (req, res, next) => {
|
|||||||
where: { companyId: company.id, isPublished: true },
|
where: { companyId: company.id, isPublished: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
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) {
|
} catch (err) {
|
||||||
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
if (isDatabaseUnavailableError(err)) return res.json({ data: [] })
|
||||||
next(err)
|
next(err)
|
||||||
@@ -79,14 +91,9 @@ router.get('/:slug/vehicles/:id', async (req, res, next) => {
|
|||||||
const company = await findCompanyBySlug(req.params.slug)
|
const company = await findCompanyBySlug(req.params.slug)
|
||||||
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||||
where: { id: req.params.id, companyId: company.id, isPublished: true },
|
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) {
|
} catch (err) {
|
||||||
if (isDatabaseUnavailableError(err)) {
|
if (isDatabaseUnavailableError(err)) {
|
||||||
return res.status(503).json({ error: 'database_unavailable', message: 'Vehicle details are temporarily unavailable', statusCode: 503 })
|
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(),
|
endDate: z.string().datetime(),
|
||||||
}).parse(req.body)
|
}).parse(req.body)
|
||||||
|
|
||||||
const conflicts = await prisma.reservation.findMany({
|
const availability = await getVehicleAvailabilitySummary(vehicleId, {
|
||||||
where: {
|
range: { startDate: new Date(startDate), endDate: new Date(endDate) },
|
||||||
companyId: company.id,
|
|
||||||
vehicleId,
|
|
||||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
|
||||||
startDate: { lt: new Date(endDate) },
|
|
||||||
endDate: { gt: new Date(startDate) },
|
|
||||||
},
|
|
||||||
select: { startDate: true, endDate: true },
|
|
||||||
})
|
})
|
||||||
|
res.json({ data: { available: availability.available, nextAvailableAt: availability.nextAvailableAt } })
|
||||||
res.json({ data: { available: conflicts.length === 0, conflicts } })
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDatabaseUnavailableError(err)) {
|
if (isDatabaseUnavailableError(err)) {
|
||||||
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
|
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({
|
const vehicle = await prisma.vehicle.findFirstOrThrow({
|
||||||
where: { id: body.vehicleId, companyId: company.id, isPublished: true },
|
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({
|
const customer = await prisma.customer.upsert({
|
||||||
where: { companyId_email: { companyId: company.id, email: body.email } },
|
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,
|
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 totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000))
|
||||||
const baseAmount = vehicle.dailyRate * totalDays
|
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>
|
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 })
|
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: {
|
where: {
|
||||||
vehicleId: req.params.id,
|
vehicleId: req.params.id,
|
||||||
companyId: req.companyId,
|
companyId: req.companyId,
|
||||||
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
status: { in: ['CONFIRMED', 'ACTIVE'] },
|
||||||
startDate: { lt: new Date(endDate) },
|
startDate: { lt: end },
|
||||||
endDate: { gt: new Date(startDate) },
|
endDate: { gt: start },
|
||||||
},
|
},
|
||||||
select: { id: true, startDate: true, endDate: true, status: true },
|
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: conflicts.length === 0, conflicts } })
|
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) }
|
} 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'],
|
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
|
module.exports = nextConfig
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
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 { formatCurrency } from '@rentaldrivego/types'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import VehicleCalendar from '@/components/VehicleCalendar'
|
||||||
|
|
||||||
interface VehicleDetail {
|
interface VehicleDetail {
|
||||||
id: string
|
id: string
|
||||||
@@ -65,11 +66,14 @@ function initColorSelect(color: string) {
|
|||||||
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
|
return PRESET_COLORS.includes(color) ? color : (color ? 'custom' : '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tab = 'details' | 'calendar'
|
||||||
|
|
||||||
export default function FleetDetailPage() {
|
export default function FleetDetailPage() {
|
||||||
const params = useParams<{ id: string }>()
|
const params = useParams<{ id: string }>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
const [vehicle, setVehicle] = useState<VehicleDetail | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('details')
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saveError, setSaveError] = useState<string | null>(null)
|
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>
|
<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]">
|
<div className="grid gap-6 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
{/* Photos */}
|
{/* Photos */}
|
||||||
<div className="card p-6">
|
<div className="card p-6">
|
||||||
@@ -494,6 +525,11 @@ export default function FleetDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'calendar' && (
|
||||||
|
<VehicleCalendar vehicleId={params.id} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { formatCurrency, type ApiPaginated } from '@rentaldrivego/types'
|
import { formatCurrency } from '@rentaldrivego/types'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Globe, CheckCircle, XCircle, Clock, ChevronRight, User, Car, CalendarDays, MessageSquare } from 'lucide-react'
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const result = await apiFetch<ApiPaginated<OnlineReservation>>('/reservations?source=MARKETPLACE&pageSize=100')
|
const result = await apiFetch<OnlineReservation[]>('/reservations?source=MARKETPLACE&pageSize=100')
|
||||||
setRows(
|
setRows(
|
||||||
(result.data ?? []).sort((a, b) => {
|
(result ?? []).sort((a, b) => {
|
||||||
// DRAFT first, then by createdAt desc
|
// DRAFT first, then by createdAt desc
|
||||||
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
|
if (a.status === 'DRAFT' && b.status !== 'DRAFT') return -1
|
||||||
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 { useEffect, useState } from 'react'
|
||||||
import { formatCurrency } from '@rentaldrivego/types'
|
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 {
|
interface ReportRow {
|
||||||
reservationId: string
|
reservationId: string
|
||||||
@@ -37,8 +37,6 @@ const periodLabels: Record<ReportPeriod, string> = {
|
|||||||
ANNUAL: 'Annual',
|
ANNUAL: 'Annual',
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
|
const [period, setPeriod] = useState<ReportPeriod>('MONTHLY')
|
||||||
const [report, setReport] = useState<ReportData | null>(null)
|
const [report, setReport] = useState<ReportData | null>(null)
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
|
import { API_BASE } from '@/lib/api'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
import { marketplaceUrl } from '@/lib/urls'
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
||||||
|
|
||||||
export default function ForgotPasswordPage() {
|
export default function ForgotPasswordPage() {
|
||||||
const { language } = useDashboardI18n()
|
const { language } = useDashboardI18n()
|
||||||
const dict = {
|
const dict = {
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import Link from 'next/link'
|
|||||||
import { useState, Suspense } from 'react'
|
import { useState, Suspense } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
|
import { API_BASE } from '@/lib/api'
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function ResetPasswordPage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import Link from 'next/link'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||||
import { marketplaceUrl } from '@/lib/urls'
|
import { adminUrl, marketplaceUrl } from '@/lib/urls'
|
||||||
import { EMPLOYEE_TOKEN_KEY } from '@/lib/api'
|
import { API_BASE, 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'
|
|
||||||
|
|
||||||
export default function SignInPage() {
|
export default function SignInPage() {
|
||||||
const { language } = useDashboardI18n()
|
const { language } = useDashboardI18n()
|
||||||
@@ -141,11 +138,11 @@ function LocalSignInForm({ dict, portal }: {
|
|||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const adminNext = searchParams.get('next') || '/dashboard'
|
const adminNext = searchParams.get('next') || '/dashboard'
|
||||||
const employeeRedirect = searchParams.get('redirect') || '/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) {
|
function redirectAdmin(token: string) {
|
||||||
const hash = new URLSearchParams({ token, next: adminNext }).toString()
|
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) {
|
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 =
|
export const API_BASE =
|
||||||
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : process.env.NEXT_PUBLIC_API_URL)
|
(typeof window === 'undefined' ? process.env.API_INTERNAL_URL : '/api/v1')
|
||||||
?? process.env.NEXT_PUBLIC_API_URL
|
?? process.env.NEXT_PUBLIC_API_URL
|
||||||
?? 'http://localhost:4000/api/v1'
|
?? '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 MarketplaceHomepageConfig,
|
||||||
type MarketplaceHomepageSectionType,
|
type MarketplaceHomepageSectionType,
|
||||||
} from '@rentaldrivego/types'
|
} from '@rentaldrivego/types'
|
||||||
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/api/v1'
|
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 [content, setContent] = useState<MarketplaceHomepageConfig>(cloneMarketplaceHomepageContent())
|
||||||
const dict = content[language]
|
const dict = content[language]
|
||||||
const sections = resolveMarketplaceHomepageSections(dict.sections)
|
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`
|
const starterSignupHref = `${dashboardUrl}/sign-up?plan=STARTER&billing=MONTHLY¤cy=MAD`
|
||||||
|
|
||||||
useEffect(() => {
|
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() {
|
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
|
dailyRate: number
|
||||||
photos: string[]
|
photos: string[]
|
||||||
availability?: boolean | null
|
availability?: boolean | null
|
||||||
|
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||||
|
nextAvailableAt?: string | null
|
||||||
company: {
|
company: {
|
||||||
slug: string
|
slug: string
|
||||||
brand: {
|
brand: {
|
||||||
@@ -27,8 +29,14 @@ interface Dict {
|
|||||||
rentalCompany: string
|
rentalCompany: string
|
||||||
checkAvailability: string
|
checkAvailability: string
|
||||||
available: string
|
available: string
|
||||||
|
reserved: string
|
||||||
|
maintenance: string
|
||||||
|
unavailableStatus: string
|
||||||
from: string
|
from: string
|
||||||
bookNow: string
|
bookNow: string
|
||||||
|
availableFrom: string
|
||||||
|
reserveAfterDate: string
|
||||||
|
unavailableNoDate: string
|
||||||
details: string
|
details: string
|
||||||
vehicleUnavailable: string
|
vehicleUnavailable: string
|
||||||
listings: string
|
listings: string
|
||||||
@@ -61,13 +69,14 @@ function daysBetween(start: string, end: string) {
|
|||||||
return Math.max(1, Math.ceil(ms / 86_400_000))
|
return Math.max(1, Math.ceil(ms / 86_400_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = () => new Date().toISOString().slice(0, 10)
|
function formatAvailabilityDate(value?: string | null) {
|
||||||
const tomorrow = () => {
|
if (!value) return null
|
||||||
const d = new Date()
|
return new Date(value).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||||
d.setDate(d.getDate() + 1)
|
|
||||||
return d.toISOString().slice(0, 10)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }) {
|
export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehicle[]; dict: Dict }) {
|
||||||
const [selected, setSelected] = useState<Vehicle | null>(null)
|
const [selected, setSelected] = useState<Vehicle | null>(null)
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
@@ -83,10 +92,12 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
|||||||
const [errorMsg, setErrorMsg] = useState('')
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
|
|
||||||
function openModal(vehicle: Vehicle) {
|
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)
|
setSelected(vehicle)
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: today(), endDate: tomorrow(), notes: '' })
|
setForm({ firstName: '', lastName: '', email: '', phone: '', startDate: minStart, endDate: minEnd, notes: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
@@ -97,12 +108,25 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
|||||||
|
|
||||||
function set(field: keyof typeof form) {
|
function set(field: keyof typeof form) {
|
||||||
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
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) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!selected) return
|
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')
|
setStatus('submitting')
|
||||||
setErrorMsg('')
|
setErrorMsg('')
|
||||||
try {
|
try {
|
||||||
@@ -121,7 +145,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatus('error')
|
setStatus('error')
|
||||||
if (err?.code === 'unavailable') {
|
if (err?.code === 'unavailable') {
|
||||||
setErrorMsg(dict.errorUnavailable)
|
setErrorMsg(err?.nextAvailableAt ? `${dict.reserveAfterDate} ${formatAvailabilityDate(err.nextAvailableAt)}.` : dict.errorUnavailable)
|
||||||
} else {
|
} else {
|
||||||
setErrorMsg(err?.message ?? dict.errorGeneric)
|
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 days = form.startDate && form.endDate ? daysBetween(form.startDate, form.endDate) : 0
|
||||||
const estimatedTotal = selected ? selected.dailyRate * days : 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<section>
|
<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>
|
<p className="mt-1 text-sm text-stone-500">{vehicle.year} · {vehicle.category}</p>
|
||||||
</div>
|
</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'}`}>
|
<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>
|
</span>
|
||||||
</div>
|
</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 className="mt-5 flex items-end justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-stone-500">{dict.from}</p>
|
<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">
|
<form onSubmit={handleSubmit} className="p-6 space-y-5">
|
||||||
<h3 className="font-semibold text-stone-900">{dict.reserveVehicle}</h3>
|
<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 */}
|
{/* Dates */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -225,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
|
|||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
required
|
required
|
||||||
min={today()}
|
min={selected.nextAvailableAt ? new Date(selected.nextAvailableAt).toISOString().slice(0, 10) : today()}
|
||||||
value={form.startDate}
|
value={form.startDate}
|
||||||
onChange={set('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"
|
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
|
whatsappNumber: string | null
|
||||||
marketplaceRating: number | null
|
marketplaceRating: number | null
|
||||||
} | 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 }>
|
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.',
|
noOffers: 'No active offers right now.',
|
||||||
fleet: 'Fleet',
|
fleet: 'Fleet',
|
||||||
viewVehicle: 'View vehicle',
|
viewVehicle: 'View vehicle',
|
||||||
|
availableFrom: 'Available from',
|
||||||
|
unavailableNoDate: 'Temporarily unavailable for new reservations.',
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
unavailable: 'Marketplace indisponible',
|
unavailable: 'Marketplace indisponible',
|
||||||
@@ -54,6 +56,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
|||||||
noOffers: 'Aucune offre active pour le moment.',
|
noOffers: 'Aucune offre active pour le moment.',
|
||||||
fleet: 'Flotte',
|
fleet: 'Flotte',
|
||||||
viewVehicle: 'Voir le véhicule',
|
viewVehicle: 'Voir le véhicule',
|
||||||
|
availableFrom: 'Disponible à partir du',
|
||||||
|
unavailableNoDate: 'Temporairement indisponible pour les nouvelles réservations.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
unavailable: 'السوق غير متاح',
|
unavailable: 'السوق غير متاح',
|
||||||
@@ -70,6 +74,8 @@ export default async function CompanyProfilePage({ params }: { params: { slug: s
|
|||||||
noOffers: 'لا توجد عروض نشطة حالياً.',
|
noOffers: 'لا توجد عروض نشطة حالياً.',
|
||||||
fleet: 'الأسطول',
|
fleet: 'الأسطول',
|
||||||
viewVehicle: 'عرض السيارة',
|
viewVehicle: 'عرض السيارة',
|
||||||
|
availableFrom: 'متاح ابتداءً من',
|
||||||
|
unavailableNoDate: 'غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
const company = await marketplaceFetchOrDefault<CompanyProfile | null>(`/marketplace/${params.slug}`, null)
|
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">
|
<div className="p-5">
|
||||||
<h3 className="text-xl font-bold text-stone-900">{vehicle.make} {vehicle.model}</h3>
|
<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>
|
<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">
|
<div className="mt-4 flex items-end justify-between">
|
||||||
<p className="text-xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}</p>
|
<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>
|
<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[]
|
features: string[]
|
||||||
dailyRate: number
|
dailyRate: number
|
||||||
photos: string[]
|
photos: string[]
|
||||||
|
availability: boolean
|
||||||
|
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||||
|
nextAvailableAt: string | null
|
||||||
company: {
|
company: {
|
||||||
brand: {
|
brand: {
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -38,6 +41,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
|||||||
perDay: '/ day',
|
perDay: '/ day',
|
||||||
continueTo: 'Continue to',
|
continueTo: 'Continue to',
|
||||||
bookingSite: 'booking site',
|
bookingSite: 'booking site',
|
||||||
|
availableFrom: 'Available from',
|
||||||
|
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
unavailable: 'Marketplace indisponible',
|
unavailable: 'Marketplace indisponible',
|
||||||
@@ -49,6 +54,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
|||||||
perDay: '/ jour',
|
perDay: '/ jour',
|
||||||
continueTo: 'Continuer vers le site de réservation de',
|
continueTo: 'Continuer vers le site de réservation de',
|
||||||
bookingSite: '',
|
bookingSite: '',
|
||||||
|
availableFrom: 'Disponible à partir du',
|
||||||
|
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||||
},
|
},
|
||||||
ar: {
|
ar: {
|
||||||
unavailable: 'السوق غير متاح',
|
unavailable: 'السوق غير متاح',
|
||||||
@@ -60,6 +67,8 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
|
|||||||
perDay: '/ يوم',
|
perDay: '/ يوم',
|
||||||
continueTo: 'الانتقال إلى موقع حجز',
|
continueTo: 'الانتقال إلى موقع حجز',
|
||||||
bookingSite: '',
|
bookingSite: '',
|
||||||
|
availableFrom: 'متاح ابتداءً من',
|
||||||
|
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||||
},
|
},
|
||||||
}[language]
|
}[language]
|
||||||
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
|
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 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 (
|
return (
|
||||||
<main className="min-h-screen bg-stone-50 py-10">
|
<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>
|
<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-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>
|
<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">
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
{vehicle.features.map((feature) => (
|
{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>
|
<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
|
dailyRate: number
|
||||||
photos: string[]
|
photos: string[]
|
||||||
availability?: boolean | null
|
availability?: boolean | null
|
||||||
|
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||||
|
nextAvailableAt?: string | null
|
||||||
company: {
|
company: {
|
||||||
slug: string
|
slug: string
|
||||||
brand: {
|
brand: {
|
||||||
@@ -72,8 +74,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
rentalCompany: 'Rental company',
|
rentalCompany: 'Rental company',
|
||||||
checkAvailability: 'Check availability',
|
checkAvailability: 'Check availability',
|
||||||
available: 'Available',
|
available: 'Available',
|
||||||
|
reserved: 'Reserved',
|
||||||
|
maintenance: 'In maintenance',
|
||||||
|
unavailableStatus: 'Unavailable',
|
||||||
from: 'From',
|
from: 'From',
|
||||||
bookNow: 'Reserve',
|
bookNow: 'Reserve',
|
||||||
|
availableFrom: 'Available from',
|
||||||
|
reserveAfterDate: 'This vehicle can only be reserved starting',
|
||||||
|
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||||
details: 'Details',
|
details: 'Details',
|
||||||
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
vehicleUnavailable: 'Vehicle listings are unavailable right now.',
|
||||||
browseByCategory: 'Browse by category',
|
browseByCategory: 'Browse by category',
|
||||||
@@ -123,8 +131,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
rentalCompany: 'Société de location',
|
rentalCompany: 'Société de location',
|
||||||
checkAvailability: 'Vérifier la disponibilité',
|
checkAvailability: 'Vérifier la disponibilité',
|
||||||
available: 'Disponible',
|
available: 'Disponible',
|
||||||
|
reserved: 'Réservé',
|
||||||
|
maintenance: 'En maintenance',
|
||||||
|
unavailableStatus: 'Indisponible',
|
||||||
from: 'À partir de',
|
from: 'À partir de',
|
||||||
bookNow: 'Réserver',
|
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',
|
details: 'Détails',
|
||||||
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
vehicleUnavailable: 'Les annonces véhicules sont indisponibles pour le moment.',
|
||||||
browseByCategory: 'Parcourir par catégorie',
|
browseByCategory: 'Parcourir par catégorie',
|
||||||
@@ -173,8 +187,14 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
|
|||||||
rentalCompany: 'شركة تأجير',
|
rentalCompany: 'شركة تأجير',
|
||||||
checkAvailability: 'تحقق من التوفر',
|
checkAvailability: 'تحقق من التوفر',
|
||||||
available: 'متاح',
|
available: 'متاح',
|
||||||
|
reserved: 'محجوز',
|
||||||
|
maintenance: 'قيد الصيانة',
|
||||||
|
unavailableStatus: 'غير متاح',
|
||||||
from: 'ابتداءً من',
|
from: 'ابتداءً من',
|
||||||
bookNow: 'احجز',
|
bookNow: 'احجز',
|
||||||
|
availableFrom: 'متاح ابتداءً من',
|
||||||
|
reserveAfterDate: 'يمكن حجز هذه السيارة ابتداءً من',
|
||||||
|
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||||
details: 'التفاصيل',
|
details: 'التفاصيل',
|
||||||
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
vehicleUnavailable: 'قوائم السيارات غير متاحة حالياً.',
|
||||||
browseByCategory: 'تصفح حسب الفئة',
|
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() {
|
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 { useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||||
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
type Currency = 'MAD' | 'USD' | 'EUR'
|
type Currency = 'MAD' | 'USD' | 'EUR'
|
||||||
type Billing = 'monthly' | 'annual'
|
type Billing = 'monthly' | 'annual'
|
||||||
@@ -124,7 +125,7 @@ export default function PricingClient() {
|
|||||||
const dict = copy[language]
|
const dict = copy[language]
|
||||||
const [currency, setCurrency] = useState<Currency>('MAD')
|
const [currency, setCurrency] = useState<Currency>('MAD')
|
||||||
const [billing, setBilling] = useState<Billing>('monthly')
|
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
|
const savings = annualSavingsPct('STARTER', currency) // same % for all plans
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
export default function RenterSignInPage() {
|
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`)
|
redirect(`${dashboardUrl}/sign-in`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
import { headers } from 'next/headers'
|
||||||
import { redirect } from 'next/navigation'
|
import { redirect } from 'next/navigation'
|
||||||
|
import { resolveServerAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
export default function RenterSignUpPage() {
|
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`)
|
redirect(`${dashboardUrl}/sign-in`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link'
|
|||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { usePathname, useRouter } from 'next/navigation'
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
import { MARKETPLACE_LANGUAGE_COOKIE, isMarketplaceLanguage, type MarketplaceLanguage } from '@/lib/i18n'
|
||||||
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
type Theme = 'light' | 'dark'
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
@@ -118,7 +119,8 @@ export function useMarketplacePreferences() {
|
|||||||
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
|
export default function MarketplaceShell({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const router = useRouter()
|
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 [language, setLanguage] = useState<MarketplaceLanguage>('en')
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
const [theme, setTheme] = useState<Theme>('light')
|
||||||
const [hydrated, setHydrated] = useState(false)
|
const [hydrated, setHydrated] = useState(false)
|
||||||
@@ -193,13 +195,13 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
|
|||||||
{dict.marketplace}
|
{dict.marketplace}
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
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}
|
{dict.companyWorkspace}
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
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}
|
{dict.platformOperations}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { resolveBrowserAppUrl } from '@/lib/appUrls'
|
||||||
|
|
||||||
export default function WorkspaceTabs() {
|
export default function WorkspaceTabs() {
|
||||||
const { language, theme } = useMarketplacePreferences()
|
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 = {
|
const copy = {
|
||||||
en: {
|
en: {
|
||||||
|
|||||||
@@ -6,19 +6,21 @@ const API_BASE =
|
|||||||
export class MarketplaceApiError extends Error {
|
export class MarketplaceApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
code?: string
|
code?: string
|
||||||
|
nextAvailableAt?: string | null
|
||||||
|
|
||||||
constructor(message: string, status: number, code?: string) {
|
constructor(message: string, status: number, code?: string, nextAvailableAt?: string | null) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'MarketplaceApiError'
|
this.name = 'MarketplaceApiError'
|
||||||
this.status = status
|
this.status = status
|
||||||
this.code = code
|
this.code = code
|
||||||
|
this.nextAvailableAt = nextAvailableAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
export async function marketplaceFetch<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
const res = await fetch(`${API_BASE}${path}`, { cache: 'no-store' })
|
||||||
const json = await res.json().catch(() => null)
|
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
|
return json.data as T
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +40,6 @@ export async function marketplacePost<T>(path: string, body: unknown): Promise<T
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
const json = await res.json().catch(() => null)
|
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
|
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
|
seats: number
|
||||||
transmission: string
|
transmission: string
|
||||||
photos: string[]
|
photos: string[]
|
||||||
|
availability: boolean
|
||||||
|
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||||
|
nextAvailableAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PromoOffer {
|
interface PromoOffer {
|
||||||
@@ -94,6 +97,11 @@ function inputClass(error?: string) {
|
|||||||
].join(' ')
|
].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) {
|
function insuranceCharge(policy: InsurancePolicy, days: number, baseTotal: number) {
|
||||||
if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days
|
if (policy.chargeType === 'PER_DAY') return policy.chargeValue * days
|
||||||
if (policy.chargeType === 'PERCENTAGE_OF_RENTAL') return Math.round(baseTotal * policy.chargeValue / 100)
|
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))
|
.finally(() => setVehicleLoading(false))
|
||||||
}, [dict.booking.vehicleLoadError, dict.booking.vehicleNotFound, vehicleId, slug])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/site/${slug}/booking-options`)
|
fetch(`${API_BASE}/site/${slug}/booking-options`)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -261,8 +282,10 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
|
|||||||
|
|
||||||
function validateStep1() {
|
function validateStep1() {
|
||||||
const errs: Partial<Step1> = {}
|
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.startDate) errs.startDate = dict.booking.validation.pickStartDate
|
||||||
if (!step1.endDate) errs.endDate = dict.booking.validation.pickEndDate
|
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
|
if (step1.startDate && step1.endDate && new Date(step1.endDate) <= new Date(step1.startDate)) errs.endDate = dict.booking.validation.endDateAfterStart
|
||||||
setStep1Errors(errs)
|
setStep1Errors(errs)
|
||||||
return Object.keys(errs).length === 0
|
return Object.keys(errs).length === 0
|
||||||
@@ -346,7 +369,7 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
|
|||||||
})
|
})
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (!res.ok) {
|
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
|
return
|
||||||
}
|
}
|
||||||
setReservationId(json.data.id)
|
setReservationId(json.data.id)
|
||||||
@@ -420,13 +443,18 @@ export default function BookClient({ language }: { language: PublicSiteLanguage
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{step === 1 && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="grid gap-5 sm:grid-cols-2">
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.booking.startDate}</label>
|
<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>}
|
{step1Errors.startDate && <p className="mt-1 text-xs text-red-600">{step1Errors.startDate}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ interface VehicleDetail {
|
|||||||
fuelType: string
|
fuelType: string
|
||||||
features: string[]
|
features: string[]
|
||||||
photos: string[]
|
photos: string[]
|
||||||
|
availability: boolean
|
||||||
|
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
|
||||||
|
nextAvailableAt: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BrandResponse {
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-slate-50 py-10">
|
<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]' : ''}`}>
|
<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>
|
<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-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>
|
<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">
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
{vehicle.features.map((feature) => (
|
{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>
|
<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
|
perDay: string
|
||||||
bookNow: string
|
bookNow: string
|
||||||
back: string
|
back: string
|
||||||
|
availableFrom: string
|
||||||
|
unavailableNoDate: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
booking: {
|
booking: {
|
||||||
@@ -144,6 +146,8 @@ export type PublicSiteDictionary = {
|
|||||||
loadingVehicle: string
|
loadingVehicle: string
|
||||||
vehicleNotFound: string
|
vehicleNotFound: string
|
||||||
vehicleLoadError: string
|
vehicleLoadError: string
|
||||||
|
vehicleAvailableFrom: (date: string) => string
|
||||||
|
vehicleUnavailableNoDate: string
|
||||||
bookingOptionsLoadError: string
|
bookingOptionsLoadError: string
|
||||||
startDate: string
|
startDate: string
|
||||||
endDate: string
|
endDate: string
|
||||||
@@ -378,6 +382,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
perDay: '/ day',
|
perDay: '/ day',
|
||||||
bookNow: 'Book now',
|
bookNow: 'Book now',
|
||||||
back: 'Back',
|
back: 'Back',
|
||||||
|
availableFrom: 'Available from',
|
||||||
|
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
booking: {
|
booking: {
|
||||||
@@ -393,6 +399,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
loadingVehicle: 'Loading vehicle...',
|
loadingVehicle: 'Loading vehicle...',
|
||||||
vehicleNotFound: 'Vehicle not found.',
|
vehicleNotFound: 'Vehicle not found.',
|
||||||
vehicleLoadError: 'Could not load vehicle details.',
|
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.',
|
bookingOptionsLoadError: 'Could not load booking options.',
|
||||||
startDate: 'Start date',
|
startDate: 'Start date',
|
||||||
endDate: 'End date',
|
endDate: 'End date',
|
||||||
@@ -625,6 +633,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
perDay: '/ jour',
|
perDay: '/ jour',
|
||||||
bookNow: 'Réserver maintenant',
|
bookNow: 'Réserver maintenant',
|
||||||
back: 'Retour',
|
back: 'Retour',
|
||||||
|
availableFrom: 'Disponible à partir du',
|
||||||
|
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
booking: {
|
booking: {
|
||||||
@@ -640,6 +650,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
loadingVehicle: 'Chargement du véhicule...',
|
loadingVehicle: 'Chargement du véhicule...',
|
||||||
vehicleNotFound: 'Véhicule introuvable.',
|
vehicleNotFound: 'Véhicule introuvable.',
|
||||||
vehicleLoadError: 'Impossible de charger les détails du véhicule.',
|
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.',
|
bookingOptionsLoadError: 'Impossible de charger les options de réservation.',
|
||||||
startDate: 'Date de début',
|
startDate: 'Date de début',
|
||||||
endDate: 'Date de fin',
|
endDate: 'Date de fin',
|
||||||
@@ -872,6 +884,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
perDay: '/ يوم',
|
perDay: '/ يوم',
|
||||||
bookNow: 'احجز الآن',
|
bookNow: 'احجز الآن',
|
||||||
back: 'رجوع',
|
back: 'رجوع',
|
||||||
|
availableFrom: 'متاح ابتداءً من',
|
||||||
|
unavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
booking: {
|
booking: {
|
||||||
@@ -887,6 +901,8 @@ const dictionaries: Record<PublicSiteLanguage, PublicSiteDictionary> = {
|
|||||||
loadingVehicle: 'جاري تحميل المركبة...',
|
loadingVehicle: 'جاري تحميل المركبة...',
|
||||||
vehicleNotFound: 'المركبة غير موجودة.',
|
vehicleNotFound: 'المركبة غير موجودة.',
|
||||||
vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.',
|
vehicleLoadError: 'تعذر تحميل تفاصيل المركبة.',
|
||||||
|
vehicleAvailableFrom: (date) => `يمكن حجز هذه المركبة ابتداءً من ${date}.`,
|
||||||
|
vehicleUnavailableNoDate: 'هذه المركبة غير متاحة مؤقتاً للحجوزات الجديدة.',
|
||||||
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
|
bookingOptionsLoadError: 'تعذر تحميل خيارات الحجز.',
|
||||||
startDate: 'تاريخ البداية',
|
startDate: 'تاريخ البداية',
|
||||||
endDate: 'تاريخ النهاية',
|
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
|
READ
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CalendarBlockType {
|
||||||
|
MANUAL
|
||||||
|
MAINTENANCE
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// B2B — COMPANY SIDE
|
// B2B — COMPANY SIDE
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -484,6 +489,7 @@ model Vehicle {
|
|||||||
reservations Reservation[]
|
reservations Reservation[]
|
||||||
maintenance MaintenanceLog[]
|
maintenance MaintenanceLog[]
|
||||||
offerVehicles OfferVehicle[]
|
offerVehicles OfferVehicle[]
|
||||||
|
calendarBlocks VehicleCalendarBlock[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -796,6 +802,23 @@ model MaintenanceLog {
|
|||||||
@@map("maintenance_logs")
|
@@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
|
// DOCUMENTS — CONTRACT SETTINGS
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user