dashboard fix

This commit is contained in:
root
2026-05-19 21:25:38 -04:00
parent 7ac0098c7f
commit d4f7de5762
27 changed files with 705 additions and 336 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+2 -1
View File
@@ -3,10 +3,11 @@ import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
import { generateFinancialReport, toCsv } from '../services/financialReportService'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
router.use(requireCompanyAuth, requireTenant, requireSubscription, requireRole('MANAGER'))
function getRangeFromPeriod(period: string) {
const now = new Date()
+2
View File
@@ -21,6 +21,7 @@ const signupSchema = z.object({
companyPhone: z.string().min(1).max(80),
fax: z.string().max(80).optional(),
yearsActive: z.string().min(1).max(80),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.enum(['MAD', 'USD', 'EUR']),
@@ -196,6 +197,7 @@ router.post('/signup', async (req, res, next) => {
phone: body.companyPhone,
passwordHash,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
},
})
+2
View File
@@ -97,6 +97,7 @@ router.get('/me', async (req, res, next) => {
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
preferredLanguage: employee.preferredLanguage,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
@@ -142,6 +143,7 @@ router.post('/login', async (req, res, next) => {
firstName: employee.firstName,
lastName: employee.lastName,
role: employee.role,
preferredLanguage: employee.preferredLanguage,
companyId: employee.companyId,
companyName: employee.company.name,
companySlug: employee.company.slug,
+14 -13
View File
@@ -6,6 +6,7 @@ import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
@@ -212,7 +213,7 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me', async (req, res, next) => {
router.patch('/me', requireRole('OWNER'), async (req, res, next) => {
try {
const body = companySchema.parse(req.body)
const company = await prisma.company.update({
@@ -232,7 +233,7 @@ router.get('/me/brand', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/brand', async (req, res, next) => {
router.patch('/me/brand', requireRole('OWNER'), async (req, res, next) => {
try {
const body = brandSchema.parse(req.body)
const current = await prisma.brandSettings.findUnique({ where: { companyId: req.companyId } })
@@ -256,7 +257,7 @@ router.patch('/me/brand', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
router.post('/me/brand/logo', requireRole('OWNER'), upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A logo file is required', statusCode: 400 })
@@ -276,7 +277,7 @@ router.post('/me/brand/logo', upload.single('file'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
router.post('/me/brand/hero', requireRole('OWNER'), upload.single('file'), async (req, res, next) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'missing_file', message: 'A hero image file is required', statusCode: 400 })
@@ -296,7 +297,7 @@ router.post('/me/brand/hero', upload.single('file'), async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/subdomain/check', async (req, res, next) => {
router.post('/me/brand/subdomain/check', requireRole('OWNER'), async (req, res, next) => {
try {
const { subdomain } = z.object({ subdomain: z.string().min(3) }).parse(req.body)
const existing = await prisma.brandSettings.findFirst({
@@ -306,7 +307,7 @@ router.post('/me/brand/subdomain/check', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/brand/custom-domain', async (req, res, next) => {
router.post('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const { customDomain } = z.object({ customDomain: z.string().min(3) }).parse(req.body)
const normalized = customDomain.toLowerCase().trim()
@@ -351,7 +352,7 @@ router.get('/me/brand/custom-domain/status', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/me/brand/custom-domain', async (req, res, next) => {
router.delete('/me/brand/custom-domain', requireRole('OWNER'), async (req, res, next) => {
try {
const brand = await prisma.brandSettings.updateMany({
where: { companyId: req.companyId },
@@ -368,7 +369,7 @@ router.get('/me/contract-settings', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/contract-settings', async (req, res, next) => {
router.patch('/me/contract-settings', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = contractSettingsSchema.parse(req.body)
const settings = await prisma.contractSettings.upsert({
@@ -390,7 +391,7 @@ router.get('/me/insurance-policies', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/insurance-policies', async (req, res, next) => {
router.post('/me/insurance-policies', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = insurancePolicySchema.parse(req.body)
const policy = await prisma.insurancePolicy.create({ data: { companyId: req.companyId, ...body } })
@@ -398,7 +399,7 @@ router.post('/me/insurance-policies', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/insurance-policies/:id', async (req, res, next) => {
router.patch('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = insurancePolicySchema.partial().parse(req.body)
const policy = await prisma.insurancePolicy.updateMany({
@@ -411,7 +412,7 @@ router.patch('/me/insurance-policies/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/me/insurance-policies/:id', async (req, res, next) => {
router.delete('/me/insurance-policies/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const deleted = await prisma.insurancePolicy.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
if (deleted.count === 0) return res.status(404).json({ error: 'not_found', message: 'Insurance policy not found', statusCode: 404 })
@@ -429,7 +430,7 @@ router.get('/me/pricing-rules', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/me/pricing-rules', async (req, res, next) => {
router.post('/me/pricing-rules', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = pricingRuleSchema.parse(req.body)
const rule = await prisma.pricingRule.create({ data: { companyId: req.companyId, ...body } })
@@ -437,7 +438,7 @@ router.post('/me/pricing-rules', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/me/pricing-rules/:id', async (req, res, next) => {
router.patch('/me/pricing-rules/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = pricingRuleSchema.partial().parse(req.body)
const updated = await prisma.pricingRule.updateMany({
+3 -3
View File
@@ -78,7 +78,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = customerSchema.partial().parse(req.body)
const updated = await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { ...body, dateOfBirth: body.dateOfBirth ? new Date(body.dateOfBirth) : undefined, licenseExpiry: body.licenseExpiry ? new Date(body.licenseExpiry) : undefined, licenseIssuedAt: body.licenseIssuedAt ? new Date(body.licenseIssuedAt) : undefined } as any })
@@ -89,7 +89,7 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/flag', async (req, res, next) => {
router.post('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
try {
const { reason } = z.object({ reason: z.string().optional() }).parse(req.body)
await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: true, flagReason: reason ?? null } })
@@ -97,7 +97,7 @@ router.post('/:id/flag', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id/flag', async (req, res, next) => {
router.delete('/:id/flag', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.customer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { flagged: false, flagReason: null } })
res.json({ data: { success: true } })
+6 -5
View File
@@ -4,6 +4,7 @@ import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscription)
@@ -40,7 +41,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
try {
const { vehicleIds, ...body } = offerSchema.parse(req.body)
const offer = await prisma.offer.create({
@@ -58,7 +59,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const { vehicleIds, ...body } = offerSchema.partial().parse(req.body)
const existing = await prisma.offer.findFirst({ where: { id: req.params.id, companyId: req.companyId } })
@@ -77,21 +78,21 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.offer.deleteMany({ where: { id: req.params.id, companyId: req.companyId } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/activate', async (req, res, next) => {
router.post('/:id/activate', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: true } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/deactivate', async (req, res, next) => {
router.post('/:id/deactivate', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.offer.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isActive: false } })
res.json({ data: { success: true } })
+5 -4
View File
@@ -4,6 +4,7 @@ import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
import * as amanpay from '../services/amanpayService'
import * as paypal from '../services/paypalService'
@@ -135,7 +136,7 @@ router.get('/reservations/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reservations/:id/charge', async (req, res, next) => {
router.post('/reservations/:id/charge', requireRole('MANAGER'), async (req, res, next) => {
try {
const { provider, type, currency, successUrl, failureUrl } = chargeSchema.parse(req.body)
@@ -208,7 +209,7 @@ router.post('/reservations/:id/charge', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
router.post('/reservations/:id/capture-paypal', requireRole('MANAGER'), async (req, res, next) => {
try {
const { paypalOrderId } = z.object({ paypalOrderId: z.string() }).parse(req.body)
@@ -233,7 +234,7 @@ router.post('/reservations/:id/capture-paypal', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reservations/:id/manual', async (req, res, next) => {
router.post('/reservations/:id/manual', requireRole('MANAGER'), async (req, res, next) => {
try {
const { amount, currency, type, paymentMethod } = manualPaymentSchema.parse(req.body)
@@ -278,7 +279,7 @@ router.post('/reservations/:id/manual', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/reservations/:reservationId/payments/:paymentId/refund', async (req, res, next) => {
router.post('/reservations/:reservationId/payments/:paymentId/refund', requireRole('MANAGER'), async (req, res, next) => {
try {
const { amount, reason } = z.object({
amount: z.number().int().positive().optional(),
+5 -4
View File
@@ -4,6 +4,7 @@ import { PLAN_PRICES } from '@rentaldrivego/types'
import { prisma } from '../lib/prisma'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireRole } from '../middleware/requireRole'
import * as amanpay from '../services/amanpayService'
import * as paypal from '../services/paypalService'
@@ -164,7 +165,7 @@ const checkoutSchema = z.object({
failureUrl: z.string().url(),
})
router.post('/checkout', async (req, res, next) => {
router.post('/checkout', requireRole('OWNER'), async (req, res, next) => {
try {
const body = checkoutSchema.parse(req.body)
const prices = PLAN_PRICES[body.plan]?.[body.billingPeriod]
@@ -245,7 +246,7 @@ router.post('/checkout', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/change-plan', async (req, res, next) => {
router.post('/change-plan', requireRole('OWNER'), async (req, res, next) => {
try {
const { plan, billingPeriod, currency } = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
@@ -262,7 +263,7 @@ router.post('/change-plan', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/cancel', async (req, res, next) => {
router.post('/cancel', requireRole('OWNER'), async (req, res, next) => {
try {
const updated = await prisma.subscription.update({
where: { companyId: req.companyId },
@@ -272,7 +273,7 @@ router.post('/cancel', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/resume', async (req, res, next) => {
router.post('/resume', requireRole('OWNER'), async (req, res, next) => {
try {
const updated = await prisma.subscription.update({
where: { companyId: req.companyId },
+10 -9
View File
@@ -6,6 +6,7 @@ import { uploadImage } from '../lib/storage'
import { requireCompanyAuth } from '../middleware/requireCompanyAuth'
import { requireTenant } from '../middleware/requireTenant'
import { requireSubscription } from '../middleware/requireSubscription'
import { requireRole } from '../middleware/requireRole'
const router = Router()
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } })
@@ -48,7 +49,7 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/', async (req, res, next) => {
router.post('/', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = vehicleSchema.parse(req.body)
const vehicle = await prisma.vehicle.create({ data: { ...body, companyId: req.companyId } })
@@ -63,7 +64,7 @@ router.get('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id', async (req, res, next) => {
router.patch('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = vehicleSchema.partial().parse(req.body)
if (body.status === 'MAINTENANCE' || body.status === 'OUT_OF_SERVICE') {
@@ -78,14 +79,14 @@ router.patch('/:id', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id', async (req, res, next) => {
router.delete('/:id', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { status: 'OUT_OF_SERVICE', isPublished: false } })
res.json({ data: { success: true } })
} catch (err) { next(err) }
})
router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) => {
router.post('/:id/photos', requireRole('MANAGER'), upload.array('photos', 10), async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const files = req.files as Express.Multer.File[]
@@ -95,7 +96,7 @@ router.post('/:id/photos', upload.array('photos', 10), async (req, res, next) =>
} catch (err) { next(err) }
})
router.delete('/:id/photos/:idx', async (req, res, next) => {
router.delete('/:id/photos/:idx', requireRole('MANAGER'), async (req, res, next) => {
try {
const vehicle = await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
const idx = parseInt(req.params.idx)
@@ -105,7 +106,7 @@ router.delete('/:id/photos/:idx', async (req, res, next) => {
} catch (err) { next(err) }
})
router.patch('/:id/publish', async (req, res, next) => {
router.patch('/:id/publish', requireRole('MANAGER'), async (req, res, next) => {
try {
const { isPublished } = z.object({ isPublished: z.boolean() }).parse(req.body)
await prisma.vehicle.updateMany({ where: { id: req.params.id, companyId: req.companyId }, data: { isPublished } })
@@ -214,7 +215,7 @@ router.get('/:id/calendar', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/calendar/blocks', async (req, res, next) => {
router.post('/:id/calendar/blocks', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = z.object({
startDate: z.string().datetime({ offset: true }).or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)),
@@ -242,7 +243,7 @@ router.post('/:id/calendar/blocks', async (req, res, next) => {
} catch (err) { next(err) }
})
router.delete('/:id/calendar/blocks/:blockId', async (req, res, next) => {
router.delete('/:id/calendar/blocks/:blockId', requireRole('MANAGER'), async (req, res, next) => {
try {
await prisma.vehicle.findFirstOrThrow({ where: { id: req.params.id, companyId: req.companyId } })
await prisma.vehicleCalendarBlock.deleteMany({
@@ -259,7 +260,7 @@ router.get('/:id/maintenance', async (req, res, next) => {
} catch (err) { next(err) }
})
router.post('/:id/maintenance', async (req, res, next) => {
router.post('/:id/maintenance', requireRole('MANAGER'), async (req, res, next) => {
try {
const body = z.object({
type: z.string().min(1),
@@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
@@ -93,9 +94,9 @@ 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>
<a href="/dashboard/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
<Link href="/dashboard/subscription" className="ml-auto text-sm font-semibold underline hover:no-underline">
{manageBillingLabel}
</a>
</Link>
</div>
)
}
@@ -243,9 +244,9 @@ 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>
<a href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
<Link href="/dashboard/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
{d.viewAll}
</a>
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full">
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type Customer = {
id: string
@@ -19,15 +20,15 @@ type Customer = {
type Vehicle = { id: string; make: string; model: string; licensePlate: string; status: string }
type CreatedCustomer = { id: string; firstName: string; lastName: string; email: string }
type AdditionalDriverForm = {
firstName: string
lastName: string
firstName: BilingualField
lastName: BilingualField
email: string
phone: string
driverLicense: string
licenseExpiry: string
licenseIssuedAt: string
dateOfBirth: string
nationality: string
nationality: BilingualField
}
type CreatedReservation = { id: string }
@@ -55,8 +56,8 @@ export default function NewReservationPage() {
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [savingCustomer, setSavingCustomer] = useState(false)
const [includeAdditionalDriver, setIncludeAdditionalDriver] = useState(false)
const [newCustomerFirstName, setNewCustomerFirstName] = useState('')
const [newCustomerLastName, setNewCustomerLastName] = useState('')
const [newCustomerFirstName, setNewCustomerFirstName] = useState<BilingualField>(emptyBilingual())
const [newCustomerLastName, setNewCustomerLastName] = useState<BilingualField>(emptyBilingual())
const [newCustomerEmail, setNewCustomerEmail] = useState('')
const [newCustomerPhone, setNewCustomerPhone] = useState('')
const [driverLicense, setDriverLicense] = useState('')
@@ -65,15 +66,15 @@ export default function NewReservationPage() {
const [licenseCountry, setLicenseCountry] = useState('')
const [licenseCategory, setLicenseCategory] = useState('')
const [additionalDriver, setAdditionalDriver] = useState<AdditionalDriverForm>({
firstName: '',
lastName: '',
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
phone: '',
driverLicense: '',
licenseExpiry: '',
licenseIssuedAt: '',
dateOfBirth: '',
nationality: '',
nationality: emptyBilingual(),
})
const copy = {
@@ -113,7 +114,7 @@ export default function NewReservationPage() {
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone (optional)',
phone: 'Phone',
saveCustomer: 'Save customer',
savingCustomer: 'Saving customer…',
selectVehicle: 'Select vehicle…',
@@ -162,7 +163,7 @@ export default function NewReservationPage() {
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone (optionnel)',
phone: 'Téléphone',
saveCustomer: 'Enregistrer le client',
savingCustomer: 'Enregistrement du client…',
selectVehicle: 'Sélectionner un véhicule…',
@@ -211,7 +212,7 @@ export default function NewReservationPage() {
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف (اختياري)',
phone: 'الهاتف',
saveCustomer: 'حفظ العميل',
savingCustomer: 'جارٍ حفظ العميل…',
selectVehicle: 'اختر مركبة…',
@@ -264,7 +265,7 @@ export default function NewReservationPage() {
async function addCustomer() {
setError(null)
if (!newCustomerFirstName.trim() || !newCustomerLastName.trim() || !newCustomerEmail.trim()) {
if (!bilingualPrimary(newCustomerFirstName).trim() || !bilingualPrimary(newCustomerLastName).trim() || !newCustomerEmail.trim() || !newCustomerPhone.trim()) {
setError(copy.required)
return
}
@@ -273,10 +274,12 @@ export default function NewReservationPage() {
const created = await apiFetch<CreatedCustomer>('/customers', {
method: 'POST',
body: JSON.stringify({
firstName: newCustomerFirstName.trim(),
lastName: newCustomerLastName.trim(),
firstName: bilingualPrimary(newCustomerFirstName).trim(),
firstNameAr: newCustomerFirstName.ar.trim() || undefined,
lastName: bilingualPrimary(newCustomerLastName).trim(),
lastNameAr: newCustomerLastName.ar.trim() || undefined,
email: newCustomerEmail.trim(),
phone: newCustomerPhone.trim() || undefined,
phone: newCustomerPhone.trim(),
driverLicense: driverLicense.trim() || undefined,
licenseExpiry: licenseExpiry ? new Date(licenseExpiry).toISOString() : undefined,
licenseIssuedAt: licenseIssuedAt ? new Date(licenseIssuedAt).toISOString() : undefined,
@@ -286,10 +289,10 @@ export default function NewReservationPage() {
})
setCustomers((prev) => [created, ...prev])
setCustomerId(created.id)
setCustomerSearch(`${created.firstName} ${created.lastName}`)
setCustomerSearch(`${bilingualPrimary(newCustomerFirstName)} ${bilingualPrimary(newCustomerLastName)}`)
setShowAddCustomer(false)
setNewCustomerFirstName('')
setNewCustomerLastName('')
setNewCustomerFirstName(emptyBilingual())
setNewCustomerLastName(emptyBilingual())
setNewCustomerEmail('')
setNewCustomerPhone('')
} catch (err: any) {
@@ -311,7 +314,7 @@ export default function NewReservationPage() {
setError(copy.invalidDates)
return
}
if (includeAdditionalDriver && (!additionalDriver.firstName.trim() || !additionalDriver.lastName.trim() || !additionalDriver.driverLicense.trim())) {
if (includeAdditionalDriver && (!bilingualPrimary(additionalDriver.firstName).trim() || !bilingualPrimary(additionalDriver.lastName).trim() || !additionalDriver.driverLicense.trim())) {
setError(copy.required)
return
}
@@ -341,15 +344,18 @@ export default function NewReservationPage() {
depositAmount: Number.isFinite(Number(depositAmount)) ? Math.max(0, Math.round(Number(depositAmount))) : 0,
paymentMode,
additionalDrivers: includeAdditionalDriver ? [{
firstName: additionalDriver.firstName.trim(),
lastName: additionalDriver.lastName.trim(),
firstName: bilingualPrimary(additionalDriver.firstName).trim(),
firstNameAr: additionalDriver.firstName.ar.trim() || undefined,
lastName: bilingualPrimary(additionalDriver.lastName).trim(),
lastNameAr: additionalDriver.lastName.ar.trim() || undefined,
email: additionalDriver.email.trim() || undefined,
phone: additionalDriver.phone.trim() || undefined,
driverLicense: additionalDriver.driverLicense.trim(),
licenseExpiry: additionalDriver.licenseExpiry ? new Date(additionalDriver.licenseExpiry).toISOString() : undefined,
licenseIssuedAt: additionalDriver.licenseIssuedAt ? new Date(additionalDriver.licenseIssuedAt).toISOString() : undefined,
dateOfBirth: additionalDriver.dateOfBirth ? new Date(additionalDriver.dateOfBirth).toISOString() : undefined,
nationality: additionalDriver.nationality.trim() || undefined,
nationality: bilingualPrimary(additionalDriver.nationality).trim() || undefined,
nationalityAr: additionalDriver.nationality.ar.trim() || undefined,
}] : [],
notes: notes || undefined,
}),
@@ -380,7 +386,7 @@ export default function NewReservationPage() {
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.customer}</span>
<span className="text-sm font-medium text-slate-700">{copy.customer} <span className="text-red-600">*</span></span>
<input
value={customerSearch}
onChange={(e) => setCustomerSearch(e.target.value)}
@@ -404,10 +410,16 @@ export default function NewReservationPage() {
{showAddCustomer ? (
<div className="mt-2 rounded-lg border border-slate-200 p-3 space-y-2 bg-slate-50">
<p className="text-xs font-semibold text-slate-700">{copy.addCustomerTitle}</p>
<input value={newCustomerFirstName} onChange={(e) => setNewCustomerFirstName(e.target.value)} placeholder={copy.firstName} className="input-field" />
<input value={newCustomerLastName} onChange={(e) => setNewCustomerLastName(e.target.value)} placeholder={copy.lastName} className="input-field" />
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} placeholder={copy.email} className="input-field" />
<input value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} placeholder={copy.phone} className="input-field" />
<BilingualInput label={copy.firstName} required value={newCustomerFirstName} onChange={setNewCustomerFirstName} />
<BilingualInput label={copy.lastName} required value={newCustomerLastName} onChange={setNewCustomerLastName} />
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.email} <span className="text-red-600">*</span></span>
<input value={newCustomerEmail} onChange={(e) => setNewCustomerEmail(e.target.value)} className="input-field" />
</div>
<div className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.phone} <span className="text-red-600">*</span></span>
<input required value={newCustomerPhone} onChange={(e) => setNewCustomerPhone(e.target.value)} className="input-field" />
</div>
<div className="flex justify-end">
<button type="button" className="btn-secondary" onClick={addCustomer} disabled={savingCustomer}>
{savingCustomer ? copy.savingCustomer : copy.saveCustomer}
@@ -418,7 +430,7 @@ export default function NewReservationPage() {
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.vehicle}</span>
<span className="text-sm font-medium text-slate-700">{copy.vehicle} <span className="text-red-600">*</span></span>
<select value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} className="input-field">
<option value="">{copy.selectVehicle}</option>
{availableVehicles.map((v) => (
@@ -469,11 +481,11 @@ export default function NewReservationPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.startDate}</span>
<span className="text-sm font-medium text-slate-700">{copy.startDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="input-field" />
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.endDate}</span>
<span className="text-sm font-medium text-slate-700">{copy.endDate} <span className="text-red-600">*</span></span>
<input type="datetime-local" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="input-field" />
</label>
</div>
@@ -517,24 +529,8 @@ export default function NewReservationPage() {
{includeAdditionalDriver ? (
<div className="space-y-4">
<p className="text-sm font-semibold text-slate-900">{copy.additionalDriverInfo}</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.firstName}</span>
<input
value={additionalDriver.firstName}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, firstName: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.lastName}</span>
<input
value={additionalDriver.lastName}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, lastName: e.target.value }))}
className="input-field"
/>
</label>
</div>
<BilingualInput label={copy.firstName} required value={additionalDriver.firstName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, firstName: v }))} />
<BilingualInput label={copy.lastName} required value={additionalDriver.lastName} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, lastName: v }))} />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
@@ -557,21 +553,16 @@ export default function NewReservationPage() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.driverLicense}</span>
<span className="text-sm font-medium text-slate-700">{copy.driverLicense} <span className="text-red-600">*</span></span>
<input
value={additionalDriver.driverLicense}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, driverLicense: e.target.value }))}
className="input-field"
/>
</label>
<label className="space-y-1">
<span className="text-sm font-medium text-slate-700">{copy.nationality}</span>
<input
value={additionalDriver.nationality}
onChange={(e) => setAdditionalDriver((current) => ({ ...current, nationality: e.target.value }))}
className="input-field"
/>
</label>
<div className="space-y-1">
<BilingualInput label={copy.nationality} value={additionalDriver.nationality} onChange={(v) => setAdditionalDriver((cur) => ({ ...cur, nationality: v }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -225,7 +225,9 @@ export default function SubscriptionPage() {
try {
if (provider === 'AMANPAY' && !providerAvailability.amanpay) throw new Error(copy.providerUnavailable)
if (provider === 'PAYPAL' && !providerAvailability.paypal) throw new Error(copy.providerUnavailable)
const base = window.location.origin
const currentUrl = new URL(window.location.href)
currentUrl.search = ''
currentUrl.hash = ''
const result = await apiFetch<{ checkoutUrl: string }>('/subscriptions/checkout', {
method: 'POST',
body: JSON.stringify({
@@ -233,8 +235,8 @@ export default function SubscriptionPage() {
billingPeriod,
currency,
provider,
successUrl: `${base}/dashboard/subscription?payment=success`,
failureUrl: `${base}/dashboard/subscription?payment=failed`,
successUrl: `${currentUrl.toString()}?payment=success`,
failureUrl: `${currentUrl.toString()}?payment=failed`,
}),
})
window.location.href = result.checkoutUrl
@@ -1,6 +1,7 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
@@ -16,19 +17,14 @@ function notifyParent(message: Record<string, unknown>) {
export default function SignInPage() {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const requestedPortal = searchParams.get('portal') === 'admin' ? 'admin' : 'workspace'
const [portal, setPortal] = useState<'admin' | 'workspace'>(requestedPortal)
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const dict = {
en: {
title: 'Sign in',
workspaceTab: 'Company Workspace',
platformTab: 'Platform',
subtitle: 'Choose where you want to sign in, then enter your credentials.',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
@@ -39,38 +35,30 @@ export default function SignInPage() {
enterCode: 'Enter the 6-digit code from your authenticator app.',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
newAccount: 'New company account?',
createWorkspace: 'Create your workspace',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
title: 'Connexion',
workspaceTab: 'Espace',
platformTab: 'Plateforme',
subtitle: 'Choisissez votre accès puis saisissez vos identifiants.',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: 'Code dauthentification',
enterCode: 'Entrez le code à 6 chiffres de votre application dauthentification.',
authCode: 'Code d\'authentification',
enterCode: 'Entrez le code à 6 chiffres de votre application d\'authentification.',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
newAccount: 'Nouveau compte entreprise ?',
createWorkspace: 'Créer votre espace',
invalidCredentials: 'Email ou mot de passe invalide.',
passwordNotSet: 'Aucun mot de passe défini. Vérifiez votre e-mail d\'invitation ou utilisez « Mot de passe oublié ? »',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
title: 'تسجيل الدخول',
workspaceTab: 'المساحة',
platformTab: 'المنصة',
subtitle: 'اختر جهة الدخول ثم أدخل بياناتك.',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
@@ -81,8 +69,6 @@ export default function SignInPage() {
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
newAccount: 'حساب شركة جديد؟',
createWorkspace: 'أنشئ مساحتك',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
@@ -96,9 +82,6 @@ export default function SignInPage() {
title: 'text-slate-900',
subtitle: 'text-slate-500',
card: 'border-slate-200 bg-white shadow-sm',
toggleWrap: 'border-slate-200 bg-slate-50',
activeToggle: 'bg-slate-900 text-white shadow-sm',
inactiveToggle: 'text-slate-600 hover:bg-white hover:text-slate-900',
},
medium: {
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
@@ -107,9 +90,6 @@ export default function SignInPage() {
title: 'text-slate-900',
subtitle: 'text-slate-600',
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
toggleWrap: 'border-slate-300 bg-slate-200/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-700 hover:bg-white/85 hover:text-slate-950',
},
dark: {
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
@@ -118,16 +98,9 @@ export default function SignInPage() {
title: 'text-slate-100',
subtitle: 'text-slate-400',
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
toggleWrap: 'border-slate-700 bg-slate-950/80',
activeToggle: 'bg-amber-400 text-slate-950 shadow-sm',
inactiveToggle: 'text-slate-300 hover:bg-slate-800 hover:text-white',
},
}[theme]
useEffect(() => {
setPortal(requestedPortal)
}, [requestedPortal])
useEffect(() => {
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
@@ -151,20 +124,6 @@ export default function SignInPage() {
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
function handlePortalChange(nextPortal: 'admin' | 'workspace') {
setPortal(nextPortal)
const nextParams = new URLSearchParams(searchParams.toString())
if (nextPortal === 'admin') {
nextParams.set('portal', 'admin')
} else {
nextParams.delete('portal')
}
const nextSearch = nextParams.toString()
router.replace(nextSearch ? `${pathname}?${nextSearch}` : pathname, { scroll: false })
}
return (
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
<div className="w-full max-w-md">
@@ -190,37 +149,14 @@ export default function SignInPage() {
</div>
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
<div className={`mb-6 grid grid-cols-2 gap-2 rounded-full border p-1 transition-colors ${themeStyles.toggleWrap}`}>
{([
{ value: 'workspace', label: dict.workspaceTab },
{ value: 'admin', label: dict.platformTab },
] as const).map((option) => {
const active = option.value === portal
return (
<button
key={option.value}
type="button"
onClick={() => handlePortalChange(option.value)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
active
? themeStyles.activeToggle
: themeStyles.inactiveToggle
}`}
>
{option.label}
</button>
)
})}
</div>
<LocalSignInForm dict={dict} portal={portal} />
<LocalSignInForm dict={dict} />
</section>
</div>
</main>
)
}
function LocalSignInForm({ dict, portal }: {
function LocalSignInForm({ dict }: {
dict: {
email: string
password: string
@@ -236,10 +172,10 @@ function LocalSignInForm({ dict, portal }: {
passwordNotSet: string
unexpectedError: string
}
portal: 'admin' | 'workspace'
}) {
const router = useRouter()
const searchParams = useSearchParams()
const { setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
@@ -249,13 +185,6 @@ function LocalSignInForm({ dict, portal }: {
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
const forgotPasswordHref = portal === 'admin' ? `${adminUrl}/forgot-password` : '/forgot-password'
useEffect(() => {
setStep('credentials')
setTotpCode('')
setError(null)
}, [portal])
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
@@ -268,28 +197,7 @@ function LocalSignInForm({ dict, portal }: {
setError(null)
try {
if (portal === 'admin') {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
return
}
// Try employee login first
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -302,6 +210,13 @@ function LocalSignInForm({ dict, portal }: {
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
// Apply the employee's stored language preference immediately so the
// dashboard renders in the correct language before any read-effect runs.
const prefLang = empJson.data.employee?.preferredLanguage
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
setLanguage(prefLang)
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
}
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
@@ -315,6 +230,24 @@ function LocalSignInForm({ dict, portal }: {
return
}
// Fall back to admin login
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
@@ -411,9 +344,9 @@ function LocalSignInForm({ dict, portal }: {
</button>
<div className="text-center">
<a href={forgotPasswordHref} className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
<Link href="/forgot-password" className="text-sm text-slate-500 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
{dict.forgotPassword}
</a>
</Link>
</div>
</form>
) : (
@@ -7,19 +7,21 @@ import { useDashboardI18n } from '@/components/I18nProvider'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type SignupForm = {
firstName: string
lastName: string
firstName: BilingualField
lastName: BilingualField
email: string
password: string
confirmPassword: string
companyName: string
preferredLanguage: 'en' | 'fr' | 'ar' | ''
companyName: BilingualField
legalForm: string
registrationNumber: string
streetAddress: string
city: string
country: string
streetAddress: BilingualField
city: BilingualField
country: BilingualField
zipCode: string
companyPhone: string
fax: string
@@ -53,7 +55,7 @@ const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR',
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
export default function SignUpPage() {
const { language } = useDashboardI18n()
const { language, setLanguage } = useDashboardI18n()
const searchParams = useSearchParams()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
@@ -63,17 +65,18 @@ export default function SignUpPage() {
const [error, setError] = useState<string | null>(null)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: '',
lastName: '',
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
password: '',
confirmPassword: '',
companyName: '',
preferredLanguage: '',
companyName: emptyBilingual(),
legalForm: '',
registrationNumber: '',
streetAddress: '',
city: '',
country: '',
streetAddress: emptyBilingual(),
city: emptyBilingual(),
country: emptyBilingual(),
zipCode: '',
companyPhone: '',
fax: '',
@@ -130,6 +133,9 @@ export default function SignUpPage() {
invalidPassword: 'Choose a password with at least 8 characters.',
passwordMismatch: 'Passwords do not match.',
missingRequired: 'Fill in all required fields.',
missingLanguage: 'Please select your preferred language.',
preferredLanguageLabel: 'Preferred language',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'Your company',
couldNotCreate: 'Could not create workspace',
legalFormOptions: {
@@ -206,6 +212,9 @@ export default function SignUpPage() {
invalidPassword: 'Choisissez un mot de passe dau moins 8 caractères.',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
missingRequired: 'Renseignez tous les champs obligatoires.',
missingLanguage: 'Veuillez sélectionner votre langue préférée.',
preferredLanguageLabel: 'Langue préférée',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'Votre entreprise',
couldNotCreate: 'Impossible de créer lespace',
legalFormOptions: {
@@ -282,6 +291,9 @@ export default function SignUpPage() {
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
missingRequired: 'أكمل جميع الحقول الإلزامية.',
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
preferredLanguageLabel: 'اللغة المفضلة',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'شركتك',
couldNotCreate: 'تعذر إنشاء المساحة',
legalFormOptions: {
@@ -316,6 +328,15 @@ export default function SignUpPage() {
},
}[language]
function handleStep1Continue() {
if (!form.preferredLanguage) {
setError(dict.missingLanguage)
return
}
setError(null)
setStep(2)
}
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.invalidPassword)
@@ -339,16 +360,23 @@ export default function SignUpPage() {
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
firstName: form.firstName,
lastName: form.lastName,
firstName: form.firstName.fr || form.firstName.ar,
firstNameAr: form.firstName.ar || undefined,
lastName: form.lastName.fr || form.lastName.ar,
lastNameAr: form.lastName.ar || undefined,
email: form.email,
password: form.password,
companyName: form.companyName,
preferredLanguage: form.preferredLanguage || 'en',
companyName: form.companyName.fr || form.companyName.ar,
companyNameAr: form.companyName.ar || undefined,
legalForm: form.legalForm,
registrationNumber: form.registrationNumber,
streetAddress: form.streetAddress,
city: form.city,
country: form.country,
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
streetAddressAr: form.streetAddress.ar || undefined,
city: form.city.fr || form.city.ar,
cityAr: form.city.ar || undefined,
country: form.country.fr || form.country.ar,
countryAr: form.country.ar || undefined,
zipCode: form.zipCode,
companyPhone: form.companyPhone,
fax: form.fax || undefined,
@@ -361,7 +389,7 @@ export default function SignUpPage() {
})
setCompletedSignup({
companyName: form.companyName,
companyName: form.companyName.fr || form.companyName.ar,
email: form.email,
emailWarning: getEmailWarning(response.emailDelivery, language),
})
@@ -426,18 +454,40 @@ export default function SignUpPage() {
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: normalizeTitleCase(value) }))} />
<LabeledInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(value) => setForm((current) => ({ ...current, lastName: normalizeTitleCase(value) }))} />
</div>
<BilingualInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
<BilingualInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(current) => setForm((f) => ({ ...f, lastName: current }))} />
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{dict.preferredLanguageLabel} <span className="text-red-600">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => {
setForm((current) => ({ ...current, preferredLanguage: lang }))
setLanguage(lang)
}}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
form.preferredLanguage === lang
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
}`}
>
{dict.languageOptions[lang]}
</button>
))}
</div>
</div>
<div className="flex justify-end">
<button type="button" onClick={() => setStep(2)} className="btn-primary">{dict.continue}</button>
<button type="button" onClick={handleStep1Continue} className="btn-primary">{dict.continue}</button>
</div>
</div>
) : null}
@@ -446,21 +496,19 @@ export default function SignUpPage() {
<div className="space-y-5">
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
<div className="space-y-4">
<LabeledInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: normalizeTitleCase(value) }))} />
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeTitleCase(value) }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
</div>
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: normalizeUpper(value) }))} />
<LabeledInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: normalizeUpper(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: normalizeUpper(value) }))} />
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
<BilingualInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
</div>
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
@@ -496,7 +544,7 @@ export default function SignUpPage() {
<div className="space-y-5">
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName || dict.companyFallback}</p>
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {form.currency}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
@@ -519,17 +567,15 @@ export default function SignUpPage() {
}
function hasRequiredPartnerFields(form: SignupForm) {
return [
form.companyName,
form.legalForm,
form.registrationNumber,
form.streetAddress,
form.city,
form.country,
form.zipCode,
form.companyPhone,
form.yearsActive,
].every((value) => value.trim().length > 0)
const bilingualFilled = (f: BilingualField) => f.fr.trim().length > 0 || f.ar.trim().length > 0
return (
bilingualFilled(form.companyName) &&
bilingualFilled(form.streetAddress) &&
bilingualFilled(form.city) &&
bilingualFilled(form.country) &&
[form.legalForm, form.registrationNumber, form.zipCode, form.companyPhone, form.yearsActive]
.every((v) => v.trim().length > 0)
)
}
function SectionIntro({ title, body }: { title: string; body: string }) {
@@ -600,6 +646,7 @@ function LabeledInput({
)
}
function LabeledSelect({
label,
value,
+34 -6
View File
@@ -1351,6 +1351,16 @@ function normalizeLanguage(value: string | null | undefined): DashboardLanguage
return value === 'fr' || value === 'ar' || value === 'en' ? value : 'en'
}
function detectBrowserLanguage(): DashboardLanguage | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code as DashboardLanguage
}
return null
}
export function DashboardI18nProvider({
children,
initialLanguage = 'en',
@@ -1361,12 +1371,19 @@ export function DashboardI18nProvider({
const [language, setLanguage] = useState<DashboardLanguage>(initialLanguage)
const [theme, setTheme] = useState<DashboardTheme>('light')
const themeInitialized = useRef(false)
// Skip the very first write so we don't overwrite a stored preference with the
// server-resolved initialLanguage before the read effect has a chance to apply it.
const skipFirstLangWrite = useRef(true)
useEffect(() => {
const stored = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
if (stored === null) return
const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next)
if (stored !== null) {
const next = normalizeLanguage(stored)
if (next !== language) setLanguage(next)
} else {
const detected = detectBrowserLanguage()
if (detected && detected !== language) setLanguage(detected)
}
}, [])
useEffect(() => {
@@ -1384,6 +1401,10 @@ export function DashboardI18nProvider({
useEffect(() => {
document.documentElement.lang = language
document.documentElement.dir = language === 'ar' ? 'rtl' : 'ltr'
if (skipFirstLangWrite.current) {
skipFirstLangWrite.current = false
return
}
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
document.cookie = `${SHARED_LANGUAGE_COOKIE}=${language}; path=/; max-age=31536000; samesite=lax`
document.cookie = `${DASHBOARD_LANGUAGE_KEY}=${language}; path=/; max-age=31536000; samesite=lax`
@@ -1407,10 +1428,17 @@ export function DashboardI18nProvider({
const scopedLanguage = readCurrentUserScopedPreference(SHARED_LANGUAGE_KEY)
const scopedTheme = readCurrentUserScopedPreference(SHARED_THEME_KEY)
if (scopedLanguage && normalizeLanguage(scopedLanguage) !== language) {
setLanguage(normalizeLanguage(scopedLanguage))
if (scopedLanguage) {
// Employee has a stored preference — apply it.
const next = normalizeLanguage(scopedLanguage)
if (next !== language) setLanguage(next)
} else {
writeScopedPreference(SHARED_LANGUAGE_KEY, language, [DASHBOARD_LANGUAGE_KEY])
// No scoped pref yet (new login). Promote any base preference that was set
// before login (e.g., language selected on the sign-up page) to the scoped key.
const baseLang = readScopedPreference(SHARED_LANGUAGE_KEY, [DASHBOARD_LANGUAGE_KEY])
const langToWrite = baseLang ? normalizeLanguage(baseLang) : language
if (langToWrite !== language) setLanguage(langToWrite)
writeScopedPreference(SHARED_LANGUAGE_KEY, langToWrite, [DASHBOARD_LANGUAGE_KEY])
}
if (scopedTheme === 'light' || scopedTheme === 'medium' || scopedTheme === 'dark') {
@@ -39,6 +39,7 @@ interface EmployeeProfile {
firstName: string
lastName: string
role: string
preferredLanguage?: string
}
function computeInitials(name: string): string {
@@ -59,23 +60,29 @@ function toSidebarUser(profile: Partial<EmployeeProfile>, fallbackName: string,
}
const NAV_ITEMS = [
{ href: '/dashboard', key: 'dashboard', icon: LayoutDashboard, exact: true },
{ href: '/dashboard/fleet', key: 'fleet', icon: Car },
{ href: '/dashboard/reservations', key: 'reservations', icon: Calendar },
{ href: '/dashboard/online-reservations', key: 'onlineReservations', icon: Globe },
{ href: '/dashboard/customers', key: 'customers', icon: Users },
{ href: '/dashboard/offers', key: 'offers', icon: Tag },
{ href: '/dashboard/team', key: 'team', icon: UserPlus },
{ href: '/dashboard/reports', key: 'reports', icon: BarChart2 },
{ href: '/dashboard/subscription', key: 'subscription', icon: CreditCard },
{ href: '/dashboard/billing', key: 'billing', icon: CreditCard },
{ href: '/dashboard/contracts', key: 'contracts', icon: FileText },
{ href: '/dashboard/notifications', key: 'notifications', icon: Bell },
{ href: '/dashboard/settings', key: 'settings', icon: Settings },
{ 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' },
] as const
const ROLE_RANK: Record<string, number> = { OWNER: 3, MANAGER: 2, AGENT: 1 }
function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
}
export default function Sidebar() {
const { dict, language } = useDashboardI18n()
const { dict, language, setLanguage } = useDashboardI18n()
const isRtl = language === 'ar'
const pathname = usePathname()
const router = useRouter()
@@ -85,7 +92,11 @@ export default function Sidebar() {
subtitle: dict.localAuth,
initials: 'W',
})
const [role, setRole] = useState<string>('AGENT')
const [open, setOpen] = useState(false)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
useEffect(() => {
apiFetch<BrandSettings>('/companies/me/brand').then(setBrand).catch(() => {})
@@ -107,6 +118,7 @@ export default function Sidebar() {
try {
const profile = JSON.parse(cached) as Partial<EmployeeProfile>
setUser(toSidebarUser(profile, dict.workspaceUser, dict.localAuth))
if (profile.role) setRole(profile.role)
} catch {}
}
@@ -117,6 +129,11 @@ export default function Sidebar() {
if (cancelled) return
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
setUser(toSidebarUser(employee, dict.workspaceUser, dict.localAuth))
if (employee.role) setRole(employee.role)
if (employee.preferredLanguage === 'en' || employee.preferredLanguage === 'fr' || employee.preferredLanguage === 'ar') {
setLanguage(employee.preferredLanguage)
document.cookie = `rentaldrivego-language=${employee.preferredLanguage}; path=/; max-age=31536000; samesite=lax`
}
})
.catch(() => {
if (cancelled) return
@@ -188,9 +205,9 @@ export default function Sidebar() {
</a>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
{NAV_ITEMS.map((item) => {
{NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon
const active = isActive(item)
const active = mounted && isActive(item)
return (
<Link
key={item.href}
@@ -29,7 +29,11 @@ export default function TopBar() {
}>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false)
const [mounted, setMounted] = useState(false)
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']
@@ -0,0 +1,66 @@
'use client'
export type BilingualField = { fr: string; ar: string }
export function emptyBilingual(): BilingualField {
return { fr: '', ar: '' }
}
export function bilingualPrimary(field: BilingualField): string {
return field.fr || field.ar
}
export function BilingualInput({
label,
value,
onChange,
required = false,
maxLength,
}: {
label: string
value: BilingualField
onChange: (value: BilingualField) => void
required?: boolean
maxLength?: number
}) {
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-blue-500">
FR
</span>
<input
type="text"
dir="ltr"
lang="fr"
maxLength={maxLength}
placeholder="Français…"
value={value.fr}
onChange={(e) => onChange({ ...value, fr: e.target.value })}
className="input-field pl-11"
/>
</div>
<div className="relative">
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 rounded bg-emerald-50 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-600">
AR
</span>
<input
type="text"
dir="rtl"
lang="ar"
maxLength={maxLength}
placeholder="بالعربية…"
value={value.ar}
onChange={(e) => onChange({ ...value, ar: e.target.value })}
className="input-field pr-11 text-right"
/>
</div>
</div>
</div>
)
}
@@ -140,7 +140,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
firstName: form.firstName,
lastName: form.lastName,
email: form.email,
phone: form.phone || undefined,
phone: form.phone,
startDate: new Date(`${form.startDate}T${form.startTime}`).toISOString(),
endDate: new Date(`${form.endDate}T${form.endTime}`).toISOString(),
notes: form.notes || undefined,
@@ -267,7 +267,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
{/* Dates + Times */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupDate} <span className="text-red-500">*</span></label>
<input
type="date"
required
@@ -278,7 +278,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.returnDate}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.returnDate} <span className="text-red-500">*</span></label>
<input
type="date"
required
@@ -289,7 +289,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.pickupTime} <span className="text-red-500">*</span></label>
<input
type="time"
required
@@ -299,7 +299,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.dropoffTime} <span className="text-red-500">*</span></label>
<input
type="time"
required
@@ -321,7 +321,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
{/* Customer info */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.firstName}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.firstName} <span className="text-red-500">*</span></label>
<input
type="text"
required
@@ -331,7 +331,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
/>
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.lastName}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.lastName} <span className="text-red-500">*</span></label>
<input
type="text"
required
@@ -343,7 +343,7 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.email} <span className="text-red-500">*</span></label>
<input
type="email"
required
@@ -354,9 +354,10 @@ export default function ExploreVehicleGrid({ vehicles, dict }: { vehicles: Vehic
</div>
<div>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone}</label>
<label className="block text-xs font-medium text-stone-600 mb-1">{dict.phone} <span className="text-red-500">*</span></label>
<input
type="tel"
required
value={form.phone}
onChange={set('phone')}
className="w-full rounded-xl border border-stone-200 px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-stone-900"
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { marketplaceFetchOrDefault } from '@/lib/api'
import { getMarketplaceLanguage } from '@/lib/i18n.server'
import { formatCurrency } from '@rentaldrivego/types'
import BookingForm from '@/components/BookingForm'
interface VehicleDetail {
id: string
@@ -32,43 +33,37 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
const language = getMarketplaceLanguage()
const dict = {
en: {
unavailable: 'Marketplace unavailable',
unavailableTitle: 'Vehicle details are temporarily unavailable.',
unavailableBody: 'The marketplace frontend is running, but the backing database is not reachable right now.',
backToFleet: 'Back to company fleet',
noPhotos: 'No photos available.',
rentalCompany: 'Rental company',
perDay: '/ day',
continueTo: 'Continue to',
bookingSite: 'booking site',
availableFrom: 'Available from',
unavailableNoDate: 'This vehicle is temporarily unavailable for new reservations.',
unavailable: Marketplace unavailable,
unavailableTitle: Vehicle details are temporarily unavailable.,
unavailableBody: The marketplace frontend is running, but the backing database is not reachable right now.,
backToFleet: Back to company fleet,
noPhotos: No photos available.,
rentalCompany: Rental company,
perDay: / day,
availableFrom: Available from,
unavailableNoDate: This vehicle is temporarily unavailable for new reservations.,
},
fr: {
unavailable: 'Marketplace indisponible',
unavailableTitle: 'Les détails du véhicule sont temporairement indisponibles.',
unavailableBody: 'Le frontend marketplace fonctionne, mais la base de données nest pas accessible pour le moment.',
backToFleet: 'Retour à la flotte',
noPhotos: 'Aucune photo disponible.',
rentalCompany: 'Société de location',
perDay: '/ jour',
continueTo: 'Continuer vers le site de réservation de',
bookingSite: '',
availableFrom: 'Disponible à partir du',
unavailableNoDate: 'Ce véhicule est temporairement indisponible pour les nouvelles réservations.',
unavailable: Marketplace indisponible,
unavailableTitle: Les détails du véhicule sont temporairement indisponibles.,
unavailableBody: Le frontend marketplace fonctionne, mais la base de données nest pas accessible pour le moment.,
backToFleet: Retour à la flotte,
noPhotos: Aucune photo disponible.,
rentalCompany: Société de location,
perDay: / jour,
availableFrom: Disponible à partir du,
unavailableNoDate: Ce véhicule est temporairement indisponible pour les nouvelles réservations.,
},
ar: {
unavailable: 'السوق غير متاح',
unavailableTitle: 'تفاصيل السيارة غير متاحة مؤقتاً.',
unavailableBody: 'واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.',
backToFleet: 'العودة إلى أسطول الشركة',
noPhotos: 'لا توجد صور متاحة.',
rentalCompany: 'شركة تأجير',
perDay: '/ يوم',
continueTo: 'الانتقال إلى موقع حجز',
bookingSite: '',
availableFrom: 'متاح ابتداءً من',
unavailableNoDate: 'هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.',
unavailable: السوق غير متاح,
unavailableTitle: تفاصيل السيارة غير متاحة مؤقتاً.,
unavailableBody: واجهة السوق تعمل، لكن قاعدة البيانات الخلفية غير متاحة حالياً.,
backToFleet: العودة إلى أسطول الشركة,
noPhotos: لا توجد صور متاحة.,
rentalCompany: شركة تأجير,
perDay: / يوم,
availableFrom: متاح ابتداءً من,
unavailableNoDate: هذه السيارة غير متاحة مؤقتاً للحجوزات الجديدة.,
},
}[language]
const vehicle = await marketplaceFetchOrDefault<VehicleDetail | null>(`/marketplace/${params.slug}/vehicles/${params.id}`, null)
@@ -90,7 +85,6 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
)
}
const destination = `https://${params.slug}.RentalDriveGo.com/book?vehicleId=${vehicle.id}&ref=marketplace`
const availabilityDate = vehicle.nextAvailableAt
? new Date(vehicle.nextAvailableAt).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
: null
@@ -109,25 +103,33 @@ export default async function VehicleDetailPage({ params }: { params: { slug: st
{vehicle.photos.length === 0 && <div className="card p-8 text-sm text-stone-500">{dict.noPhotos}</div>}
</div>
</section>
<aside className="card p-6 h-fit">
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
{!vehicle.availability && (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
<aside className="card p-6 h-fit space-y-6">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-stone-500">{vehicle.company.brand?.displayName ?? dict.rentalCompany}</p>
<h1 className="mt-3 text-4xl font-black text-stone-900">{vehicle.make} {vehicle.model}</h1>
<p className="mt-3 text-stone-600">{vehicle.year} · {vehicle.category} · {vehicle.transmission}</p>
<p className="mt-6 text-3xl font-black text-stone-900">{formatCurrency(vehicle.dailyRate, 'MAD')}<span className="text-base font-medium text-stone-500"> {dict.perDay}</span></p>
{!vehicle.availability && (
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{availabilityDate ? `${dict.availableFrom} ${availabilityDate}` : dict.unavailableNoDate}
</div>
)}
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
))}
</div>
)}
<div className="mt-6 flex flex-wrap gap-2">
{vehicle.features.map((feature) => (
<span key={feature} className="rounded-full bg-stone-100 px-3 py-1 text-xs font-medium text-stone-700">{feature}</span>
))}
</div>
<div className="mt-8 space-y-3">
<Link href={destination} className="block rounded-full bg-stone-900 px-6 py-3 text-center text-sm font-semibold text-white">{dict.continueTo} {vehicle.company.brand?.displayName ?? dict.rentalCompany} {dict.bookingSite}</Link>
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link>
</div>
{vehicle.availability ? (
<BookingForm
vehicleId={vehicle.id}
companySlug={params.slug}
dailyRate={vehicle.dailyRate}
/>
) : null}
<Link href={`/explore/${params.slug}`} className="block rounded-full border border-stone-300 px-6 py-3 text-center text-sm font-semibold text-stone-700">{dict.backToFleet}</Link>
</aside>
</div>
</main>
+3 -3
View File
@@ -102,7 +102,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
phone: 'Phone number (optional)',
phone: 'Phone number',
notes: 'Additional notes (optional)',
cancel: 'Cancel',
submitRequest: 'Send reservation request',
@@ -160,7 +160,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
firstName: 'Prénom',
lastName: 'Nom',
email: 'Adresse e-mail',
phone: 'Numéro de téléphone (optionnel)',
phone: 'Numéro de téléphone',
notes: 'Notes supplémentaires (optionnel)',
cancel: 'Annuler',
submitRequest: 'Envoyer la demande',
@@ -218,7 +218,7 @@ export default async function ExplorePage({ searchParams }: { searchParams?: Rec
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'رقم الهاتف (اختياري)',
phone: 'رقم الهاتف',
notes: 'ملاحظات إضافية (اختياري)',
cancel: 'إلغاء',
submitRequest: 'إرسال طلب الحجز',
@@ -0,0 +1,246 @@
'use client'
import { useState } from 'react'
import { marketplacePost } from '@/lib/api'
import { useMarketplacePreferences } from '@/components/MarketplaceShell'
type Props = {
vehicleId: string
companySlug: string
dailyRate: number
}
const copy = {
en: {
title: 'Reserve this vehicle',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
phone: 'Phone',
startDate: 'Pick-up date',
endDate: 'Return date',
notes: 'Notes (optional)',
submit: 'Request reservation',
submitting: 'Sending…',
successTitle: 'Request received!',
successBody: 'Your reservation request has been sent. The company will review it and get in touch with you shortly.',
invalidDates: 'Return date must be after pick-up date.',
required: 'Please fill in all required fields.',
perDay: '/ day',
estimatedTotal: 'Estimated total',
days: 'day(s)',
},
fr: {
title: 'Réserver ce véhicule',
firstName: 'Prénom',
lastName: 'Nom',
email: 'Email',
phone: 'Téléphone',
startDate: 'Date de départ',
endDate: 'Date de retour',
notes: 'Notes (optionnelles)',
submit: 'Demander une réservation',
submitting: 'Envoi…',
successTitle: 'Demande reçue !',
successBody: 'Votre demande de réservation a été envoyée. La société va l'examiner et vous contactera prochainement.',
invalidDates: 'La date de retour doit être après la date de départ.',
required: 'Veuillez remplir tous les champs obligatoires.',
perDay: '/ jour',
estimatedTotal: 'Total estimé',
days: 'jour(s)',
},
ar: {
title: 'احجز هذه السيارة',
firstName: 'الاسم الأول',
lastName: 'اسم العائلة',
email: 'البريد الإلكتروني',
phone: 'الهاتف',
startDate: 'تاريخ الاستلام',
endDate: 'تاريخ الإعادة',
notes: 'ملاحظات (اختيارية)',
submit: 'طلب حجز',
submitting: 'جارٍ الإرسال',
successTitle: 'تم استلام الطلب!',
successBody: 'تم إرسال طلب الحجز. ستراجعه الشركة وستتواصل معك قريباً.',
invalidDates: 'يجب أن يكون تاريخ الإعادة بعد تاريخ الاستلام.',
required: 'يرجى تعبئة جميع الحقول المطلوبة.',
perDay: '/ يوم',
estimatedTotal: 'الإجمالي التقديري',
days: 'يوم',
},
} as const
export default function BookingForm({ vehicleId, companySlug, dailyRate }: Props) {
const { language } = useMarketplacePreferences()
const t = copy[language]
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [email, setEmail] = useState('')
const [phone, setPhone] = useState('')
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const [notes, setNotes] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const totalDays =
startDate && endDate
? Math.max(0, Math.ceil((new Date(endDate).getTime() - new Date(startDate).getTime()) / 86_400_000))
: 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
if (!firstName.trim() || !lastName.trim() || !email.trim() || !phone.trim() || !startDate || !endDate) {
setError(t.required)
return
}
const start = new Date(startDate)
const end = new Date(endDate)
if (end <= start) {
setError(t.invalidDates)
return
}
setSubmitting(true)
try {
await marketplacePost('/marketplace/reservations', {
vehicleId,
companySlug,
firstName: firstName.trim(),
lastName: lastName.trim(),
email: email.trim(),
phone: phone.trim(),
startDate: start.toISOString(),
endDate: end.toISOString(),
notes: notes.trim() || undefined,
})
setSuccess(true)
} catch (err: any) {
setError(err.message ?? 'Something went wrong. Please try again.')
} finally {
setSubmitting(false)
}
}
if (success) {
return (
<div className="rounded-2xl border border-green-200 bg-green-50 p-6 dark:border-green-800 dark:bg-green-950/40">
<p className="text-base font-semibold text-green-800 dark:text-green-300">{t.successTitle}</p>
<p className="mt-2 text-sm text-green-700 dark:text-green-400">{t.successBody}</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-sm font-semibold text-stone-900 dark:text-stone-100">{t.title}</p>
{error ? (
<p className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/40 dark:text-red-400">
{error}
</p>
) : 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.firstName} <span className="text-red-500">*</span></label>
<input
required
value={firstName}
onChange={(e) => setFirstName(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.lastName} <span className="text-red-500">*</span></label>
<input
required
value={lastName}
onChange={(e) => setLastName(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.email} <span className="text-red-500">*</span></label>
<input
required
type="email"
value={email}
onChange={(e) => setEmail(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.phone} <span className="text-red-500">*</span></label>
<input
required
type="tel"
value={phone}
onChange={(e) => setPhone(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<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>
<input
required
type="date"
value={startDate}
min={new Date().toISOString().slice(0, 10)}
onChange={(e) => setStartDate(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.endDate} <span className="text-red-500">*</span></label>
<input
required
type="date"
value={endDate}
min={startDate || new Date().toISOString().slice(0, 10)}
onChange={(e) => setEndDate(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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
</div>
{totalDays > 0 ? (
<p className="text-sm text-stone-600 dark:text-stone-400">
{t.estimatedTotal}:{' '}
<strong className="text-stone-900 dark:text-stone-100">
{(dailyRate * totalDays).toLocaleString()} MAD
</strong>{' '}
({totalDays} {t.days})
</p>
) : null}
<div className="space-y-1">
<label className="text-xs font-medium text-stone-600 dark:text-stone-400">{t.notes}</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
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-stone-700 dark:bg-stone-800 dark:text-stone-100 dark:focus:border-stone-500"
/>
</div>
<button
type="submit"
disabled={submitting}
className="w-full rounded-full bg-stone-900 px-6 py-3 text-center text-sm font-semibold text-white transition hover:bg-stone-700 disabled:opacity-60 dark:bg-amber-400 dark:text-stone-950 dark:hover:bg-amber-300"
>
{submitting ? t.submitting : t.submit}
</button>
</form>
)
}
@@ -189,6 +189,16 @@ function SegmentedControl<T extends string>({
)
}
function detectBrowserLanguage(): string | null {
if (typeof navigator === 'undefined') return null
const langs = Array.from(navigator.languages ?? [navigator.language])
for (const lang of langs) {
const code = lang.split('-')[0].toLowerCase()
if (code === 'fr' || code === 'ar' || code === 'en') return code
}
return null
}
export function useMarketplacePreferences() {
const context = useContext(PreferencesContext)
if (!context) throw new Error('useMarketplacePreferences must be used within MarketplaceShell')
@@ -262,6 +272,9 @@ export default function MarketplaceShell({ children }: { children: React.ReactNo
if (isMarketplaceLanguage(storedLanguage)) {
setLanguage(storedLanguage)
} else {
const detected = detectBrowserLanguage()
if (isMarketplaceLanguage(detected)) setLanguage(detected)
}
if (storedTheme === 'light' || storedTheme === 'medium' || storedTheme === 'dark') {
@@ -0,0 +1 @@
ALTER TABLE "employees" ADD COLUMN "preferredLanguage" TEXT NOT NULL DEFAULT 'en';
+1
View File
@@ -452,6 +452,7 @@ model Employee {
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
role EmployeeRole @default(AGENT)
preferredLanguage String @default("en")
isActive Boolean @default(true)
notifications Notification[] @relation("EmployeeNotifications")
+6
View File
@@ -0,0 +1,6 @@
root@rentaldrivego:~# command -v docker && (crontab -l 2>/dev/null | grep -v 'docker image prune -a -f'; echo '0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1') | crontab - && crontab -l | tail -n 5
/usr/bin/docker
0 2 * * * /usr/bin/docker image prune -a -f >> /var/log/docker-image-prune.log 2>&1
#how to run scripts
bash scripts/docker-prod-up-frontends.sh