fix signin signout and apply the new style
This commit is contained in:
+6
-1
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
+2
-2
@@ -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>
|
||||
+2
-2
@@ -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()}>
|
||||
+1
-1
@@ -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>
|
||||
+70
@@ -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>
|
||||
+65
-4
@@ -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>
|
||||
+1
-1
@@ -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>
|
||||
+4
-4
@@ -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>
|
||||
+2
-2
@@ -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}>
|
||||
+3
-3
@@ -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>
|
||||
+3
-3
@@ -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
-1
@@ -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'
|
||||
|
||||
@@ -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 |
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export default function OnboardingPage() {
|
||||
isListedOnMarketplace: payments.isListedOnMarketplace,
|
||||
}),
|
||||
})
|
||||
router.push('/dashboard')
|
||||
router.push('/')
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
|
||||
@@ -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 l’entreprise requises pour créer l’espace.',
|
||||
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 l’espace entreprise et démarrer l’essai de 14 jours avec la formule choisie.',
|
||||
reviewBody: 'Nous allons créer immédiatement l’espace entreprise et démarrer l’essai 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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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,20 +127,56 @@ export default function ExploreSearchForm({
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 bg-white dark:bg-[#162038] px-4">
|
||||
<MapPin className="h-5 w-5 text-stone-300 dark:text-stone-500" />
|
||||
<select
|
||||
name="dropoffLocation"
|
||||
value={filters.dropoffLocation}
|
||||
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.dropoffLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<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"
|
||||
value={filters.dropoffLocation}
|
||||
onChange={(event) => updateFilter('dropoffLocation', event.target.value)}
|
||||
className={selectBase}
|
||||
>
|
||||
<option value="">{dict.dropoffLocation}</option>
|
||||
{cityOptions.map((city) => (
|
||||
<option key={`dropoff-${city}`} value={city}>{city}</option>
|
||||
))}
|
||||
</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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "vehicles"
|
||||
ADD COLUMN "pickupLocations" TEXT[],
|
||||
ADD COLUMN "allowDifferentDropoff" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "dropoffLocations" TEXT[];
|
||||
+10
@@ -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;
|
||||
@@ -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[]
|
||||
|
||||
@@ -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! })),
|
||||
|
||||
Reference in New Issue
Block a user