fix signin signout and apply the new style

This commit is contained in:
root
2026-05-24 23:58:54 -04:00
parent bf97a072dd
commit 9bd0938951
68 changed files with 851 additions and 200 deletions
+6 -1
View File
@@ -15,7 +15,10 @@ import offersRouter from './modules/offers/offer.routes'
import analyticsRouter from './modules/analytics/analytics.routes'
import notificationsRouter from './modules/notifications/notification.routes'
import adminRouter from './modules/admin/admin.routes'
import subscriptionsRouter from './modules/subscriptions/subscription.routes'
import subscriptionsRouter, {
subscriptionPublicRouter,
subscriptionWebhookRouter,
} from './modules/subscriptions/subscription.routes'
import paymentsRouter from './modules/payments/payment.routes'
import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes'
@@ -113,6 +116,8 @@ export function createApp() {
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
app.use(`${v1}/vehicles`, apiLimiter, vehiclesRouter)
app.use(`${v1}/reservations`, apiLimiter, reservationsRouter)
+2 -2
View File
@@ -68,7 +68,7 @@
{
"step": "1",
"title": "Create your company workspace",
"body": "Pick a plan, launch your 14-day trial, and verify the owner account."
"body": "Pick a plan, launch your 90-day trial, and verify the owner account."
},
{
"step": "2",
@@ -156,7 +156,7 @@
{
"step": "1",
"title": "Créez votre espace entreprise",
"body": "Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire."
"body": "Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire."
},
{
"step": "2",
+1 -1
View File
@@ -85,7 +85,7 @@ cron.schedule('0 9 * * *', async () => {
const owner = sub.company.employees[0]
if (owner) {
await prisma.notification.create({
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 14-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
data: { type: 'SUBSCRIPTION_TRIAL_ENDING', title: 'Your trial ends soon', body: 'Your 90-day free trial ends in 3 days. Add a payment method to keep access.', companyId: sub.companyId, employeeId: owner.id, channel: 'IN_APP', status: 'DELIVERED' },
})
}
}
@@ -9,6 +9,7 @@ import { companySignupSchema } from './auth.company.schemas'
import type { output } from 'zod'
type CompanySignupInput = output<typeof companySignupSchema>
const TRIAL_PERIOD_DAYS = 90
function slugify(value: string) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || 'company'
@@ -37,7 +38,7 @@ export async function signup(body: CompanySignupInput) {
const slug = await generateUniqueSlug(body.companyName)
const now = new Date()
const trialEndAt = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000)
const trialEndAt = new Date(now.getTime() + TRIAL_PERIOD_DAYS * 24 * 60 * 60 * 1000)
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction((tx) => repo.createCompanySignup(tx, {
@@ -31,14 +31,12 @@ export async function findListedCompanies(where: any, skip: number, take: number
})
}
export async function findPublishedVehicles(where: any, skip: number, take: number) {
export async function findPublishedVehicles(where: any) {
return prisma.vehicle.findMany({
where,
include: {
company: { include: { brand: { select: { displayName: true, logoUrl: true, subdomain: true, marketplaceRating: true, primaryColor: true } } } },
},
skip,
take,
orderBy: { dailyRate: 'asc' },
})
}
@@ -62,7 +60,8 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
export async function createMarketplaceReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; dailyRate: number; totalDays: number; totalAmount: number; notes?: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string
}) {
return prisma.reservation.create({
data: { ...data, source: 'MARKETPLACE', status: 'DRAFT' },
@@ -7,6 +7,9 @@ export const paginationSchema = z.object({
export const searchSchema = z.object({
city: z.string().trim().max(100).optional(),
pickupLocation: z.string().trim().max(100).optional(),
dropoffLocation: z.string().trim().max(100).optional(),
dropoffMode: z.enum(['same', 'different']).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
category: z.string().trim().max(50).optional(),
@@ -30,6 +33,8 @@ export const marketplaceReservationSchema = z.object({
phone: z.string().max(30).optional(),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
pickupLocation: z.string().trim().max(100).optional(),
returnLocation: z.string().trim().max(100).optional(),
notes: z.string().max(500).optional(),
language: z.enum(['en', 'fr', 'ar']).default('fr'),
})
@@ -4,6 +4,46 @@ import { sendNotification, sendTransactionalEmail } from '../../services/notific
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
return normalized ? normalized : null
}
function normalizeLocationList(values?: string[] | null) {
if (!Array.isArray(values)) return []
return values
.map((value) => normalizeLocation(value))
.filter((value): value is string => Boolean(value))
}
function includesLocation(values: string[], target: string) {
const key = target.toLocaleLowerCase()
return values.some((value) => value.toLocaleLowerCase() === key)
}
function matchesVehicleLocationRules(
vehicle: { pickupLocations?: string[]; dropoffLocations?: string[]; allowDifferentDropoff?: boolean },
params: { pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different' },
) {
const pickupLocation = normalizeLocation(params.pickupLocation)
const dropoffLocation = normalizeLocation(params.dropoffLocation)
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
return false
}
if (params.dropoffMode === 'different') {
if (!vehicle.allowDifferentDropoff) return false
if (dropoffLocation && dropoffLocations.length > 0 && !includesLocation(dropoffLocations, dropoffLocation)) {
return false
}
}
return true
}
export async function getPublicOffers() {
return repo.findPublicOffers()
}
@@ -34,7 +74,8 @@ export async function getListedCompanies(params: {
}
export async function searchVehicles(params: {
city?: string; startDate?: string; endDate?: string; category?: string
city?: string; pickupLocation?: string; dropoffLocation?: string; dropoffMode?: 'same' | 'different'
startDate?: string; endDate?: string; category?: string
maxPrice?: number; transmission?: string; make?: string; model?: string
page?: number; pageSize?: number
}) {
@@ -54,10 +95,12 @@ export async function searchVehicles(params: {
where.company = { ...where.company, brand: { isListedOnMarketplace: true, publicCity: { contains: params.city, mode: 'insensitive' } } }
}
const vehicles = await repo.findPublishedVehicles(where, (page - 1) * pageSize, pageSize)
const vehicles = await repo.findPublishedVehicles(where)
const locationFiltered = vehicles.filter((vehicle) => matchesVehicleLocationRules(vehicle, params))
const pagedVehicles = locationFiltered.slice((page - 1) * pageSize, page * pageSize)
const availability = await Promise.all(
vehicles.map(async (v) => {
pagedVehicles.map(async (v) => {
const a = await getVehicleAvailabilitySummary(v.id, {
range: params.startDate && params.endDate
? { startDate: new Date(params.startDate), endDate: new Date(params.endDate) }
@@ -68,7 +111,7 @@ export async function searchVehicles(params: {
)
const availMap = new Map(availability)
return vehicles.map((v) => ({
return pagedVehicles.map((v) => ({
...v,
availability: availMap.get(v.id)?.available ?? null,
availabilityStatus: availMap.get(v.id)?.status ?? 'AVAILABLE',
@@ -79,7 +122,7 @@ export async function searchVehicles(params: {
export async function createMarketplaceReservation(body: {
vehicleId: string; companySlug: string; firstName: string; lastName: string
email: string; phone?: string; startDate: string; endDate: string
notes?: string; language?: string
pickupLocation?: string; returnLocation?: string; notes?: string; language?: string
}) {
const startDate = new Date(body.startDate)
const endDate = new Date(body.endDate)
@@ -91,6 +134,24 @@ export async function createMarketplaceReservation(body: {
const vehicle = await repo.findVehicleForMarketplace(body.vehicleId, body.companySlug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
const returnLocation = normalizeLocation(body.returnLocation) ?? pickupLocation
const pickupLocations = normalizeLocationList(vehicle.pickupLocations)
const dropoffLocations = normalizeLocationList(vehicle.dropoffLocations)
if (pickupLocation && pickupLocations.length > 0 && !includesLocation(pickupLocations, pickupLocation)) {
throw new AppError('Selected pickup location is not available for this vehicle', 400, 'invalid_pickup_location')
}
if (pickupLocation && returnLocation && pickupLocation.toLocaleLowerCase() !== returnLocation.toLocaleLowerCase()) {
if (!vehicle.allowDifferentDropoff) {
throw new AppError('This vehicle must be returned to the same pickup location', 400, 'same_dropoff_required')
}
if (dropoffLocations.length > 0 && !includesLocation(dropoffLocations, returnLocation)) {
throw new AppError('Selected return location is not available for this vehicle', 400, 'invalid_return_location')
}
}
const availability = await getVehicleAvailabilitySummary(body.vehicleId, { range: { startDate, endDate } })
if (!availability.available) {
throw new AppError('Vehicle is not available for the selected dates', 409, 'unavailable', {
@@ -107,7 +168,7 @@ export async function createMarketplaceReservation(body: {
const reservation = await repo.createMarketplaceReservation({
companyId: vehicle.companyId, vehicleId: body.vehicleId, customerId: customer.id,
startDate, endDate, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
startDate, endDate, pickupLocation, returnLocation, dailyRate: vehicle.dailyRate, totalDays, totalAmount, notes: body.notes,
})
const lang = (body.language ?? 'fr') as Lang
@@ -52,6 +52,9 @@ function makeVehicle(overrides: object = {}) {
return {
id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry',
isPublished: true, status: 'AVAILABLE', companyId: 'co-1',
pickupLocations: ['Casablanca'],
allowDifferentDropoff: false,
dropoffLocations: [],
company: { name: 'Test Co', brand: { displayName: 'Test Co' } },
...overrides,
}
@@ -98,6 +101,19 @@ describe('searchVehicles', () => {
})
expect(result[0].availability).toBe(false)
})
it('filters out vehicles that do not support different drop-off when requested', async () => {
vi.mocked(repo.findPublishedVehicles).mockResolvedValue([
makeVehicle({ id: 'same-only' }),
makeVehicle({ id: 'one-way', allowDifferentDropoff: true, dropoffLocations: ['Rabat'] }),
] as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
const result = await searchVehicles({ pickupLocation: 'Casablanca', dropoffLocation: 'Rabat', dropoffMode: 'different' })
expect(result).toHaveLength(1)
expect(result[0].id).toBe('one-way')
})
})
// ────────────────────────────────────────────────────────────────────────────
@@ -114,8 +130,12 @@ describe('createMarketplaceReservation', () => {
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1' } as any)
const result = await createMarketplaceReservation(baseBody)
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
it('throws NotFoundError when vehicle not found', async () => {
@@ -131,6 +151,14 @@ describe('createMarketplaceReservation', () => {
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' })
})
it('throws AppError for invalid date range', async () => {
await expect(
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
@@ -9,21 +9,23 @@ import * as paypal from '../../services/paypalService'
import * as service from './subscription.service'
import { checkoutSchema, changePlanSchema, capturePaypalSchema } from './subscription.schemas'
const publicRouter = Router()
const webhookRouter = Router()
const router = Router()
// ─── Public ────────────────────────────────────────────────────
router.get('/plans', (_req, res) => {
publicRouter.get('/plans', (_req, res) => {
ok(res, service.getPlans())
})
router.get('/providers', (_req, res) => {
publicRouter.get('/providers', (_req, res) => {
ok(res, service.getProviders())
})
// ─── Webhooks (no auth) ────────────────────────────────────────
router.post('/webhooks/amanpay', async (req, res, next) => {
webhookRouter.post('/webhooks/amanpay', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const signature = (req.headers['x-amanpay-signature'] as string) ?? ''
@@ -35,7 +37,7 @@ router.post('/webhooks/amanpay', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/webhooks/paypal', async (req, res, next) => {
webhookRouter.post('/webhooks/paypal', async (req, res, next) => {
try {
const rawBody = JSON.stringify(req.body)
const isValid = await paypal.verifyWebhookEvent(req.headers as Record<string, string>, rawBody)
@@ -97,3 +99,4 @@ router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
})
export default router
export { publicRouter as subscriptionPublicRouter, webhookRouter as subscriptionWebhookRouter }
@@ -1,5 +1,14 @@
function normalizeLocations(value: unknown) {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
export function presentVehicle(vehicle: any) {
return vehicle
return {
...vehicle,
pickupLocations: normalizeLocations(vehicle?.pickupLocations),
allowDifferentDropoff: Boolean(vehicle?.allowDifferentDropoff),
dropoffLocations: normalizeLocations(vehicle?.dropoffLocations),
}
}
export function presentVehicleList(vehicles: any[], meta: { total: number; page: number; pageSize: number; totalPages: number }) {
@@ -17,6 +17,9 @@ export const vehicleSchema = z.object({
notes: z.string().optional(),
isPublished: z.boolean().default(true),
status: z.enum(['AVAILABLE', 'RENTED', 'MAINTENANCE', 'OUT_OF_SERVICE']).optional(),
pickupLocations: z.array(z.string().trim().min(1)).default([]),
allowDifferentDropoff: z.boolean().default(false),
dropoffLocations: z.array(z.string().trim().min(1)).default([]),
})
export const listQuerySchema = z.object({
@@ -3,6 +3,42 @@ import { NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter'
import * as repo from './vehicle.repo'
function normalizeLocations(locations: unknown) {
if (!Array.isArray(locations)) return []
const seen = new Set<string>()
const normalized: string[] = []
for (const entry of locations) {
if (typeof entry !== 'string') continue
const value = entry.trim()
const key = value.toLocaleLowerCase()
if (!value || seen.has(key)) continue
seen.add(key)
normalized.push(value)
}
return normalized
}
function applyLocationSettings(data: any) {
const patch = { ...data }
if (patch.pickupLocations !== undefined) {
patch.pickupLocations = normalizeLocations(patch.pickupLocations)
}
if (patch.dropoffLocations !== undefined) {
patch.dropoffLocations = normalizeLocations(patch.dropoffLocations)
}
if (patch.allowDifferentDropoff === false) {
patch.dropoffLocations = []
}
if (patch.allowDifferentDropoff === true && patch.dropoffLocations !== undefined && patch.dropoffLocations.length === 0) {
throw new ValidationError('Drop-off locations are required when different drop-off is enabled')
}
return patch
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
@@ -22,11 +58,11 @@ export async function getVehicle(id: string, companyId: string) {
}
export async function createVehicle(data: any, companyId: string) {
return presentVehicle(await repo.create({ ...data, companyId }))
return presentVehicle(await repo.create({ ...applyLocationSettings(data), companyId }))
}
export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = { ...data }
const patch = applyLocationSettings(data)
if (patch.status === 'MAINTENANCE' || patch.status === 'OUT_OF_SERVICE') {
patch.isPublished = false
} else if (patch.status === 'AVAILABLE' || patch.status === 'RENTED') {
+3
View File
@@ -52,6 +52,9 @@ export async function createVehicle(companyId: string, overrides: Record<string,
dailyRate: 500,
status: 'AVAILABLE',
isPublished: true,
pickupLocations: ['Casablanca'],
allowDifferentDropoff: false,
dropoffLocations: [],
...overrides,
} as any,
})
@@ -0,0 +1,45 @@
import request from 'supertest'
import { createApp } from '../../app'
const app = createApp()
function uniqueEmail(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@test.com`
}
describe('Company signup API', () => {
it('creates new accounts with a 90-day trial period', async () => {
const startedAt = Date.now()
const res = await request(app)
.post('/api/v1/auth/company/signup')
.send({
firstName: 'Trial',
lastName: 'Owner',
email: uniqueEmail('trial-owner'),
password: 'Password123!',
companyName: `Trial Cars ${Date.now()}`,
legalForm: 'LLC',
registrationNumber: `REG-${Date.now()}`,
streetAddress: '123 Main Street',
city: 'Casablanca',
country: 'Morocco',
zipCode: '20000',
companyPhone: '+212600000000',
yearsActive: '1',
preferredLanguage: 'en',
plan: 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
paymentProvider: 'AMANPAY',
})
expect(res.status).toBe(201)
const trialEndsAt = new Date(res.body.data.trialEndsAt).getTime()
const trialDurationDays = (trialEndsAt - startedAt) / (24 * 60 * 60 * 1000)
expect(trialDurationDays).toBeGreaterThan(89.9)
expect(trialDurationDays).toBeLessThan(90.1)
})
})
@@ -64,11 +64,17 @@ describe('Vehicles API', () => {
licensePlate: `TEST-${Date.now()}`,
category: 'COMPACT',
dailyRate: 400,
pickupLocations: ['Casablanca Airport', 'Casablanca Downtown'],
allowDifferentDropoff: true,
dropoffLocations: ['Rabat Downtown'],
})
expect(res.status).toBe(201)
expect(res.body.data.make).toBe('Honda')
expect(res.body.data.model).toBe('Civic')
expect(res.body.data.pickupLocations).toEqual(['Casablanca Airport', 'Casablanca Downtown'])
expect(res.body.data.allowDifferentDropoff).toBe(true)
expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown'])
})
it('returns 400 for missing required fields', async () => {
@@ -116,6 +122,24 @@ describe('Vehicles API', () => {
expect(res.body.data.notes).toBe('Updated via integration test')
})
it('updates pickup and drop-off settings', async () => {
const vehicle = await createVehicle(companyId)
const res = await request(app)
.patch(`/api/v1/vehicles/${vehicle.id}`)
.set(authHeader(token))
.send({
pickupLocations: ['Casablanca Airport'],
allowDifferentDropoff: true,
dropoffLocations: ['Rabat Downtown', 'Marrakech Center'],
})
expect(res.status).toBe(200)
expect(res.body.data.pickupLocations).toEqual(['Casablanca Airport'])
expect(res.body.data.allowDifferentDropoff).toBe(true)
expect(res.body.data.dropoffLocations).toEqual(['Rabat Downtown', 'Marrakech Center'])
})
it('sets isPublished=false when status=MAINTENANCE', async () => {
const vehicle = await createVehicle(companyId)
+6 -6
View File
@@ -23,14 +23,14 @@ const storagePatterns = [
.filter(Boolean)
.filter((p, i, arr) => arr.findIndex((q) => q.hostname === p.hostname && q.port === p.port) === i)
// When the dashboard is accessed via the marketplace proxy (localhost:3000/dashboard),
// the browser resolves _next/static chunk URLs relative to localhost:3000 — but those
// chunks only exist on the dashboard's own server (localhost:3001). DASHBOARD_ASSET_PREFIX
// makes Next.js embed absolute chunk URLs so the browser fetches them from the right port.
// Set in .env.docker.dev; unset for local/production (where no cross-port proxy is used).
// In Docker dev the dashboard app runs on port 3001 while the marketplace proxy
// is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit
// absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001.
const assetPrefix = process.env.DASHBOARD_ASSET_PREFIX || undefined
const nextConfig = {
basePath: '/dashboard',
...(process.env.DASHBOARD_ASSET_PREFIX ? { assetPrefix: process.env.DASHBOARD_ASSET_PREFIX } : {}),
...(assetPrefix ? { assetPrefix } : {}),
images: {
remotePatterns: [
{
@@ -549,10 +549,10 @@ export default function BillingPage() {
) : (
<span className="text-xs text-slate-400">{copy.paymentActionDisabled}</span>
)}
<Link href={`/dashboard/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
<Link href={`/contracts/${row.id}`} className="font-semibold text-blue-700 hover:underline">
{copy.openContract}
</Link>
<Link href={`/dashboard/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
<Link href={`/reservations/${row.id}`} className="text-slate-600 hover:text-slate-900 hover:underline">
{copy.openBooking}
</Link>
</div>
@@ -843,7 +843,7 @@ export default function ContractDetailPage() {
<div className="space-y-6 print:space-y-0">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between print:hidden">
<div className="space-y-2">
<Link href="/dashboard/contracts" className="text-sm font-semibold text-blue-700 hover:underline">{copy.back}</Link>
<Link href="/contracts" className="text-sm font-semibold text-blue-700 hover:underline">{copy.back}</Link>
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{contract.company.name}</p>
<div className="flex flex-wrap items-center gap-3">
<h2 className="text-xl font-semibold text-slate-900">{contract.contractNumber ?? copy.contractNo}</h2>
@@ -855,7 +855,7 @@ export default function ContractDetailPage() {
</p>
</div>
<div className="flex flex-wrap gap-3 print:hidden">
<Link href={`/dashboard/reservations/${contract.reservationId}`} className="btn-secondary">
<Link href={`/reservations/${contract.reservationId}`} className="btn-secondary">
{copy.actionOpenBooking}
</Link>
<button type="button" className="btn-primary" onClick={() => window.print()}>
@@ -165,7 +165,7 @@ export default function ContractsPage() {
</td>
<td className="px-6 py-4 text-right text-sm font-semibold text-slate-900">{formatCurrency(row.totalAmount, 'MAD')}</td>
<td className="px-6 py-4 text-right">
<Link href={`/dashboard/contracts/${row.id}`} className="text-sm font-semibold text-blue-700 hover:underline">
<Link href={`/contracts/${row.id}`} className="text-sm font-semibold text-blue-700 hover:underline">
{row.contractNumber ? copy.open : copy.generate}
</Link>
</td>
@@ -26,6 +26,9 @@ interface VehicleDetail {
photos: string[]
notes: string | null
isPublished: boolean
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const PRESET_COLORS = ['White', 'Black', 'Silver', 'Gray', 'Red', 'Blue', 'Green', 'Yellow', 'Orange', 'Brown', 'Beige', 'Gold']
@@ -55,6 +58,17 @@ const MODELS_BY_MAKE: Record<string, string[]> = {
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function formatLocationInput(values: string[]) {
return Array.isArray(values) ? values.join(', ') : ''
}
function initMakeSelect(make: string) {
return PRESET_MAKES.includes(make) ? make : (make ? 'custom' : '')
}
@@ -283,6 +297,7 @@ export default function FleetDetailPage() {
dailyRate: string; licensePlate: string; color: string
seats: number; transmission: string; fuelType: string
status: string; notes: string; mileage: string
pickupLocations: string; allowDifferentDropoff: boolean; dropoffLocations: string
} | null>(null)
const [makeSelect, setMakeSelect] = useState('')
const [modelSelect, setModelSelect] = useState('')
@@ -321,6 +336,9 @@ export default function FleetDetailPage() {
transmission: vehicle.transmission, fuelType: vehicle.fuelType,
status: vehicle.status, notes: vehicle.notes ?? '',
mileage: vehicle.mileage != null ? String(vehicle.mileage) : '',
pickupLocations: formatLocationInput(vehicle.pickupLocations),
allowDifferentDropoff: vehicle.allowDifferentDropoff,
dropoffLocations: formatLocationInput(vehicle.dropoffLocations),
})
setMakeSelect(initMakeSelect(vehicle.make))
setModelSelect(initModelSelect(vehicle.make, vehicle.model))
@@ -345,6 +363,10 @@ export default function FleetDetailPage() {
if (!form.licensePlate) { setSaveError(vd.plateRequired); return }
const rate = parseFloat(form.dailyRate)
if (isNaN(rate) || rate < 0) { setSaveError(vd.rateInvalid); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setSaveError(fl.dropoffLocationsRequired)
return
}
setSaving(true)
setSaveError(null)
@@ -359,6 +381,9 @@ export default function FleetDetailPage() {
fuelType: form.fuelType, status: form.status,
notes: form.notes || undefined,
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
@@ -558,6 +583,11 @@ export default function FleetDetailPage() {
<div><dt className="text-slate-500">{vd.labelFuelType}</dt><dd className="text-slate-900">{fuelLabel}</dd></div>
<div><dt className="text-slate-500">{vd.labelColor}</dt><dd className="text-slate-900">{vehicle.color || '—'}</dd></div>
<div><dt className="text-slate-500">{vd.labelOdometer}</dt><dd className="text-slate-900">{vehicle.mileage != null ? `${vehicle.mileage.toLocaleString()} km` : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.pickupLocations}</dt><dd className="text-slate-900">{vehicle.pickupLocations.length > 0 ? vehicle.pickupLocations.join(', ') : '—'}</dd></div>
<div><dt className="text-slate-500">{fl.allowDifferentDropoff}</dt><dd className="text-slate-900">{vehicle.allowDifferentDropoff ? fl.differentDropoffAvailable : fl.sameDropoffOnly}</dd></div>
{vehicle.allowDifferentDropoff && (
<div><dt className="text-slate-500">{fl.dropoffLocations}</dt><dd className="text-slate-900">{vehicle.dropoffLocations.length > 0 ? vehicle.dropoffLocations.join(', ') : '—'}</dd></div>
)}
<div><dt className="text-slate-500">{vd.labelNotes}</dt><dd className="text-slate-900">{vehicle.notes ?? '—'}</dd></div>
</dl>
) : (
@@ -701,6 +731,46 @@ export default function FleetDetailPage() {
<input type="number" className="input-field" placeholder="e.g. 45000" min="0" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{fl.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{fl.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={fl.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{fl.locationHint}</p>
</div>
)}
</div>
{/* Notes */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{vd.notesLabel}</label>
@@ -19,6 +19,9 @@ interface Vehicle {
isPublished: boolean
primaryPhoto: string | null
licensePlate: string
pickupLocations?: string[]
allowDifferentDropoff?: boolean
dropoffLocations?: string[]
}
interface AddVehicleModalProps {
@@ -248,13 +251,20 @@ const MODELS_BY_MAKE: Record<string, string[]> = {
'Volvo': ['S60', 'S90', 'V40', 'V60', 'V90', 'XC40', 'XC60', 'XC90'],
}
function parseLocationInput(value: string) {
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
}
function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
const { dict } = useDashboardI18n()
const f = dict.fleet
const [form, setForm] = useState({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
const [colorSelect, setColorSelect] = useState('')
const [makeSelect, setMakeSelect] = useState('')
@@ -283,7 +293,11 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
}
const reset = () => {
setForm({ make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY', dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL', fuelType: 'GASOLINE', mileage: '' })
setForm({
make: '', model: '', year: new Date().getFullYear(), category: 'ECONOMY',
dailyRate: '', licensePlate: '', color: '', seats: 5, transmission: 'MANUAL',
fuelType: 'GASOLINE', mileage: '', pickupLocations: '', allowDifferentDropoff: false, dropoffLocations: '',
})
setColorSelect('')
setMakeSelect('')
setModelSelect('')
@@ -301,6 +315,10 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
if (!form.model) { setError(f.modelMissing); return }
if (!form.licensePlate) { setError(f.plateMissing); return }
if (!form.dailyRate || isNaN(parseFloat(form.dailyRate))) { setError(f.rateMissing); return }
if (form.allowDifferentDropoff && parseLocationInput(form.dropoffLocations).length === 0) {
setError(f.dropoffLocationsRequired)
return
}
setLoading(true)
setError(null)
try {
@@ -312,6 +330,9 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
year: Number(form.year),
seats: Number(form.seats),
mileage: form.mileage ? Number(form.mileage) : undefined,
pickupLocations: parseLocationInput(form.pickupLocations),
allowDifferentDropoff: form.allowDifferentDropoff,
dropoffLocations: form.allowDifferentDropoff ? parseLocationInput(form.dropoffLocations) : [],
}),
})
if (photoFiles.length > 0) {
@@ -474,6 +495,46 @@ function AddVehicleModal({ open, onClose, onSaved }: AddVehicleModalProps) {
</div>
</div>
<div className="space-y-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.pickupLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.pickupLocationsPlaceholder}
value={form.pickupLocations}
onChange={(e) => setForm({ ...form, pickupLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
<label className="flex items-center gap-3 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm font-medium text-slate-700">
<input
type="checkbox"
checked={form.allowDifferentDropoff}
onChange={(e) => setForm({
...form,
allowDifferentDropoff: e.target.checked,
dropoffLocations: e.target.checked ? form.dropoffLocations : '',
})}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<span>{f.allowDifferentDropoff}</span>
</label>
{form.allowDifferentDropoff && (
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.dropoffLocations}</label>
<textarea
className="input-field resize-none"
rows={2}
placeholder={f.dropoffLocationsPlaceholder}
value={form.dropoffLocations}
onChange={(e) => setForm({ ...form, dropoffLocations: e.target.value })}
/>
<p className="mt-1 text-xs text-slate-400">{f.locationHint}</p>
</div>
)}
</div>
{/* Photos */}
<div>
<label className="block text-xs font-medium text-slate-500 mb-1.5">{f.photosLabel}</label>
@@ -682,10 +743,10 @@ export default function FleetPage() {
</td>
<td className="px-6 py-4">
<div className="flex items-center justify-end gap-2 rtl:justify-start">
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Eye className="w-4 h-4" />
</Link>
<Link href={`/dashboard/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Link href={`/fleet/${vehicle.id}`} className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</Link>
</div>
@@ -301,7 +301,7 @@ function ReservationCard({ r, acting, onConfirm, onDecline }: {
<div className="border-t border-slate-100 bg-slate-50 px-5 py-2.5 flex items-center justify-between">
<p className="text-xs text-slate-400">Ref: {r.id.slice(-10).toUpperCase()}</p>
<Link href={`/dashboard/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
<Link href={`/reservations/${r.id}`} className="flex items-center gap-1 text-xs text-slate-500 hover:text-blue-700">
View full detail <ChevronRight className="h-3 w-3" />
</Link>
</div>
@@ -98,7 +98,7 @@ function SubscriptionBanner({
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl border mb-6 ${config.bg}`}>
{config.icon}
<p className="text-sm font-medium">{config.message}</p>
<Link href="/dashboard/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
<Link href="/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</Link>
</div>
@@ -261,7 +261,7 @@ export default function DashboardPage() {
<div className="card">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
<Link href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
{d.viewAll}
</Link>
</div>
@@ -281,9 +281,9 @@ export default function DashboardPage() {
{(data?.recentReservations ?? []).map((res) => (
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-3">
<a href={`/dashboard/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
#{res.bookingRef}
</a>
</Link>
</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
@@ -418,7 +418,7 @@ export default function NewReservationPage() {
notes: notes || undefined,
}),
})
router.push(`/dashboard/reservations/${created.id}`)
router.push(`/reservations/${created.id}`)
} catch (err: any) {
setError(err.message)
} finally {
@@ -688,7 +688,7 @@ export default function NewReservationPage() {
</label>
<div className="pt-2 flex items-center justify-end gap-3">
<button type="button" className="btn-secondary" onClick={() => router.push('/dashboard/reservations')}>
<button type="button" className="btn-secondary" onClick={() => router.push('/reservations')}>
{copy.cancel}
</button>
<button type="button" className="btn-primary" onClick={submit} disabled={saving || !canSubmit}>
@@ -60,7 +60,7 @@ export default function ReservationsPage() {
<h2 className="text-xl font-semibold text-slate-900">{r.heading}</h2>
<p className="text-sm text-slate-500 mt-1">{r.subtitle}</p>
</div>
<Link href="/dashboard/reservations/new" className="btn-primary whitespace-nowrap">
<Link href="/reservations/new" className="btn-primary whitespace-nowrap">
{language === 'fr' ? 'Réserver une voiture' : language === 'ar' ? 'حجز سيارة' : 'Book car'}
</Link>
</div>
@@ -84,11 +84,11 @@ export default function ReservationsPage() {
{rows.map((row) => (
<tr key={row.id}>
<td className="px-6 py-4">
<Link href={`/dashboard/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
<Link href={`/reservations/${row.id}`} className="text-sm font-semibold text-slate-900 hover:text-blue-700">
{row.customer.firstName} {row.customer.lastName}
</Link>
<p className="text-xs text-slate-500">{row.customer.email}</p>
<Link href={`/dashboard/reservations/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
<Link href={`/reservations/${row.id}`} className="mt-1 inline-block text-xs font-semibold text-blue-700 hover:underline">
{reservationActionLabel(row)}
</Link>
</td>
@@ -211,7 +211,7 @@ export default function SubscriptionPage() {
const profile = JSON.parse(cached) as EmployeeProfile
const allowed = profile.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/dashboard')
if (!allowed) router.replace('/')
return
} catch {}
}
@@ -221,11 +221,11 @@ export default function SubscriptionPage() {
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
const allowed = employee.role === 'OWNER'
setCanViewPage(allowed)
if (!allowed) router.replace('/dashboard')
if (!allowed) router.replace('/')
})
.catch(() => {
setCanViewPage(false)
router.replace('/dashboard')
router.replace('/')
})
}, [router])
@@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return children
}
@@ -1,7 +1,7 @@
import { Suspense } from 'react'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import SignInPageClient from './SignInPageClient'
import SignInPageClient from '@/app/sign-in/[[...sign-in]]/SignInPageClient'
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

-32
View File
@@ -1,32 +0,0 @@
import { ImageResponse } from 'next/og'
export const size = {
width: 32,
height: 32,
}
export const contentType = 'image/png'
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#0f172a',
color: '#ffffff',
fontSize: 18,
fontWeight: 700,
borderRadius: 8,
}}
>
R
</div>
),
size,
)
}
+1 -1
View File
@@ -83,7 +83,7 @@ export default function OnboardingPage() {
isListedOnMarketplace: payments.isListedOnMarketplace,
}),
})
router.push('/dashboard')
router.push('/')
} catch (err: any) {
setError(err.message)
} finally {
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation'
export default function DashboardRootPage() {
redirect('/sign-in')
}
@@ -8,6 +8,7 @@ import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
@@ -220,6 +221,7 @@ function LocalSignInForm({
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const employeeAppRedirect = toDashboardAppPath(employeeRedirect)
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
@@ -252,8 +254,8 @@ function LocalSignInForm({
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
router.push(employeeRedirect)
notifyParent({ type: 'rentaldrivego:employee-login', path: toPublicDashboardPath(employeeRedirect) })
router.push(employeeAppRedirect)
return
}
@@ -101,9 +101,9 @@ export default function SignUpPage() {
partnerTitle: 'Company info',
partnerBody: 'Complete the company information required to create the workspace.',
planTitle: 'Choose your plan',
planBody: 'Your workspace starts immediately on a 14-day free trial in your preferred billing currency.',
planBody: 'Your workspace starts immediately on a 90-day free trial in your preferred billing currency.',
reviewTitle: 'Review and launch',
reviewBody: 'We will create the company workspace immediately and start the 14-day trial with the plan you selected.',
reviewBody: 'We will create the company workspace immediately and start the 90-day trial with the plan you selected.',
firstName: 'Manager/Owner first name',
lastName: 'Manager/Owner last name',
ownerEmail: 'Manager/Owner email',
@@ -180,9 +180,9 @@ export default function SignUpPage() {
partnerTitle: 'Infos entreprise',
partnerBody: 'Complétez les informations de lentreprise requises pour créer lespace.',
planTitle: 'Choisissez votre formule',
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 14 jours dans la devise choisie.',
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
reviewTitle: 'Vérification et lancement',
reviewBody: 'Nous allons créer immédiatement lespace entreprise et démarrer lessai de 14 jours avec la formule choisie.',
reviewBody: 'Nous allons créer immédiatement lespace entreprise et démarrer lessai de 90 jours avec la formule choisie.',
firstName: 'Prénom du gérant/propriétaire',
lastName: 'Nom du gérant/propriétaire',
ownerEmail: 'E-mail du gérant/propriétaire',
@@ -431,7 +431,7 @@ export default function SignUpPage() {
</div>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/dashboard" className="btn-primary justify-center">
<Link href="/" className="btn-primary justify-center">
{dict.enterDashboard}
</Link>
<a href="/sign-in" className="btn-secondary justify-center">
+78 -42
View File
@@ -49,6 +49,15 @@ type FleetDict = {
manual: string
automatic: string
fuelType: string
pickupLocations: string
pickupLocationsPlaceholder: string
dropoffLocations: string
dropoffLocationsPlaceholder: string
locationHint: string
allowDifferentDropoff: string
dropoffLocationsRequired: string
sameDropoffOnly: string
differentDropoffAvailable: string
photosLabel: string
clickToAddPhotos: string
addMorePhotos: string
@@ -323,20 +332,20 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
settings: 'Settings',
},
titles: {
'/dashboard': 'Dashboard',
'/dashboard/fleet': 'Fleet Management',
'/dashboard/reservations': 'Booking',
'/dashboard/reservations/new': 'Book Car',
'/dashboard/online-reservations': 'Online Reservations',
'/dashboard/customers': 'Customers',
'/dashboard/offers': 'Offers',
'/dashboard/team': 'Team',
'/dashboard/reports': 'Reports',
'/dashboard/subscription': 'Subscription',
'/dashboard/billing': 'Customer Billing',
'/dashboard/contracts': 'Contracts',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Settings',
'/': 'Dashboard',
'/fleet': 'Fleet Management',
'/reservations': 'Booking',
'/reservations/new': 'Book Car',
'/online-reservations': 'Online Reservations',
'/customers': 'Customers',
'/offers': 'Offers',
'/team': 'Team',
'/reports': 'Reports',
'/subscription': 'Subscription',
'/billing': 'Customer Billing',
'/contracts': 'Contracts',
'/notifications': 'Notifications',
'/settings': 'Settings',
},
notifications: 'Notifications',
noNewNotifications: 'No new notifications',
@@ -390,6 +399,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
manual: 'Manual',
automatic: 'Automatic',
fuelType: 'Fuel Type',
pickupLocations: 'Pick-up locations',
pickupLocationsPlaceholder: 'e.g. Casablanca Airport, Casablanca Downtown',
dropoffLocations: 'Drop-off locations',
dropoffLocationsPlaceholder: 'e.g. Rabat Downtown, Marrakech Center',
locationHint: 'Separate multiple locations with commas.',
allowDifferentDropoff: 'Allow different drop-off location',
dropoffLocationsRequired: 'Please add at least one drop-off location when different drop-off is enabled.',
sameDropoffOnly: 'Same drop-off only',
differentDropoffAvailable: 'Different drop-off available',
photosLabel: 'Photos (up to 10)',
clickToAddPhotos: 'Click to add photos',
addMorePhotos: 'Add more photos',
@@ -666,20 +684,20 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
settings: 'Paramètres',
},
titles: {
'/dashboard': 'Tableau de bord',
'/dashboard/fleet': 'Gestion de flotte',
'/dashboard/reservations': 'Réservations',
'/dashboard/reservations/new': 'Réserver une voiture',
'/dashboard/online-reservations': 'Réservations en ligne',
'/dashboard/customers': 'Clients',
'/dashboard/offers': 'Offres',
'/dashboard/team': 'Équipe',
'/dashboard/reports': 'Rapports',
'/dashboard/subscription': 'Abonnement',
'/dashboard/billing': 'Facturation clients',
'/dashboard/contracts': 'Contrats',
'/dashboard/notifications': 'Notifications',
'/dashboard/settings': 'Paramètres',
'/': 'Tableau de bord',
'/fleet': 'Gestion de flotte',
'/reservations': 'Réservations',
'/reservations/new': 'Réserver une voiture',
'/online-reservations': 'Réservations en ligne',
'/customers': 'Clients',
'/offers': 'Offres',
'/team': 'Équipe',
'/reports': 'Rapports',
'/subscription': 'Abonnement',
'/billing': 'Facturation clients',
'/contracts': 'Contrats',
'/notifications': 'Notifications',
'/settings': 'Paramètres',
},
notifications: 'Notifications',
noNewNotifications: 'Aucune nouvelle notification',
@@ -733,6 +751,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
manual: 'Manuelle',
automatic: 'Automatique',
fuelType: 'Carburant',
pickupLocations: 'Lieux de départ',
pickupLocationsPlaceholder: 'ex. Aéroport de Casablanca, Centre-ville de Casablanca',
dropoffLocations: 'Lieux de retour',
dropoffLocationsPlaceholder: 'ex. Centre-ville de Rabat, Centre de Marrakech',
locationHint: 'Séparez plusieurs lieux par des virgules.',
allowDifferentDropoff: 'Autoriser un lieu de retour différent',
dropoffLocationsRequired: 'Ajoutez au moins un lieu de retour lorsque le retour différent est activé.',
sameDropoffOnly: 'Retour au même lieu uniquement',
differentDropoffAvailable: 'Retour différent disponible',
photosLabel: "Photos (jusqu'à 10)",
clickToAddPhotos: 'Cliquer pour ajouter des photos',
addMorePhotos: "Ajouter d'autres photos",
@@ -1009,20 +1036,20 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
settings: 'الإعدادات',
},
titles: {
'/dashboard': 'لوحة التحكم',
'/dashboard/fleet': 'إدارة الأسطول',
'/dashboard/reservations': 'الحجوزات',
'/dashboard/reservations/new': 'حجز سيارة',
'/dashboard/online-reservations': 'الحجوزات الإلكترونية',
'/dashboard/customers': 'العملاء',
'/dashboard/offers': 'العروض',
'/dashboard/team': 'الفريق',
'/dashboard/reports': 'التقارير',
'/dashboard/subscription': 'الاشتراك',
'/dashboard/billing': 'فوترة العملاء',
'/dashboard/contracts': 'العقود',
'/dashboard/notifications': 'الإشعارات',
'/dashboard/settings': 'الإعدادات',
'/': 'لوحة التحكم',
'/fleet': 'إدارة الأسطول',
'/reservations': 'الحجوزات',
'/reservations/new': 'حجز سيارة',
'/online-reservations': 'الحجوزات الإلكترونية',
'/customers': 'العملاء',
'/offers': 'العروض',
'/team': 'الفريق',
'/reports': 'التقارير',
'/subscription': 'الاشتراك',
'/billing': 'فوترة العملاء',
'/contracts': 'العقود',
'/notifications': 'الإشعارات',
'/settings': 'الإعدادات',
},
notifications: 'الإشعارات',
noNewNotifications: 'لا توجد إشعارات جديدة',
@@ -1076,6 +1103,15 @@ const dictionaries: Record<DashboardLanguage, DashboardDictionary> = {
manual: 'يدوي',
automatic: 'أوتوماتيكي',
fuelType: 'نوع الوقود',
pickupLocations: 'مواقع الاستلام',
pickupLocationsPlaceholder: 'مثال: مطار الدار البيضاء، وسط الدار البيضاء',
dropoffLocations: 'مواقع الإرجاع',
dropoffLocationsPlaceholder: 'مثال: وسط الرباط، مركز مراكش',
locationHint: 'افصل بين المواقع المتعددة بفواصل.',
allowDifferentDropoff: 'السماح بموقع إرجاع مختلف',
dropoffLocationsRequired: 'أضف موقع إرجاع واحداً على الأقل عند تفعيل الإرجاع المختلف.',
sameDropoffOnly: 'الإرجاع في نفس الموقع فقط',
differentDropoffAvailable: 'الإرجاع المختلف متاح',
photosLabel: 'الصور (حتى 10)',
clickToAddPhotos: 'انقر لإضافة صور',
addMorePhotos: 'إضافة المزيد من الصور',
@@ -23,6 +23,7 @@ import {
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
import { SHARED_LANGUAGE_KEY, readCurrentUserScopedPreference } from '@/lib/preferences'
interface BrandSettings {
@@ -61,19 +62,19 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/dashboard/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/dashboard/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/dashboard/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/dashboard/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/dashboard/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/dashboard/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/dashboard/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
{ href: '/', key: 'dashboard', icon: LayoutDashboard, exact: true, minRole: 'AGENT' },
{ href: '/fleet', key: 'fleet', icon: Car, minRole: 'AGENT' },
{ href: '/reservations', key: 'reservations', icon: Calendar, minRole: 'AGENT' },
{ href: '/online-reservations', key: 'onlineReservations', icon: Globe, minRole: 'AGENT' },
{ href: '/customers', key: 'customers', icon: Users, minRole: 'AGENT' },
{ href: '/offers', key: 'offers', icon: Tag, minRole: 'MANAGER' },
{ href: '/team', key: 'team', icon: UserPlus, minRole: 'AGENT' },
{ href: '/reports', key: 'reports', icon: BarChart2, minRole: 'MANAGER' },
{ href: '/subscription', key: 'subscription', icon: CreditCard, minRole: 'OWNER' },
{ href: '/billing', key: 'billing', icon: CreditCard, minRole: 'MANAGER' },
{ href: '/contracts', key: 'contracts', icon: FileText, minRole: 'AGENT' },
{ href: '/notifications', key: 'notifications', icon: Bell, minRole: 'AGENT' },
{ href: '/settings', key: 'settings', icon: Settings, minRole: 'OWNER' },
] as const
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
@@ -82,10 +83,16 @@ function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function Sidebar() {
const { dict, language, setLanguage } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [brand, setBrand] = useState<BrandSettings | null>(null)
const [user, setUser] = useState<SidebarUser>({
@@ -153,15 +160,17 @@ export default function Sidebar() {
useEffect(() => { setOpen(false) }, [pathname])
const isActive = (item: typeof NAV_ITEMS[number]) => {
if ('exact' in item && item.exact) return pathname === item.href
return pathname.startsWith(item.href)
if ('exact' in item && item.exact) return appPath === item.href
return appPath.startsWith(item.href)
}
function signOut() {
localStorage.removeItem(EMPLOYEE_TOKEN_KEY)
localStorage.removeItem(EMPLOYEE_PROFILE_KEY)
document.cookie = 'employee_token=; path=/; max-age=0; samesite=lax'
router.push('/sign-in')
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-logout' })
window.location.href = marketplaceUrl
}
return (
@@ -5,6 +5,7 @@ import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react'
import { EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { toDashboardAppPath } from '@/lib/dashboardPaths'
function computeInitials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean)
@@ -16,6 +17,7 @@ function computeInitials(name: string): string {
export default function TopBar() {
const { dict } = useDashboardI18n()
const pathname = usePathname()
const appPath = toDashboardAppPath(pathname)
const router = useRouter()
const [unreadCount, setUnreadCount] = useState(0)
const [showNotifs, setShowNotifs] = useState(false)
@@ -33,11 +35,11 @@ export default function TopBar() {
useEffect(() => { setMounted(true) }, [])
const title = (() => {
if (!mounted) return dict.titles['/dashboard']
if (dict.titles[pathname]) return dict.titles[pathname]
if (pathname.startsWith('/dashboard/contracts/')) return dict.titles['/dashboard/contracts'] ?? dict.titles['/dashboard']
if (pathname.startsWith('/dashboard/reservations/')) return dict.titles['/dashboard/reservations'] ?? dict.titles['/dashboard']
return dict.titles['/dashboard']
if (!mounted) return dict.titles['/']
if (dict.titles[appPath]) return dict.titles[appPath]
if (appPath.startsWith('/contracts/')) return dict.titles['/contracts'] ?? dict.titles['/']
if (appPath.startsWith('/reservations/')) return dict.titles['/reservations'] ?? dict.titles['/']
return dict.titles['/']
})()
async function refreshUnreadCount() {
try {
@@ -139,7 +141,7 @@ export default function TopBar() {
} catch {}
}
setShowNotifs(false)
router.push('/dashboard/notifications')
router.push('/notifications')
}
return (
+19
View File
@@ -0,0 +1,19 @@
const DASHBOARD_BASE_PATH = '/dashboard'
export function toDashboardAppPath(path?: string | null): string {
const value = (path ?? '').trim()
if (!value || value === '/') return '/'
let normalized = value.startsWith('/') ? value : `/${value}`
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
}
return normalized
}
export function toPublicDashboardPath(path?: string | null): string {
const appPath = toDashboardAppPath(path)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
+20 -2
View File
@@ -4,6 +4,10 @@ import type { NextRequest } from 'next/server'
const PUBLIC_DASHBOARD_PREFIXES = ['/dashboard/sign-in', '/dashboard/forgot-password']
const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
function dedupeDashboardPath(pathname: string): string {
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard')
}
function isInternalHost(host: string | null): boolean {
if (!host) return false
const hostname = host.split(':')[0]?.toLowerCase()
@@ -17,9 +21,16 @@ function isProtectedRoute(req: NextRequest) {
}
function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_token')?.value
if (token && req.nextUrl.pathname === '/dashboard/sign-in') {
const dashboardUrl = req.nextUrl.clone()
dashboardUrl.pathname = '/dashboard'
dashboardUrl.search = ''
return NextResponse.redirect(dashboardUrl)
}
if (!isProtectedRoute(req)) return NextResponse.next()
const token = req.cookies.get('employee_token')?.value
if (!token) {
const forwardedHost = req.headers.get('x-forwarded-host')
const forwardedProto = req.headers.get('x-forwarded-proto')
@@ -35,7 +46,7 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
}
const isMarketplaceHost = signInUrl.host === marketplaceOrigin.host
signInUrl.pathname = isMarketplaceHost ? '/sign-in' : '/dashboard/sign-in'
signInUrl.pathname = isMarketplaceHost ? '/' : '/dashboard/sign-in'
signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
return NextResponse.redirect(signInUrl)
}
@@ -43,6 +54,13 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
}
export default function middleware(req: NextRequest) {
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname)
if (dedupedPath !== req.nextUrl.pathname) {
const redirectUrl = req.nextUrl.clone()
redirectUrl.pathname = dedupedPath
return NextResponse.redirect(redirectUrl)
}
return localJwtMiddleware(req)
}
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
export default function CompanyWorkspacePage() {
redirect('/sign-in')
redirect('/')
}
@@ -8,6 +8,9 @@ type ExploreSearchFormDict = {
searchTitle: string
pickupLocation: string
dropoffLocation: string
sameDropoff: string
differentDropoff: string
sameAsPickup: string
pickupDate: string
returnDate: string
hour: string
@@ -29,6 +32,7 @@ const selectBase = 'w-full border-0 bg-white dark:bg-[#162038] py-4 text-sm text
export default function ExploreSearchForm({
pickupLocation,
dropoffLocation,
dropoffMode,
pickupDate,
pickupTime,
returnDate,
@@ -40,6 +44,7 @@ export default function ExploreSearchForm({
}: {
pickupLocation: string
dropoffLocation: string
dropoffMode: 'same' | 'different'
pickupDate: string
pickupTime: string
returnDate: string
@@ -55,6 +60,7 @@ export default function ExploreSearchForm({
const [filters, setFilters] = useState({
pickupLocation,
dropoffLocation,
dropoffMode,
pickupDate,
pickupTime: pickupTime || '10:00',
returnDate,
@@ -73,14 +79,19 @@ export default function ExploreSearchForm({
const query = new URLSearchParams()
const normalizedPickup = filters.pickupLocation.trim()
const normalizedDropoff = filters.dropoffLocation.trim()
const normalizedDropoff = filters.dropoffMode === 'different'
? filters.dropoffLocation.trim()
: normalizedPickup
const normalizedPromo = filters.promoCode.trim()
if (normalizedPickup) {
query.set('pickupLocation', normalizedPickup)
query.set('city', normalizedPickup)
}
if (normalizedDropoff) query.set('dropoffLocation', normalizedDropoff)
query.set('dropoffMode', filters.dropoffMode)
if (filters.dropoffMode === 'different' && normalizedDropoff) {
query.set('dropoffLocation', normalizedDropoff)
}
if (filters.pickupDate) query.set('pickupDate', filters.pickupDate)
if (filters.pickupTime) query.set('pickupTime', filters.pickupTime)
if (filters.returnDate) query.set('returnDate', filters.returnDate)
@@ -116,7 +127,36 @@ export default function ExploreSearchForm({
))}
</select>
</label>
<label className="flex items-center gap-3 bg-white dark:bg-[#162038] px-4">
<div className="bg-white dark:bg-[#162038] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-semibold uppercase tracking-[0.14em] text-stone-400 dark:text-stone-500">{dict.dropoffLocation}</span>
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-stone-100 dark:bg-[#0d1b38] p-1">
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'same', dropoffLocation: '' }))}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
filters.dropoffMode === 'same'
? 'bg-stone-900 text-white dark:bg-orange-400 dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{dict.sameDropoff}
</button>
<button
type="button"
onClick={() => setFilters((current) => ({ ...current, dropoffMode: 'different' }))}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
filters.dropoffMode === 'different'
? 'bg-stone-900 text-white dark:bg-orange-400 dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{dict.differentDropoff}
</button>
</div>
</div>
{filters.dropoffMode === 'different' ? (
<label className="mt-3 flex items-center gap-3">
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<select
name="dropoffLocation"
@@ -130,6 +170,13 @@ export default function ExploreSearchForm({
))}
</select>
</label>
) : (
<div className="mt-3 flex items-center gap-3 rounded-2xl bg-stone-50 px-4 py-4 text-sm font-medium text-stone-500 dark:bg-[#0d1b38] dark:text-stone-300">
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
<span>{filters.pickupLocation || dict.sameAsPickup}</span>
</div>
)}
</div>
</div>
<div className="grid gap-px overflow-hidden rounded-[1.35rem] border border-stone-200 dark:border-blue-800 bg-stone-200 dark:bg-stone-700 md:grid-cols-4">
@@ -16,6 +16,9 @@ interface VehicleDetail {
features: string[]
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability: boolean
availabilityStatus: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt: string | null
@@ -259,6 +262,9 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
vehicleId={vehicle.id}
companySlug={params.slug}
dailyRate={vehicle.dailyRate}
pickupLocations={vehicle.pickupLocations}
allowDifferentDropoff={vehicle.allowDifferentDropoff}
dropoffLocations={vehicle.dropoffLocations}
/>
)}
@@ -12,6 +12,9 @@ interface Vehicle {
category: string
dailyRate: number
photos: string[]
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
availability?: boolean | null
availabilityStatus?: 'AVAILABLE' | 'RESERVED' | 'MAINTENANCE' | 'UNAVAILABLE'
nextAvailableAt?: string | null
@@ -73,6 +76,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
searchTitle: 'Find a vehicle',
pickupLocation: 'Pick-up location',
dropoffLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
pickupDate: 'Start date',
returnDate: 'Return date',
hour: 'Hour',
@@ -116,6 +122,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
searchTitle: 'Trouver un véhicule',
pickupLocation: 'Lieu de départ',
dropoffLocation: "Lieu de retour",
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
pickupDate: 'Date de départ',
returnDate: 'Date de retour',
hour: 'Heure',
@@ -159,6 +168,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
searchTitle: 'احجز سيارة',
pickupLocation: 'موقع الاستلام',
dropoffLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
pickupDate: 'تاريخ البدء',
returnDate: 'تاريخ الإرجاع',
hour: 'الساعة',
@@ -200,6 +212,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
const city = typeof searchParams?.city === 'string' ? searchParams.city : ''
const pickupLocation = typeof searchParams?.pickupLocation === 'string' ? searchParams.pickupLocation : city
const dropoffLocation = typeof searchParams?.dropoffLocation === 'string' ? searchParams.dropoffLocation : ''
const dropoffMode = searchParams?.dropoffMode === 'different' ? 'different' : 'same'
const pickupDate = typeof searchParams?.pickupDate === 'string' ? searchParams.pickupDate : ''
const pickupTime = typeof searchParams?.pickupTime === 'string' ? searchParams.pickupTime : '10:00'
const returnDate = typeof searchParams?.returnDate === 'string' ? searchParams.returnDate : ''
@@ -215,6 +228,9 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
const query = new URLSearchParams()
if (pickupLocation) query.set('city', pickupLocation)
if (pickupLocation) query.set('pickupLocation', pickupLocation)
query.set('dropoffMode', dropoffMode)
if (dropoffMode === 'different' && dropoffLocation) query.set('dropoffLocation', dropoffLocation)
if (startDate) query.set('startDate', startDate)
if (endDate) query.set('endDate', endDate)
if (category) query.set('category', category)
@@ -246,6 +262,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
<ExploreSearchForm
pickupLocation={pickupLocation}
dropoffLocation={dropoffLocation}
dropoffMode={dropoffMode}
pickupDate={pickupDate}
pickupTime={pickupTime}
returnDate={returnDate}
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'
export default function PlatformOperationsPage() {
redirect('/sign-in')
redirect('/')
}
@@ -82,7 +82,7 @@ const copy = {
perMonth: '/mo',
billedAs: 'Billed as',
getStarted: 'Get started',
footer: 'All plans include a 14-day free trial. No credit card required.',
footer: 'All plans include a 90-day free trial. No credit card required.',
},
fr: {
monthly: 'Mensuel',
@@ -94,7 +94,7 @@ const copy = {
perMonth: '/mois',
billedAs: 'Facturé à',
getStarted: 'Commencer',
footer: "Toutes les formules incluent un essai gratuit de 14 jours. Aucune carte bancaire n'est requise.",
footer: "Toutes les formules incluent un essai gratuit de 90 jours. Aucune carte bancaire n'est requise.",
},
ar: {
monthly: 'شهري',
@@ -7,7 +7,7 @@ const copy = {
en: {
kicker: 'Pricing',
title: 'One platform, every fleet size.',
body: 'Start free for 14 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
body: 'Start free for 90 days, then choose the plan that grows with your business. Switch plans or cancel at any time.',
faq: 'Frequently asked',
items: [
['Can I change plans later?', 'Yes. Upgrade or downgrade at any time — changes take effect on your next billing cycle.'],
@@ -19,7 +19,7 @@ const copy = {
fr: {
kicker: 'Tarifs',
title: 'Une seule plateforme pour toutes les tailles de flotte.',
body: 'Commencez gratuitement pendant 14 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
body: 'Commencez gratuitement pendant 90 jours, puis choisissez la formule qui accompagne votre activité. Vous pouvez changer de formule ou annuler à tout moment.',
faq: 'Questions fréquentes',
items: [
['Puis-je changer de formule plus tard ?', 'Oui. Vous pouvez passer à une formule supérieure ou inférieure à tout moment. Les changements prennent effet au cycle de facturation suivant.'],
@@ -115,7 +115,7 @@ export default function RenterDashboardPage() {
useEffect(() => {
const token = localStorage.getItem('renter_token')
if (!token) {
router.replace('/renter/sign-in')
router.replace('/')
return
}
@@ -127,7 +127,7 @@ export default function RenterDashboardPage() {
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem('renter_token')
router.replace('/renter/sign-in')
router.replace('/')
} else {
setErrorMsg(json?.message ?? dict.loadProfile)
setStatus('error')
@@ -65,7 +65,7 @@ export default function RenterNotificationsPage() {
.then((items) => setNotifications(items))
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/renter/sign-in')
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.load)
@@ -126,7 +126,7 @@ export default function RenterProfilePage() {
})
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/renter/sign-in')
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.loadFailed)
@@ -50,7 +50,7 @@ export default function RenterSavedCompaniesPage() {
.then((profile) => setCompanies(profile.savedCompanies ?? []))
.catch((err) => {
if (err instanceof RenterAuthError) {
router.replace('/renter/sign-in')
router.replace('/')
return
}
setErrorMsg(err instanceof Error ? err.message : dict.load)
+101 -1
View File
@@ -8,6 +8,9 @@ type Props = {
vehicleId: string
companySlug: string
dailyRate: number
pickupLocations: string[]
allowDifferentDropoff: boolean
dropoffLocations: string[]
}
const copy = {
@@ -17,6 +20,11 @@ const copy = {
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
pickupLocation: 'Pick-up location',
returnLocation: 'Drop-off location',
sameDropoff: 'Same drop-off',
differentDropoff: 'Different drop-off',
sameAsPickup: 'Return to the same location',
startDate: 'Pick-up date',
endDate: 'Return date',
notes: 'Notes (optional)',
@@ -30,6 +38,7 @@ const copy = {
estimatedTotal: 'Estimated total',
days: 'day(s)',
genericError: 'Something went wrong. Please try again.',
locationRequired: 'Please select the required pick-up and drop-off locations.',
},
fr: {
title: 'Réserver ce véhicule',
@@ -37,6 +46,11 @@ const copy = {
lastName: 'Nom',
email: 'E-mail',
phone: 'Téléphone',
pickupLocation: 'Lieu de départ',
returnLocation: 'Lieu de retour',
sameDropoff: 'Même retour',
differentDropoff: 'Retour différent',
sameAsPickup: 'Retour au même lieu',
startDate: 'Date de départ',
endDate: 'Date de retour',
notes: 'Notes (optionnelles)',
@@ -50,6 +64,7 @@ const copy = {
estimatedTotal: 'Total estimé',
days: 'jour(s)',
genericError: 'Une erreur est survenue. Veuillez réessayer.',
locationRequired: 'Veuillez sélectionner les lieux de départ et de retour requis.',
},
ar: {
title: 'احجز هذه السيارة',
@@ -57,6 +72,11 @@ const copy = {
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
pickupLocation: 'موقع الاستلام',
returnLocation: 'موقع الإرجاع',
sameDropoff: 'نفس موقع الإرجاع',
differentDropoff: 'موقع إرجاع مختلف',
sameAsPickup: 'الإرجاع في نفس موقع الاستلام',
startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإرجاع',
notes: 'ملاحظات (اختيارية)',
@@ -70,18 +90,31 @@ const copy = {
estimatedTotal: 'الإجمالي التقديري',
days: 'يوم',
genericError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
locationRequired: 'يرجى اختيار مواقع الاستلام والإرجاع المطلوبة.',
},
} as const
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
export default function BookingForm({
vehicleId,
companySlug,
dailyRate,
pickupLocations,
allowDifferentDropoff,
dropoffLocations,
}: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const pickupOptions = Array.isArray(pickupLocations) ? pickupLocations : []
const dropoffOptions = Array.isArray(dropoffLocations) ? dropoffLocations : []
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [pickupLocation, setPickupLocation] = useState(pickupOptions[0] ?? '')
const [dropoffMode, setDropoffMode] = useState<'same' | 'different'>('same')
const [returnLocation, setReturnLocation] = useState(dropoffOptions[0] ?? '')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [notes, setNotes] = useState('')
@@ -102,6 +135,10 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
setError(t.required)
return
}
if ((pickupOptions.length > 0 && !pickupLocation) || (dropoffMode === 'different' && dropoffOptions.length > 0 && !returnLocation)) {
setError(t.locationRequired)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
@@ -121,6 +158,8 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
phone: phone.trim(),
startDate: start.toISOString(),
endDate: end.toISOString(),
pickupLocation: pickupLocation || undefined,
returnLocation: dropoffMode === 'different' ? (returnLocation || undefined) : (pickupLocation || undefined),
notes: notes.trim() || undefined,
language,
})
@@ -194,6 +233,67 @@ export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props
/>
</div>
{pickupOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.pickupLocation} <span className="text-red-500">*</span></label>
<select
value={pickupLocation}
onChange={(e) => setPickupLocation(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
>
{pickupOptions.map((location) => (
<option key={location} value={location}>{location}</option>
))}
</select>
</div>
) : null}
{allowDifferentDropoff ? (
<div className="space-y-3 rounded-2xl border border-stone-200 bg-stone-50 p-4 dark:border-blue-800 dark:bg-[#0d1b38]">
<div className="inline-flex rounded-full border border-stone-200 dark:border-blue-800 bg-white dark:bg-[#162038] p-1">
<button
type="button"
onClick={() => setDropoffMode('same')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
dropoffMode === 'same'
? 'bg-stone-900 text-white dark:bg-orange-400 dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{t.sameDropoff}
</button>
<button
type="button"
onClick={() => setDropoffMode('different')}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition ${
dropoffMode === 'different'
? 'bg-stone-900 text-white dark:bg-orange-400 dark:text-stone-900'
: 'text-stone-500 dark:text-stone-400'
}`}
>
{t.differentDropoff}
</button>
</div>
{dropoffMode === 'different' && dropoffOptions.length > 0 ? (
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.returnLocation} <span className="text-red-500">*</span></label>
<select
value={returnLocation}
onChange={(e) => setReturnLocation(e.target.value)}
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 outline-none focus:border-stone-400 dark:border-blue-800 dark:bg-[#162038] dark:text-stone-100 dark:focus:border-stone-500"
>
{dropoffOptions.map((location) => (
<option key={location} value={location}>{location}</option>
))}
</select>
</div>
) : (
<p className="text-sm text-stone-500 dark:text-stone-400">{pickupLocation || t.sameAsPickup}</p>
)}
</div>
) : null}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.startDate} <span className="text-red-500">*</span></label>
@@ -120,7 +120,7 @@ export default function MarketplaceHeader({
</Link>
{companyName ? (
<Link
href={`${dashboardUrl}/dashboard`}
href={dashboardUrl}
className="ml-1 flex items-center gap-1.5 rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400 sm:ml-2 sm:gap-2 sm:px-4 sm:py-2 sm:text-sm"
>
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-white/20 text-[10px] font-black dark:bg-[#07101e]/20">
@@ -88,7 +88,7 @@ export default function RenterShell({ children }: { children: React.ReactNode })
function signOut() {
clearRenterSession()
router.push('/renter/sign-in')
router.push('/')
}
const isActive = (item: (typeof NAV_ITEMS)[number]) => {
@@ -25,7 +25,7 @@ function getDefaultFramePath(target: FrameId) {
.find((c) => c.startsWith('employee_token='))
if (target === 'sign-in' && employeeToken) {
return '/dashboard/dashboard'
return '/dashboard'
}
return frameConfig[target].path
@@ -60,12 +60,30 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
setFramePath(getDefaultFramePath(target))
}, [target])
useEffect(() => {
if (target !== 'sign-in') return
const employeeToken = document.cookie
.split(';')
.map((c) => c.trim())
.find((c) => c.startsWith('employee_token='))
if (employeeToken) {
window.location.replace('/dashboard')
}
}, [target])
useEffect(() => {
function handleFrameMessage(event: MessageEvent) {
if (!event.data || typeof event.data !== 'object') return
const message = event.data as { type?: string; path?: string }
if (target === 'sign-in' && message.type === 'rentaldrivego:employee-login' && typeof message.path === 'string') {
window.location.replace(message.path)
return
}
if ((message.type === 'rentaldrivego:embedded-path' || message.type === 'rentaldrivego:employee-login') && typeof message.path === 'string') {
setFramePath(message.path)
}
@@ -73,7 +91,7 @@ export default function WorkspaceFrame({ target }: { target: FrameId }) {
window.addEventListener('message', handleFrameMessage)
return () => window.removeEventListener('message', handleFrameMessage)
}, [])
}, [target])
useEffect(() => {
const nextUrl = new URL(buildFrameUrl(config.appUrl, framePath))
+15
View File
@@ -0,0 +1,15 @@
# infra
docker compose -f docker-compose.dev.yml up -d postgres
docker compose -f docker-compose.dev.yml up -d redis
# app services
docker compose -f docker-compose.dev.yml --profile api up --build
docker compose -f docker-compose.dev.yml --profile marketplace up --build
docker compose -f docker-compose.dev.yml --profile dashboard up --build
docker compose -f docker-compose.dev.yml --profile admin up --build
# tools
docker compose -f docker-compose.dev.yml --profile tools up --build
# full stack
docker compose -f docker-compose.dev.yml --profile full up --build
+3 -3
View File
@@ -114,7 +114,7 @@ services:
[
"sh",
"-c",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/marketplace && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3000",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/marketplace && rm -rf .next && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3000",
]
ports:
- "3000:3000"
@@ -136,7 +136,7 @@ services:
[
"sh",
"-c",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/dashboard && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3001",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/dashboard && rm -rf .next && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3001",
]
ports:
- "3001:3001"
@@ -158,7 +158,7 @@ services:
[
"sh",
"-c",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/admin && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3002",
"npm run build --workspace @rentaldrivego/types && cd /app/apps/admin && rm -rf .next && exec env PATH=/app/docker/scripts:$PATH /app/node_modules/.bin/next dev -H 0.0.0.0 -p 3002",
]
ports:
- "3002:3002"
+12
View File
@@ -11,6 +11,18 @@
"dev": "turbo dev",
"lint": "turbo lint",
"type-check": "turbo type-check",
"docker:dev:start:all": "docker compose -f docker-compose.dev.yml --profile full up --build",
"docker:dev:start:traefik": "echo 'No development Traefik stack is configured. Use docker-compose.dev.yml directly.'",
"docker:dev:start:postgres": "docker compose -f docker-compose.dev.yml up -d postgres",
"docker:dev:start:redis": "docker compose -f docker-compose.dev.yml up -d redis",
"docker:dev:start:api": "docker compose -f docker-compose.dev.yml --profile api up --build",
"docker:dev:start:marketplace": "docker compose -f docker-compose.dev.yml --profile marketplace up --build",
"docker:dev:start:dashboard": "docker compose -f docker-compose.dev.yml --profile dashboard up --build",
"docker:dev:start:admin": "docker compose -f docker-compose.dev.yml --profile admin up --build",
"docker:dev:start:frontends": "docker compose -f docker-compose.dev.yml --profile marketplace --profile dashboard --profile admin up --build",
"docker:dev:start:pgmanage": "docker compose -f docker-compose.dev.yml --profile tools up --build pgmanage",
"docker:dev:start:tools": "docker compose -f docker-compose.dev.yml --profile tools up --build",
"docker:dev:start:portainer": "echo 'No development Portainer stack is configured. Use the production compose files if needed.'",
"docker:prod:start:all": "bash scripts/docker-prod-up-all.sh",
"docker:prod:start:traefik": "bash scripts/docker-prod-up-traefik.sh",
"docker:prod:start:postgres": "bash scripts/docker-prod-up-postgres.sh",
@@ -0,0 +1,4 @@
ALTER TABLE "vehicles"
ADD COLUMN "pickupLocations" TEXT[],
ADD COLUMN "allowDifferentDropoff" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "dropoffLocations" TEXT[];
@@ -0,0 +1,10 @@
UPDATE "vehicles"
SET
"pickupLocations" = COALESCE("pickupLocations", ARRAY[]::TEXT[]),
"dropoffLocations" = COALESCE("dropoffLocations", ARRAY[]::TEXT[]);
ALTER TABLE "vehicles"
ALTER COLUMN "pickupLocations" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "pickupLocations" SET NOT NULL,
ALTER COLUMN "dropoffLocations" SET DEFAULT ARRAY[]::TEXT[],
ALTER COLUMN "dropoffLocations" SET NOT NULL;
+3
View File
@@ -486,6 +486,9 @@ model Vehicle {
mileage Int?
notes String?
isPublished Boolean @default(true)
pickupLocations String[] @default([])
allowDifferentDropoff Boolean @default(false)
dropoffLocations String[] @default([])
reservations Reservation[]
maintenance MaintenanceLog[]
+2 -2
View File
@@ -115,7 +115,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
],
stepsTitle: 'How companies launch',
steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 14-day trial, and verify the owner account.'],
['1', 'Create your company workspace', 'Pick a plan, launch your 90-day trial, and verify the owner account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
@@ -170,7 +170,7 @@ export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
],
stepsTitle: 'Comment les entreprises démarrent',
steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 14 jours et vérifiez le compte propriétaire.'],
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire.'],
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'],
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),