2 Commits

Author SHA1 Message Date
root 3b142ca4c7 chore: bulk commit of pre-existing changes
Build & Deploy / Build & Push Docker Image (push) Successful in 48s
Test / API Unit Tests (push) Successful in 9m48s
Test / Marketplace Unit Tests (push) Successful in 9m38s
Test / Admin Unit Tests (push) Successful in 9m33s
Build & Deploy / Deploy to VPS (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Includes:
- Admin dashboard layout and page updates
- API account auth module (routes, schemas, service)
- Dashboard sign-up form extraction, middleware refactor
- Marketplace proxy and middleware updates
- CORS origins expansion for dev environment
- Seed email domain correction (.com → .ma)
- Docs: create-account guide, fleet page, signup plan
- Various UI component and layout refinements
2026-06-25 00:57:59 -04:00
root 572d115003 update dashboard ux 2026-06-24 03:04:01 -04:00
39 changed files with 3393 additions and 1149 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ ADMIN_SEED_LAST_NAME=Admin
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=placeholder CLERK_SECRET_KEY=placeholder
NODE_ENV=development NODE_ENV=development
CORS_ORIGINS=http://localhost:3000,http://localhost:4000 CORS_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:4000,http://127.0.0.1:3000,http://127.0.0.1:3001,http://127.0.0.1:3002,http://127.0.0.1:4000
# Email - disable Resend (placeholder), use SMTP instead # Email - disable Resend (placeholder), use SMTP instead
RESEND_API_KEY=placeholder RESEND_API_KEY=placeholder
+1 -1
View File
@@ -83,7 +83,7 @@ NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
DASHBOARD_URL=http://localhost:3000/dashboard DASHBOARD_URL=http://localhost:3000/dashboard
# ─── Admin seed (first SUPER_ADMIN created on db:seed) ──────── # ─── Admin seed (first SUPER_ADMIN created on db:seed) ────────
ADMIN_SEED_EMAIL=admin@rentaldrivego.com ADMIN_SEED_EMAIL=admin@rentaldrivego.ma
ADMIN_SEED_PASSWORD=placeholder ADMIN_SEED_PASSWORD=placeholder
ADMIN_SEED_FIRST_NAME=Super ADMIN_SEED_FIRST_NAME=Super
ADMIN_SEED_LAST_NAME=Admin ADMIN_SEED_LAST_NAME=Admin
+1 -1
View File
@@ -89,7 +89,7 @@ export default function AdminDashboardLayout({ children }: { children: React.Rea
<aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78"> <aside className="flex w-60 flex-shrink-0 flex-col border-r border-stone-200/80 bg-white/78 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-[#07101e]/78">
<Link href="/" className="block px-5 py-6"> <Link href="/" className="block px-5 py-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p> <p className="text-xs font-semibold uppercase tracking-[0.2em] text-orange-700 dark:text-orange-300">{dict.admin}</p>
<p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">RentalDriveGo</p> <p className="mt-0.5 text-sm font-semibold text-blue-950 dark:text-stone-100">FleetOS</p>
</Link> </Link>
<nav className="flex-1 px-3 space-y-0.5"> <nav className="flex-1 px-3 space-y-0.5">
{navLinks.map((link) => { {navLinks.map((link) => {
+160 -38
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
import { useAdminI18n } from '@/components/I18nProvider' import { useAdminI18n } from '@/components/I18nProvider'
import { ADMIN_API_BASE } from '@/lib/api' import { ADMIN_API_BASE } from '@/lib/api'
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext' import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
@@ -15,35 +16,65 @@ interface Metrics {
} }
export default function AdminDashboardPage() { export default function AdminDashboardPage() {
const { language, dict } = useAdminI18n() const { language } = useAdminI18n()
const admin = useAdminSession() const admin = useAdminSession()
const copy = { const copy = {
en: { en: {
kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'], kpis: ['Total companies', 'Active companies', 'Total renters', 'Total reservations'],
platformOverview: 'Platform overview', brand: 'FleetOS',
eyebrow: 'Operations command',
platformOverview: 'FleetOS admin dashboard',
subtitle: 'Monitor marketplace health, subscription coverage, renter activity, and operator actions from one control surface.',
liveSignal: 'Live platform signal',
readiness: 'Operational readiness',
readinessBody: 'Core admin workflows are connected and ready for platform support.',
quickActions: 'Quick actions',
mrr: 'Monthly recurring revenue',
noMetrics: 'Metrics are limited for this admin role.',
cards: [ cards: [
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all'], ['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all'], ['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs'], ['Audit logs', 'Trace every sensitive admin action across FleetOS.', 'View logs'],
], ],
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
}, },
fr: { fr: {
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'], kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
platformOverview: 'Vue densemble de la plateforme', brand: 'FleetOS',
eyebrow: 'Centre des opérations',
platformOverview: 'Tableau de bord admin FleetOS',
subtitle: 'Suivez la santé de la marketplace, les abonnements, lactivité des locataires et les actions opérateur depuis une même interface.',
liveSignal: 'Signal plateforme en direct',
readiness: 'Disponibilité opérationnelle',
readinessBody: 'Les principaux workflows admin sont connectés et prêts pour le support plateforme.',
quickActions: 'Actions rapides',
mrr: 'Revenu mensuel récurrent',
noMetrics: 'Les métriques sont limitées pour ce rôle admin.',
cards: [ cards: [
['Entreprises', 'Lister, rechercher, suspendre, réactiver et consulter l’état des abonnements.', 'Voir tout'], ['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
['Locataires', 'Gérer lassistance liée au blocage et au déblocage des locataires de la marketplace.', 'Voir tout'], ['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
['Journaux daudit', 'Consulter la trace complète de chaque action dadministration sur la plateforme.', 'Voir les journaux'], ['Journaux daudit', 'Tracer chaque action admin sensible dans FleetOS.', 'Voir les journaux'],
], ],
health: ['Comptes locataires', 'Identité marketplace', 'Piste daudit admin'],
}, },
ar: { ar: {
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'], kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
platformOverview: 'نظرة عامة على المنصة', brand: 'FleetOS',
eyebrow: 'مركز العمليات',
platformOverview: 'لوحة إدارة FleetOS',
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
liveSignal: 'مؤشر المنصة المباشر',
readiness: 'جاهزية التشغيل',
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
quickActions: 'إجراءات سريعة',
mrr: 'الإيراد الشهري المتكرر',
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
cards: [ cards: [
['الشركات', 'اعرض الشركات وابحث عنها وعلّقها وأعد تفعيلها وراجع حالة الاشتراك.', 'عرض الكل'], ['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
['المستأجرون', 'إدارة طلبات الدعم الخاصة بحظر مستأجري السوق وفك الحظر عنهم.', 'عرض الكل'], ['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
['سجلات التدقيق', 'مراجعة السجل الكامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات'], ['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل FleetOS.', 'عرض السجلات'],
], ],
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
}, },
}[language] }[language]
const [metrics, setMetrics] = useState<Metrics | null>(null) const [metrics, setMetrics] = useState<Metrics | null>(null)
@@ -72,21 +103,90 @@ export default function AdminDashboardPage() {
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [admin]) }, [admin])
const numberFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US')
const currencyFormatter = new Intl.NumberFormat(language === 'ar' ? 'ar-MA' : language === 'fr' ? 'fr-FR' : 'en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
})
const kpis = metrics const kpis = metrics
? [ ? [
{ label: 'Total companies', value: metrics.totalCompanies }, { label: copy.kpis[0], value: metrics.totalCompanies, icon: Building2, tone: 'text-blue-600 dark:text-blue-300', bg: 'bg-blue-50 dark:bg-blue-500/10' },
{ label: copy.kpis[1], value: metrics.activeCompanies }, { label: copy.kpis[1], value: metrics.activeCompanies, icon: Activity, tone: 'text-emerald-600 dark:text-emerald-300', bg: 'bg-emerald-50 dark:bg-emerald-500/10' },
{ label: copy.kpis[2], value: metrics.totalRenters }, { label: copy.kpis[2], value: metrics.totalRenters, icon: Users, tone: 'text-violet-600 dark:text-violet-300', bg: 'bg-violet-50 dark:bg-violet-500/10' },
{ label: copy.kpis[3], value: metrics.totalReservations }, { label: copy.kpis[3], value: metrics.totalReservations, icon: ClipboardList, tone: 'text-orange-600 dark:text-orange-300', bg: 'bg-orange-50 dark:bg-orange-500/10' },
] ]
: [] : []
if (kpis.length > 0) kpis[0].label = copy.kpis[0]
const activeRate = metrics?.totalCompanies
? Math.round((metrics.activeCompanies / metrics.totalCompanies) * 100)
: 0
const renterRatio = metrics?.totalCompanies
? Math.round(metrics.totalRenters / Math.max(metrics.totalCompanies, 1))
: 0
const actionCards = [
{ href: '/dashboard/companies', icon: Building2, title: copy.cards[0][0], body: copy.cards[0][1], cta: copy.cards[0][2] },
{ href: '/dashboard/renters', icon: Users, title: copy.cards[1][0], body: copy.cards[1][1], cta: copy.cards[1][2] },
{ href: '/dashboard/audit-logs', icon: ShieldCheck, title: copy.cards[2][0], body: copy.cards[2][1], cta: copy.cards[2][2] },
]
return ( return (
<div className="shell py-8 space-y-8"> <div className="shell py-8 space-y-8">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)]">
<section className="relative overflow-hidden rounded-[2rem] border border-stone-200/80 bg-white/85 p-6 shadow-[0_30px_90px_rgba(15,23,42,0.10)] transition-colors dark:border-blue-900/60 dark:bg-[#081427]/86 dark:shadow-[0_34px_100px_rgba(0,0,0,0.34)] sm:p-8">
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#2563eb,#f97316,#10b981)]" />
<div className="flex flex-col gap-5 md:flex-row md:items-start md:justify-between">
<div className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-300">{copy.eyebrow}</p>
<h1 className="mt-3 text-4xl font-black tracking-normal text-blue-950 dark:text-white sm:text-5xl">{copy.platformOverview}</h1>
<p className="mt-4 max-w-2xl text-sm leading-6 text-stone-600 dark:text-slate-300">{copy.subtitle}</p>
</div>
<div className="flex w-full max-w-[15rem] items-center gap-3 rounded-2xl border border-stone-200/80 bg-stone-50/90 p-3 dark:border-blue-900/60 dark:bg-[#0d1b38]/85">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-blue-950 text-white dark:bg-orange-500">
<WalletCards className="h-5 w-5" aria-hidden="true" />
</div>
<div> <div>
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p> <p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1> <p className="text-lg font-black text-blue-950 dark:text-white">{metrics?.mrr != null ? currencyFormatter.format(metrics.mrr) : '—'}</p>
</div>
</div>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{copy.health.map((item) => (
<div key={item} className="rounded-2xl border border-stone-200/70 bg-white/70 px-4 py-3 text-sm font-semibold text-stone-700 dark:border-blue-900/50 dark:bg-[#0d1b38]/70 dark:text-slate-200">
{item}
</div>
))}
</div>
</section>
<section className="panel p-6">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-400">{copy.liveSignal}</p>
<div className="mt-5 space-y-5">
<div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-zinc-200">{copy.readiness}</span>
<span className="font-black text-orange-600 dark:text-orange-300">{activeRate}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-stone-200 dark:bg-blue-950">
<div className="h-full rounded-full bg-[linear-gradient(90deg,#2563eb,#f97316)]" style={{ width: `${Math.min(activeRate, 100)}%` }} />
</div>
<p className="mt-3 text-sm leading-6 text-zinc-500">{copy.readinessBody}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
<p className="text-xs text-zinc-500">{copy.kpis[1]}</p>
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(metrics.activeCompanies) : '—'}</p>
</div>
<div className="rounded-2xl border border-stone-200/80 bg-stone-50/80 p-4 dark:border-blue-900/50 dark:bg-[#07101e]/70">
<p className="text-xs text-zinc-500">{copy.kpis[2]}</p>
<p className="mt-1 text-2xl font-black text-zinc-200">{metrics ? numberFormatter.format(renterRatio) : '—'}</p>
</div>
</div>
</div>
</section>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
@@ -97,27 +197,49 @@ export default function AdminDashboardPage() {
<div className="mt-3 h-8 w-16 rounded bg-zinc-800" /> <div className="mt-3 h-8 w-16 rounded bg-zinc-800" />
</div> </div>
)) ))
: kpis.map((kpi) => ( : kpis.length > 0 ? kpis.map((kpi) => {
<div key={kpi.label} className="panel p-6"> const Icon = kpi.icon
<p className="text-xs text-zinc-500">{kpi.label}</p> return (
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p> <div key={kpi.label} className="panel p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-zinc-500">{kpi.label}</p>
<p className="mt-2 text-3xl font-black text-zinc-200">{numberFormatter.format(kpi.value ?? 0)}</p>
</div> </div>
))} <div className={`grid h-10 w-10 place-items-center rounded-2xl ${kpi.bg}`}>
<Icon className={`h-5 w-5 ${kpi.tone}`} aria-hidden="true" />
</div> </div>
<div className="grid gap-6 md:grid-cols-3">
{[
['/dashboard/companies', ...copy.cards[0]],
['/dashboard/renters', ...copy.cards[1]],
['/dashboard/audit-logs', ...copy.cards[2]],
].map(([href, title, body, cta]) => (
<Link key={href} href={href} className="panel p-6 hover:border-zinc-700 transition-colors block">
<p className="text-sm font-semibold text-zinc-200">{title}</p>
<p className="mt-2 text-sm text-zinc-500">{body}</p>
<p className="mt-4 text-xs text-emerald-400 font-medium">{cta}</p>
</Link>
))}
</div> </div>
</div> </div>
) )
}) : (
<div className="panel p-6 md:col-span-2 lg:col-span-4">
<p className="text-sm text-zinc-500">{copy.noMetrics}</p>
</div>
)}
</div>
<section>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-blue-950 dark:text-white">{copy.quickActions}</p>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">{copy.brand}</p>
</div>
<div className="grid gap-5 md:grid-cols-3">
{actionCards.map(({ href, icon: Icon, title, body, cta }) => (
<Link key={href} href={href} className="panel group block p-6 hover:border-orange-300 dark:hover:border-orange-500/70">
<div className="flex items-start justify-between gap-4">
<div className="grid h-11 w-11 place-items-center rounded-2xl bg-blue-950 text-white dark:bg-orange-500">
<Icon className="h-5 w-5" aria-hidden="true" />
</div>
<ArrowRight className="h-4 w-4 text-zinc-500 transition-transform group-hover:translate-x-1 group-hover:text-orange-500" aria-hidden="true" />
</div>
<p className="mt-5 text-base font-bold text-zinc-200">{title}</p>
<p className="mt-2 min-h-[3rem] text-sm leading-6 text-zinc-500">{body}</p>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.16em] text-emerald-400">{cta}</p>
</Link>
))}
</div>
</section>
</div>
)
} }
+2 -2
View File
@@ -3,8 +3,8 @@ import { AdminI18nProvider } from '@/components/I18nProvider'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'RentalDriveGo Admin', title: 'FleetOS Admin',
description: 'Platform administration for RentalDriveGo.', description: 'FleetOS platform administration.',
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
+35 -2
View File
@@ -1,5 +1,5 @@
import express from 'express' import express from 'express'
import cors from 'cors' import cors, { type CorsOptions } from 'cors'
import helmet from 'helmet' import helmet from 'helmet'
import morgan from 'morgan' import morgan from 'morgan'
import swaggerUi from 'swagger-ui-express' import swaggerUi from 'swagger-ui-express'
@@ -12,6 +12,7 @@ import { requestIdMiddleware } from './middleware/requestId'
import webhookRouter from './modules/webhooks/webhook.routes' import webhookRouter from './modules/webhooks/webhook.routes'
import companyAuthRouter from './modules/auth/auth.company.routes' import companyAuthRouter from './modules/auth/auth.company.routes'
import employeeAuthRouter from './modules/auth/auth.employee.routes' import employeeAuthRouter from './modules/auth/auth.employee.routes'
import accountAuthRouter from './modules/auth/auth.account.routes'
import renterAuthRouter from './modules/auth/auth.renter.routes' import renterAuthRouter from './modules/auth/auth.renter.routes'
import teamRouter from './modules/team/team.routes' import teamRouter from './modules/team/team.routes'
import offersRouter from './modules/offers/offer.routes' import offersRouter from './modules/offers/offer.routes'
@@ -40,8 +41,12 @@ const v1 = '/api/v1'
const defaultCorsOrigins = [ const defaultCorsOrigins = [
'http://localhost:3000', 'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:4000', 'http://localhost:4000',
'http://127.0.0.1:3000', 'http://127.0.0.1:3000',
'http://127.0.0.1:3001',
'http://127.0.0.1:3002',
'http://127.0.0.1:4000', 'http://127.0.0.1:4000',
] ]
@@ -49,6 +54,33 @@ export const corsOrigins = process.env.CORS_ORIGINS
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean) ? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
: defaultCorsOrigins : defaultCorsOrigins
function isAllowedLocalDevOrigin(origin: string) {
if (process.env.NODE_ENV === 'production') return false
try {
const url = new URL(origin)
return (
url.protocol === 'http:' &&
['localhost', '127.0.0.1'].includes(url.hostname) &&
['3000', '3001', '3002', '4000'].includes(url.port)
)
} catch {
return false
}
}
export function isCorsOriginAllowed(origin: string | undefined) {
if (!origin) return true
return corsOrigins.includes(origin) || isAllowedLocalDevOrigin(origin)
}
export const corsOptions: CorsOptions = {
origin(origin, callback) {
callback(null, isCorsOriginAllowed(origin))
},
credentials: true,
}
const routeDocs = [ const routeDocs = [
{ method: 'GET', path: '/health', description: 'Health check' }, { method: 'GET', path: '/health', description: 'Health check' },
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' }, { method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
@@ -74,7 +106,7 @@ const routeDocs = [
export function createApp() { export function createApp() {
const app = express() const app = express()
const corsMiddleware = cors({ origin: corsOrigins, credentials: true }) const corsMiddleware = cors(corsOptions)
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1) app.set('trust proxy', 1)
@@ -147,6 +179,7 @@ export function createApp() {
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
// ─── API Routes ───────────────────────────────────────────── // ─── API Routes ─────────────────────────────────────────────
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter) app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter) app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter) app.use(`${v1}/auth/employee`, authLimiter, employeeAuthRouter)
@@ -0,0 +1,26 @@
import { Router } from 'express'
import { parseBody } from '../../http/validate'
import { created } from '../../http/respond'
import { setSessionCookie } from '../../security/sessionCookies'
import { accountStartSchema } from './auth.account.schemas'
import * as service from './auth.account.service'
const router = Router()
/**
* POST /api/v1/auth/account/start
*
* Minimal signup — Step 0 of progressive profiling.
* Creates a DRAFT company + OWNER employee and returns a JWT session.
* See progressive-signup-plan.md Section 3.
*/
router.post('/start', async (req, res, next) => {
try {
const body = parseBody(accountStartSchema, req)
const result = await service.startAccount(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
created(res, result)
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,59 @@
import { z } from 'zod'
/**
* Minimal signup schema — only asks for what's needed to create an identity.
* See progressive-signup-plan.md Section 3.
*/
export const accountStartSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
})
export const companyProfileSchema = z.object({
firstName: z.string().min(1).max(80).optional(),
lastName: z.string().min(1).max(80).optional(),
firstNameAr: z.string().max(80).optional(),
lastNameAr: z.string().max(80).optional(),
companyName: z.string().min(2).max(120).optional(),
companyNameAr: z.string().max(120).optional(),
phone: z.string().min(1).max(80).optional(),
companyEmail: z.string().email().optional(),
streetAddress: z.string().min(1).max(200).optional(),
streetAddressAr: z.string().max(200).optional(),
city: z.string().min(1).max(120).optional(),
cityAr: z.string().max(120).optional(),
country: z.string().min(1).max(120).optional(),
countryAr: z.string().max(120).optional(),
zipCode: z.string().min(1).max(40).optional(),
fax: z.string().max(80).optional(),
yearsActive: z.string().max(80).optional(),
})
export const legalIdentitySchema = z.object({
legalName: z.string().min(2).max(160),
legalForm: z.string().min(1).max(80),
registrationNumber: z.string().min(1).max(120),
iceNumber: z.string().min(1).max(120),
taxId: z.string().min(1).max(120),
operatingLicenseNumber: z.string().min(1).max(120),
operatingLicenseIssuedAt: z.string().min(1).max(40),
operatingLicenseIssuedBy: z.string().min(1).max(160),
})
export const paymentSetupSchema = z.object({
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
responsibleName: z.string().min(1).max(160),
responsibleRole: z.string().min(1).max(120),
responsibleIdentityNumber: z.string().min(1).max(120),
responsibleQualification: z.string().max(200).optional(),
responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(),
representativeName: z.string().max(160).optional(),
representativeTitle: z.string().max(120).optional(),
})
export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
})
@@ -0,0 +1,85 @@
import bcrypt from 'bcryptjs'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { signActorToken } from '../../security/tokens'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
type AccountStartInput = output<typeof accountStartSchema>
/**
* Minimal account creation — Step 0 of progressive signup.
* Creates a DRAFT company + OWNER employee, logs the user in immediately.
*/
export async function startAccount(body: AccountStartInput) {
if (await repo.findEmployeeByEmail(body.email)) {
throw new AppError('An account with this email already exists', 409, 'email_taken')
}
const slug = `company-${Date.now().toString(36)}`
const passwordHash = await bcrypt.hash(body.password, 12)
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
const trialEndAt = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000)
const company = await tx.company.create({
data: {
name: '',
slug,
email: body.email,
address: {},
status: 'TRIALING',
},
})
await tx.brandSettings.create({
data: {
companyId: company.id,
displayName: body.email.split('@')[0] || 'My Workspace',
subdomain: slug,
defaultLocale: body.preferredLanguage,
defaultCurrency: 'MAD',
},
})
await tx.subscription.create({
data: {
companyId: company.id,
plan: 'STARTER',
billingPeriod: 'MONTHLY',
currency: 'MAD',
status: 'TRIALING',
trialStartAt: now,
trialEndAt,
currentPeriodStart: now,
currentPeriodEnd: trialEndAt,
},
})
const employee = await tx.employee.create({
data: {
companyId: company.id,
clerkUserId: `local_owner_${company.id}`,
firstName: '',
lastName: '',
email: body.email,
passwordHash,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
},
include: { company: true },
})
return employee
})
const token = signActorToken(result.id, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as any,
})
return { token, ...presentEmployeeSession(result as any, token) }
}
@@ -75,6 +75,18 @@ describe('API foundation integration', () => {
expect(res.body.paths).toHaveProperty('/health') expect(res.body.paths).toHaveProperty('/health')
}) })
it('allows dashboard credentialed preflights to employee auth routes', async () => {
const res = await request(app)
.options('/api/v1/auth/employee/menu')
.set('Origin', 'http://localhost:3001')
.set('Access-Control-Request-Method', 'GET')
.set('Access-Control-Request-Headers', 'content-type')
expect(res.status).toBe(204)
expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3001')
expect(res.headers['access-control-allow-credentials']).toBe('true')
})
it('blocks legacy anonymous access to customer identity document storage paths', async () => { it('blocks legacy anonymous access to customer identity document storage paths', async () => {
const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg') const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg')
+44 -5
View File
@@ -27,11 +27,21 @@ const storagePatterns = [
const DASHBOARD_BASE_PATH = '/dashboard' const DASHBOARD_BASE_PATH = '/dashboard'
// In Docker dev the dashboard app runs on port 3001 while the marketplace proxy // basePath is required so the client-side router knows it's mounted at /dashboard.
// is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit // Without it, React cannot hydrate because the URL (/dashboard/sign-up) doesn't
// absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001. // match the route (/sign-up). The Turbopack double-basePath prefetch bug is
const assetPrefix = normalizeAssetPrefix(process.env.DASHBOARD_ASSET_PREFIX, DASHBOARD_BASE_PATH) // handled by the marketplace middleware (307 redirect /dashboard/dashboard → /dashboard).
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] }) //
// In local development the dashboard is often viewed through the marketplace
// proxy on port 3000 while the dashboard dev server runs on port 3001. Next
// rewrites do not reliably proxy HMR WebSocket upgrades, so emit absolute
// dashboard asset/HMR URLs directly to the dashboard dev server.
const dashboardAssetPrefix = process.env.DASHBOARD_ASSET_PREFIX
?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined)
const assetPrefix = normalizeAssetPrefix(dashboardAssetPrefix, DASHBOARD_BASE_PATH)
const securityHeaders = buildSecurityHeaders({
assetSources: [assetPrefix].filter(Boolean),
})
const nextConfig = { const nextConfig = {
basePath: DASHBOARD_BASE_PATH, basePath: DASHBOARD_BASE_PATH,
@@ -48,12 +58,41 @@ const nextConfig = {
transpilePackages: ['@rentaldrivego/types'], transpilePackages: ['@rentaldrivego/types'],
async headers() { async headers() {
return [ return [
{
source: '/_next/static/media/:path*',
headers: [
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
],
},
{ {
source: '/:path*', source: '/:path*',
headers: securityHeaders, headers: securityHeaders,
}, },
] ]
}, },
async redirects() {
return [
{
source: `${DASHBOARD_BASE_PATH}/:path*`,
destination: '/:path*',
permanent: false,
},
{
source: DASHBOARD_BASE_PATH,
destination: '/',
permanent: false,
},
{
source: '/',
destination: DASHBOARD_BASE_PATH,
permanent: false,
basePath: false,
},
]
},
async rewrites() { async rewrites() {
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '') const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
return [ return [
@@ -4,7 +4,7 @@ import TopBar from '@/components/layout/TopBar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return ( return (
<div className="flex min-h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] print:block print:min-h-0 print:bg-white"> <div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
<div className="print:hidden"> <div className="print:hidden">
<Sidebar /> <Sidebar />
</div> </div>
+156 -45
View File
@@ -2,13 +2,25 @@
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp } from 'lucide-react' import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle, Plus, BarChart3, FileText, TrendingUp, MapPin } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts' import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import StatCard from '@/components/ui/StatCard' import StatCard from '@/components/ui/StatCard'
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api' import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { formatCurrency } from '@rentaldrivego/types' import { formatCurrency } from '@rentaldrivego/types'
/**
* Location breakdown item — requires backend field on /analytics/dashboard.
* Flagged as a contract change (not faked).
*/
interface LocationBreakdown {
name: string
bookingCount: number
revenue: number
/** 0100 utilization percentage */
utilizationPct: number
}
interface DashboardData { interface DashboardData {
kpis: { kpis: {
totalBookings: number totalBookings: number
@@ -19,6 +31,12 @@ interface DashboardData {
vehiclesChange: number vehiclesChange: number
revenueChange: number revenueChange: number
customersChange: number customersChange: number
/** Requires backend field — flagged as contract change */
utilizationRate?: number
/** Requires backend field — flagged as contract change */
revenuePerVehicle?: number
/** Requires backend field — flagged as contract change */
fulfillmentRate?: number
} }
recentReservations: { recentReservations: {
id: string id: string
@@ -31,6 +49,8 @@ interface DashboardData {
totalAmount: number totalAmount: number
}[] }[]
sourceBreakdown: { source: string; count: number; revenue: number }[] sourceBreakdown: { source: string; count: number; revenue: number }[]
/** Requires backend field — flagged as contract change */
locationBreakdown?: LocationBreakdown[]
subscription: { subscription: {
status: string status: string
planName: string planName: string
@@ -67,7 +87,7 @@ function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
<Link <Link
key={action.href} key={action.href}
href={action.href} href={action.href}
className={`flex items-center gap-2 px-4 py-3 rounded-lg border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-all ${action.color}`} className={`flex items-center gap-2 rounded-lg border border-blue-400/15 bg-blue-500/[0.06] px-4 py-3 transition-all hover:border-blue-400/35 hover:bg-blue-500/10 ${action.color}`}
> >
{action.icon} {action.icon}
<span className="text-sm font-medium">{action.label}</span> <span className="text-sm font-medium">{action.label}</span>
@@ -102,17 +122,17 @@ function SubscriptionBanner({
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = { const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
TRIALING: { TRIALING: {
bg: 'bg-blue-50 border-blue-200 text-blue-800', bg: 'bg-blue-500/10 border-blue-400/25 text-blue-200',
icon: <Clock className="w-4 h-4" />, icon: <Clock className="w-4 h-4" />,
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg, message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
}, },
PAST_DUE: { PAST_DUE: {
bg: 'bg-orange-50 border-orange-200 text-orange-800', bg: 'bg-orange-500/10 border-orange-400/25 text-orange-200',
icon: <AlertTriangle className="w-4 h-4" />, icon: <AlertTriangle className="w-4 h-4" />,
message: pastDueMsg, message: pastDueMsg,
}, },
SUSPENDED: { SUSPENDED: {
bg: 'bg-red-50 border-red-200 text-red-800', bg: 'bg-red-500/10 border-red-400/25 text-red-200',
icon: <XCircle className="w-4 h-4" />, icon: <XCircle className="w-4 h-4" />,
message: suspendedMsg, message: suspendedMsg,
}, },
@@ -132,11 +152,59 @@ function SubscriptionBanner({
) )
} }
function BranchPerformanceList({
locations,
formatCurrencyFn,
}: {
locations: LocationBreakdown[]
formatCurrencyFn: (amount: number, currency: string) => string
}) {
const maxCount = Math.max(...locations.map((l) => l.bookingCount), 1)
return (
<div className="glass-card p-6">
<div className="mb-4 flex items-center gap-2">
<MapPin className="h-4 w-4 text-orange-700 dark:text-orange-300" />
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">Location Performance</h2>
</div>
<div className="space-y-4">
{locations.map((loc) => (
<div key={loc.name}>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-medium text-blue-950 dark:text-slate-100">{loc.name}</span>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.bookingCount} bookings · {formatCurrencyFn(loc.revenue, 'MAD')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill blue"
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
/>
</div>
<span className="w-8 text-right text-xs font-mono text-slate-500 dark:text-slate-400">
{loc.utilizationPct}%
</span>
</div>
</div>
))}
</div>
</div>
)
}
export default function DashboardPage() { export default function DashboardPage() {
const { dict, language } = useDashboardI18n() const { dict, language, theme } = useDashboardI18n()
const d = dict.dashboardPage const d = dict.dashboardPage
const nav = dict.nav const nav = dict.nav
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US' const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
const isDark = theme === 'dark'
const chartGrid = isDark ? 'rgba(59,130,246,0.14)' : 'rgba(37,99,235,0.14)'
const chartAxis = isDark ? 'rgba(59,130,246,0.20)' : 'rgba(37,99,235,0.22)'
const chartText = isDark ? '#8899b4' : '#64748b'
const chartTooltipBg = isDark ? '#111827' : '#ffffff'
const chartTooltipText = isDark ? '#e8edf5' : '#172554'
const [data, setData] = useState<DashboardData | null>(null) const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -174,7 +242,7 @@ export default function DashboardPage() {
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
return ( return (
<div className="card p-6 text-center"> <div className="card p-6 text-center">
<p className="text-slate-500 text-sm">{errorMsg}</p> <p className="text-slate-500 text-sm dark:text-slate-400">{errorMsg}</p>
</div> </div>
) )
} }
@@ -191,10 +259,10 @@ export default function DashboardPage() {
<div className="space-y-6"> <div className="space-y-6">
{/* Welcome Section */} {/* Welcome Section */}
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 mb-1"> <h1 className="mb-1 text-2xl font-semibold text-blue-950 dark:text-slate-50">
Welcome back{employeeName ? `, ${employeeName}` : ''}! Welcome back{employeeName ? `, ${employeeName}` : ''}!
</h1> </h1>
<p className="text-sm text-slate-600 mb-4"> <p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
Here's what's happening with your fleet today Here's what's happening with your fleet today
</p> </p>
</div> </div>
@@ -206,25 +274,25 @@ export default function DashboardPage() {
icon: <Plus className="w-4 h-4" />, icon: <Plus className="w-4 h-4" />,
label: 'New Reservation', label: 'New Reservation',
href: '/reservations/new', href: '/reservations/new',
color: 'text-blue-600', color: 'text-blue-700 dark:text-blue-300',
}, },
{ {
icon: <Car className="w-4 h-4" />, icon: <Car className="w-4 h-4" />,
label: nav.fleet, label: nav.fleet,
href: '/fleet', href: '/fleet',
color: 'text-green-600', color: 'text-emerald-700 dark:text-emerald-300',
}, },
{ {
icon: <Users className="w-4 h-4" />, icon: <Users className="w-4 h-4" />,
label: nav.customers, label: nav.customers,
href: '/customers', href: '/customers',
color: 'text-purple-600', color: 'text-orange-700 dark:text-orange-300',
}, },
{ {
icon: <FileText className="w-4 h-4" />, icon: <FileText className="w-4 h-4" />,
label: nav.reports, label: nav.reports,
href: '/reports', href: '/reports',
color: 'text-orange-600', color: 'text-orange-700 dark:text-orange-300',
}, },
]} ]}
/> />
@@ -249,8 +317,9 @@ export default function DashboardPage() {
change={kpis.bookingsChange} change={kpis.bookingsChange}
vsLastMonthLabel={d.vsLastMonth} vsLastMonthLabel={d.vsLastMonth}
icon={Calendar} icon={Calendar}
iconColor="text-blue-600" iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-50" iconBg="bg-blue-100/80 dark:bg-blue-500/10"
pulse
/> />
<StatCard <StatCard
title={d.kpiActiveVehicles} title={d.kpiActiveVehicles}
@@ -258,8 +327,8 @@ export default function DashboardPage() {
change={kpis.vehiclesChange} change={kpis.vehiclesChange}
vsLastMonthLabel={d.vsLastMonth} vsLastMonthLabel={d.vsLastMonth}
icon={Car} icon={Car}
iconColor="text-green-600" iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-green-50" iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
/> />
{canViewRevenue ? ( {canViewRevenue ? (
<StatCard <StatCard
@@ -268,8 +337,9 @@ export default function DashboardPage() {
change={kpis.revenueChange} change={kpis.revenueChange}
vsLastMonthLabel={d.vsLastMonth} vsLastMonthLabel={d.vsLastMonth}
icon={DollarSign} icon={DollarSign}
iconColor="text-orange-600" iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-50" iconBg="bg-orange-100/80 dark:bg-orange-500/10"
valueGradient
/> />
) : null} ) : null}
<StatCard <StatCard
@@ -278,27 +348,60 @@ export default function DashboardPage() {
change={kpis.customersChange} change={kpis.customersChange}
vsLastMonthLabel={d.vsLastMonth} vsLastMonthLabel={d.vsLastMonth}
icon={Users} icon={Users}
iconColor="text-purple-600" iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-purple-50" iconBg="bg-blue-100/80 dark:bg-blue-500/10"
/> />
</div> </div>
{/* New metrics row — contract change flag */}
{kpis.utilizationRate !== undefined && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<StatCard
title="Utilization Rate"
value={`${kpis.utilizationRate}%`}
icon={TrendingUp}
iconColor="text-blue-700 dark:text-blue-300"
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
progressPercent={kpis.utilizationRate}
target={100}
/>
<StatCard
title="Revenue / Vehicle"
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
icon={DollarSign}
iconColor="text-orange-700 dark:text-orange-300"
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
/>
<StatCard
title="Fulfillment Rate"
value={`${kpis.fulfillmentRate ?? 0}%`}
icon={BarChart3}
iconColor="text-emerald-700 dark:text-emerald-300"
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
progressPercent={kpis.fulfillmentRate ?? 0}
target={100}
/>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Booking Sources Chart */} {/* Left column — 2/3: Booking Sources + Location Performance */}
<div className="card p-6 lg:col-span-2"> <div className="lg:col-span-2 space-y-6">
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2> <div className="card p-6">
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.bookingSources}</h2>
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? ( {data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<BarChart data={data.sourceBreakdown}> <BarChart data={data.sourceBreakdown}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" /> <CartesianGrid strokeDasharray="3 3" stroke={chartGrid} />
<XAxis dataKey="source" tick={{ fontSize: 12 }} /> <XAxis dataKey="source" tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<YAxis tick={{ fontSize: 12 }} /> <YAxis tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
<Tooltip <Tooltip
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }} contentStyle={{ borderRadius: '8px', border: `1px solid ${chartAxis}`, background: chartTooltipBg, color: chartTooltipText, fontSize: '12px' }}
cursor={{ fill: 'rgba(59,130,246,0.08)' }}
/> />
<Legend wrapperStyle={{ fontSize: '12px' }} /> <Legend wrapperStyle={{ fontSize: '12px', color: chartText }} />
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} /> <Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" fill="#f97316" name={d.barRevenue} radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
@@ -308,15 +411,23 @@ export default function DashboardPage() {
)} )}
</div> </div>
{/* Quick stats */} {data?.locationBreakdown && data.locationBreakdown.length > 0 ? (
<BranchPerformanceList
locations={data.locationBreakdown}
formatCurrencyFn={(a, c) => formatCurrency(a, c as 'MAD')}
/>
) : null}
</div>
{/* Right column — 1/3: Quick Stats */}
<div className="card p-6"> <div className="card p-6">
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2> <h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.quickStats}</h2>
<div className="space-y-4"> <div className="space-y-4">
{(data?.sourceBreakdown ?? []).map((item) => ( {(data?.sourceBreakdown ?? []).map((item) => (
<div key={item.source} className="flex items-center justify-between"> <div key={item.source} className="flex items-center justify-between">
<span className="text-sm text-slate-600 capitalize">{item.source.toLowerCase()}</span> <span className="text-sm capitalize text-slate-500 dark:text-slate-400">{item.source.toLowerCase()}</span>
<div className="text-right"> <div className="text-right">
<span className="text-sm font-semibold text-slate-900">{item.count}</span> <span className="text-sm font-semibold text-blue-950 dark:text-slate-100">{item.count}</span>
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p> <p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
</div> </div>
</div> </div>
@@ -330,16 +441,16 @@ export default function DashboardPage() {
{/* Recent Reservations */} {/* Recent Reservations */}
<div className="card"> <div className="card">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between"> <div className="flex items-center justify-between border-b border-blue-200/70 px-6 py-4 dark:border-blue-400/10">
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2> <h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">{d.recentReservations}</h2>
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium"> <Link href="/reservations" className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
{d.viewAll} {d.viewAll}
</Link> </Link>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-slate-100"> <tr className="border-b border-blue-200/70 dark:border-blue-400/10">
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th> <th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
@@ -348,17 +459,17 @@ export default function DashboardPage() {
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th> <th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-blue-200/70 dark:divide-blue-400/10">
{(data?.recentReservations ?? []).map((res) => ( {(data?.recentReservations ?? []).map((res) => (
<tr key={res.id} className="hover:bg-slate-50 transition-colors"> <tr key={res.id} className="transition-colors hover:bg-blue-500/[0.06]">
<td className="px-6 py-3"> <td className="px-6 py-3">
<Link href={`/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-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
#{res.bookingRef} #{res.bookingRef}
</Link> </Link>
</td> </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 dark:text-slate-300">{res.customerName}</td>
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td> <td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.vehicleName}</td>
<td className="px-6 py-3 text-sm text-slate-500"> <td className="px-6 py-3 text-sm text-slate-500 dark:text-slate-400">
{formatDate(res.startDate)} {formatDateYear(res.endDate)} {formatDate(res.startDate)} {formatDateYear(res.endDate)}
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
@@ -366,7 +477,7 @@ export default function DashboardPage() {
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status} {d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
</span> </span>
</td> </td>
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right"> <td className="px-6 py-3 text-right text-sm font-semibold text-blue-950 dark:text-slate-100">
{formatCurrency(res.totalAmount, 'MAD')} {formatCurrency(res.totalAmount, 'MAD')}
</td> </td>
</tr> </tr>
+239
View File
@@ -17,6 +17,20 @@ html.dark {
--sidebar-bg: #0a1128; --sidebar-bg: #0a1128;
--sidebar-text: #94a3b8; --sidebar-text: #94a3b8;
--sidebar-active: #ffffff; --sidebar-active: #ffffff;
--bg-elevated: rgb(255 255 255 / 0.82);
--border-accent: rgb(231 229 228 / 0.8);
--glass-bg: rgb(255 255 255 / 0.72);
--shadow-card: 0 30px 80px rgba(28, 25, 23, 0.08);
--shadow-glow: 0 0 20px rgba(249, 115, 22, 0.15);
}
html.dark {
--bg-elevated: rgb(11 25 55 / 0.78);
--border-accent: rgb(30 60 140 / 0.40);
--glass-bg: rgb(11 25 55 / 0.65);
--shadow-card: 0 30px 80px rgba(0, 0, 0, 0.36);
--shadow-glow: 0 0 20px rgba(249, 115, 22, 0.25);
} }
@layer base { @layer base {
@@ -41,6 +55,231 @@ html.dark body {
} }
@layer components { @layer components {
.fleet-dashboard-shell {
--fleet-bg-primary: #f5f8ff;
--fleet-bg-elevated: #ffffff;
--fleet-glass-bg: rgb(255 255 255 / 0.82);
--fleet-card-bg: rgb(255 255 255 / 0.72);
--fleet-border-subtle: rgb(37 99 235 / 0.14);
--fleet-border-accent: rgb(249 115 22 / 0.30);
--fleet-text-primary: #172554;
--fleet-text-secondary: #64748b;
--fleet-accent-blue: #2563eb;
--fleet-accent-blue-light: #2563eb;
--fleet-accent-orange: #f97316;
--fleet-accent-orange-light: #ea580c;
--fleet-success: #059669;
--fleet-danger: #dc2626;
background:
radial-gradient(ellipse at 20% 20%, rgb(37 99 235 / 0.10) 0%, transparent 56%),
radial-gradient(ellipse at 82% 72%, rgb(249 115 22 / 0.08) 0%, transparent 54%),
linear-gradient(180deg, #ffffff 0%, #f5f8ff 35%, #edf4ff 100%);
color: var(--fleet-text-primary);
}
html.dark .fleet-dashboard-shell {
--fleet-bg-primary: #0a0f1a;
--fleet-bg-elevated: #111827;
--fleet-glass-bg: rgb(15 25 45 / 0.66);
--fleet-card-bg: rgb(30 58 95 / 0.28);
--fleet-border-subtle: rgb(59 130 246 / 0.14);
--fleet-text-primary: #e8edf5;
--fleet-text-secondary: #8899b4;
--fleet-accent-blue: #3b82f6;
--fleet-accent-blue-light: #60a5fa;
--fleet-accent-orange-light: #fb923c;
--fleet-success: #10b981;
--fleet-danger: #ef4444;
background:
radial-gradient(ellipse at 20% 20%, rgb(59 130 246 / 0.09) 0%, transparent 58%),
radial-gradient(ellipse at 82% 72%, rgb(249 115 22 / 0.08) 0%, transparent 56%),
linear-gradient(180deg, #0a0f1a 0%, #0d1728 46%, #08111f 100%);
}
.fleet-dashboard-shell * {
scrollbar-width: thin;
scrollbar-color: rgb(59 130 246 / 0.22) transparent;
}
.fleet-dashboard-shell *::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.fleet-dashboard-shell *::-webkit-scrollbar-track {
background: transparent;
}
.fleet-dashboard-shell *::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgb(59 130 246 / 0.22);
}
.fleet-dashboard-shell .card,
.fleet-dashboard-shell .glass-card {
border-color: var(--fleet-border-subtle);
background: var(--fleet-glass-bg);
box-shadow: 0 18px 60px rgb(15 23 42 / 0.08);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
@apply rounded-xl border transition-colors;
}
.fleet-dashboard-shell .card:hover,
.fleet-dashboard-shell .glass-card:hover {
border-color: rgb(59 130 246 / 0.24);
box-shadow: 0 18px 60px rgb(15 23 42 / 0.10), 0 0 20px rgb(59 130 246 / 0.10);
}
html.dark .fleet-dashboard-shell .card,
html.dark .fleet-dashboard-shell .glass-card {
box-shadow: 0 18px 60px rgb(0 0 0 / 0.26);
}
html.dark .fleet-dashboard-shell .card:hover,
html.dark .fleet-dashboard-shell .glass-card:hover {
box-shadow: 0 18px 60px rgb(0 0 0 / 0.26), 0 0 20px rgb(59 130 246 / 0.12);
}
.fleet-dashboard-shell .btn-primary {
position: relative;
overflow: hidden;
box-shadow: 0 2px 10px rgb(59 130 246 / 0.30);
@apply rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 text-white hover:from-blue-400 hover:to-blue-600;
}
.fleet-dashboard-shell .btn-secondary {
border-color: rgb(59 130 246 / 0.18);
background: rgb(59 130 246 / 0.08);
color: var(--fleet-text-primary);
@apply rounded-lg hover:border-blue-400/40 hover:bg-blue-500/15 hover:text-white;
}
.fleet-dashboard-shell .input-field {
border-color: rgb(59 130 246 / 0.14);
background: rgb(59 130 246 / 0.07);
color: var(--fleet-text-primary);
@apply rounded-lg placeholder:text-slate-500 focus:border-blue-400 focus:ring-blue-500/20;
}
.fleet-dashboard-shell .progress-bar {
height: 6px;
background: rgb(59 130 246 / 0.12);
@apply rounded-full;
}
.fleet-dashboard-shell .progress-fill.blue {
@apply bg-gradient-to-r from-blue-500 to-blue-400;
}
.fleet-dashboard-shell .progress-fill.orange {
@apply bg-gradient-to-r from-orange-500 to-orange-600;
}
.fleet-dashboard-shell .badge-blue {
border: 1px solid rgb(59 130 246 / 0.30);
background: rgb(59 130 246 / 0.15);
color: var(--fleet-accent-blue-light);
}
.fleet-dashboard-shell .badge-amber {
border: 1px solid rgb(249 115 22 / 0.30);
background: rgb(249 115 22 / 0.15);
color: var(--fleet-accent-orange-light);
}
.fleet-dashboard-shell .badge-green {
border: 1px solid rgb(16 185 129 / 0.28);
background: rgb(16 185 129 / 0.13);
color: #047857;
}
.fleet-dashboard-shell .badge-red {
border: 1px solid rgb(239 68 68 / 0.30);
background: rgb(239 68 68 / 0.13);
color: #dc2626;
}
.fleet-dashboard-shell .badge-gray {
border: 1px solid rgb(148 163 184 / 0.20);
background: rgb(148 163 184 / 0.10);
color: #475569;
}
html.dark .fleet-dashboard-shell .badge-green {
color: #6ee7b7;
}
html.dark .fleet-dashboard-shell .badge-red {
color: #fca5a5;
}
html.dark .fleet-dashboard-shell .badge-gray {
color: #cbd5e1;
}
.glass-card {
border-color: var(--border-accent);
background-color: var(--glass-bg);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
@apply rounded-[1.75rem] border transition-colors;
}
html.dark .glass-card {
border-color: rgb(30 60 140 / 0.40);
background-color: rgb(11 25 55 / 0.65);
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.36);
}
.progress-bar {
@apply h-2 w-full overflow-hidden rounded-full;
background-color: rgb(231 229 228 / 0.5);
}
html.dark .progress-bar {
background-color: rgb(30 60 140 / 0.3);
}
.progress-fill {
@apply h-full rounded-full transition-all duration-500;
}
.progress-fill.blue {
@apply bg-gradient-to-r from-blue-500 to-blue-400;
}
.progress-fill.orange {
@apply bg-gradient-to-r from-orange-500 to-orange-400;
}
html.dark .progress-fill.blue {
@apply bg-gradient-to-r from-blue-400 to-blue-300;
}
html.dark .progress-fill.orange {
@apply bg-gradient-to-r from-orange-400 to-orange-300;
}
/* Pulse glow animation for live indicators */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 4px rgba(249, 115, 22, 0.3); }
50% { box-shadow: 0 0 12px rgba(249, 115, 22, 0.6); }
}
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.pulse-glow,
.progress-fill {
animation: none !important;
transition: none !important;
}
}
.btn-primary { .btn-primary {
@apply inline-flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-400; @apply inline-flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-orange-500 dark:hover:bg-orange-400;
} }
+13 -1
View File
@@ -1,12 +1,24 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { JetBrains_Mono } from 'next/font/google'
import { DashboardI18nProvider } from '@/components/I18nProvider' import { DashboardI18nProvider } from '@/components/I18nProvider'
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences' import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
import './globals.css' import './globals.css'
const jetbrainsMono = JetBrains_Mono({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
})
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard', title: 'RentalDriveGo Dashboard',
description: 'Manage your rental car business', description: 'Manage your rental car business',
icons: {
icon: '/dashboard/icon.svg',
shortcut: '/dashboard/icon.svg',
},
} }
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' { function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
@@ -30,7 +42,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
}} }}
/> />
</head> </head>
<body className="font-sans"> <body className={`font-sans ${jetbrainsMono.variable}`}>
<DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider> <DashboardI18nProvider initialLanguage={initialLanguage}>{children}</DashboardI18nProvider>
</body> </body>
</html> </html>
@@ -0,0 +1,199 @@
'use client'
import Image from 'next/image'
import { useState } from 'react'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const dict = {
en: {
title: 'Create your workspace',
subtitle: 'Enter your email and password to get started. You can complete your profile after signing in.',
email: 'Email',
password: 'Password',
languageLabel: 'Preferred language',
create: 'Create workspace',
creating: 'Creating\u2026',
alreadyHave: 'Already have an account?',
signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.',
},
fr: {
title: 'Cr\u00e9ez votre espace',
subtitle: 'Saisissez votre email et mot de passe pour commencer. Vous pourrez compl\u00e9ter votre profil apr\u00e8s la connexion.',
email: 'Email',
password: 'Mot de passe',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026',
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
},
ar: {
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621. \u064a\u0645\u0643\u0646\u0643 \u0625\u0643\u0645\u0627\u0644 \u0645\u0644\u0641\u0643 \u0627\u0644\u0634\u062e\u0635\u064a \u0628\u0639\u062f \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
},
}[preferredLanguage]
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await apiFetch<{ token: string }>('/auth/account/start', {
method: 'POST',
body: JSON.stringify({ email, password, preferredLanguage }),
})
setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
if (typeof window !== 'undefined' && res.token) {
window.location.href = toPublicDashboardPath('/')
}
} catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
setError(dict.errorEmailTaken)
} else {
setError(dict.errorGeneric)
}
} finally {
setLoading(false)
}
}
return (
<PublicShell>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
width={80}
height={80}
priority
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
/>
</a>
</div>
<h1 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.title}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
{error ? (
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email} <span className="text-red-500">*</span>
</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
autoFocus
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.password} <span className="text-red-500">*</span>
</label>
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
/>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">Minimum 8 characters</p>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.languageLabel} <span className="text-red-500">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setPreferredLanguage(lang)}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
preferredLanguage === lang
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
}`}
>
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
</button>
))}
</div>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{loading ? dict.creating : dict.create}
</button>
</form>
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
{dict.alreadyHave}{' '}
<a
href="/sign-in"
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
>
{dict.signIn}
</a>
</p>
</section>
</div>
</main>
</PublicShell>
)
}
@@ -1,936 +1,10 @@
'use client' import { Suspense } from 'react'
import SignUpForm from './SignUpForm'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { getCurrencyLabel } from '@rentaldrivego/types'
import { useDashboardI18n } from '@/components/I18nProvider'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
import { BilingualField, BilingualInput, emptyBilingual } from '@/components/ui/BilingualInput'
type SignupForm = {
firstName: BilingualField
lastName: BilingualField
email: string
password: string
confirmPassword: string
preferredLanguage: 'en' | 'fr' | 'ar' | ''
companyName: BilingualField
legalName: string
legalForm: string
registrationNumber: string
iceNumber: string
taxId: string
operatingLicenseNumber: string
operatingLicenseIssuedAt: string
operatingLicenseIssuedBy: string
streetAddress: BilingualField
city: BilingualField
country: BilingualField
zipCode: string
companyPhone: string
companyEmail: string
fax: string
yearsActive: string
representativeName: string
representativeTitle: string
responsibleName: string
responsibleRole: string
responsibleIdentityNumber: string
responsibleQualification: string
responsiblePhone: string
responsibleEmail: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}
type CompletedSignup = {
companyName: string
email: string
emailWarning?: string | null
}
type SignupResponse = {
companyId: string
companyName: string
slug: string
trialEndsAt: string | null
nextStep: string
emailDelivery?: {
attempted: boolean
success: boolean
error: string | null
} | null
}
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function SignUpPage() { export default function SignUpPage() {
const { language, setLanguage } = useDashboardI18n()
const searchParams = useSearchParams()
const initialPlan = normalizePlan(searchParams.get('plan'))
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
const [step, setStep] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
const [form, setForm] = useState<SignupForm>({
firstName: emptyBilingual(),
lastName: emptyBilingual(),
email: '',
password: '',
confirmPassword: '',
preferredLanguage: '',
companyName: emptyBilingual(),
legalName: '',
legalForm: '',
registrationNumber: '',
iceNumber: '',
taxId: '',
operatingLicenseNumber: '',
operatingLicenseIssuedAt: '',
operatingLicenseIssuedBy: '',
streetAddress: emptyBilingual(),
city: emptyBilingual(),
country: emptyBilingual(),
zipCode: '',
companyPhone: '',
companyEmail: '',
fax: '',
yearsActive: '',
representativeName: '',
representativeTitle: '',
responsibleName: '',
responsibleRole: '',
responsibleIdentityNumber: '',
responsibleQualification: '',
responsiblePhone: '',
responsibleEmail: '',
plan: initialPlan,
billingPeriod: initialBillingPeriod,
currency: initialCurrency,
paymentProvider: 'AMANPAY',
})
const isArabic = language === 'ar'
const dict = {
en: {
brand: 'RentalDriveGo',
pageTitle: 'Launch your rental workspace',
pageSubtitle: 'Create the user account, complete the company information, and launch your workspace.',
steps: ['Account User', 'Company info', 'Plan', 'Verify'],
ownerTitle: 'Account User',
ownerBody: 'Create the primary user account that will manage this company workspace.',
partnerTitle: 'Company info',
partnerBody: 'Complete the company information required to create the workspace.',
planTitle: 'Choose your plan',
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 90-day trial with the plan you selected.',
firstName: 'Manager/Owner first name',
lastName: 'Manager/Owner last name',
ownerEmail: 'Manager/Owner email',
emailReuseHint: 'You can reuse the same email for the owner, company, and responsible person.',
password: 'Password',
confirmPassword: 'Confirm password',
passwordHint: 'Use at least 8 characters for your company owner account.',
companyName: 'Commercial name',
legalName: 'Legal company name',
legalForm: 'Legal form',
registrationNumber: 'Commercial Registry (RC)',
iceNumber: 'ICE number',
taxId: 'Fiscal ID (IF)',
operatingLicenseNumber: 'Operating license number',
operatingLicenseIssuedAt: 'License issue date',
operatingLicenseIssuedBy: 'Issuing authority',
streetAddress: 'Street Address',
city: 'City',
country: 'Country',
zipCode: 'Zip code',
companyPhone: 'Phone',
companyEmail: 'Company contact email',
fax: 'Fax (optional)',
yearsActive: 'Years active',
representativeName: 'Legal representative name',
representativeTitle: 'Legal representative title',
responsibleName: 'Responsible person name',
responsibleRole: 'Responsible person role',
responsibleIdentityNumber: 'Responsible person ID number',
responsibleQualification: 'Qualification / diploma / experience',
responsiblePhone: 'Responsible person phone',
responsibleEmail: 'Responsible person email',
identitySection: 'Legal identity',
contactSection: 'Company contact',
representativeSection: 'Legal representative',
responsibleSection: 'Responsible person',
continue: 'Continue',
back: 'Back',
working: 'Working…',
createWorkspace: 'Create workspace',
workspaceReady: 'Workspace ready',
workspaceReadyBody: 'is now active and the owner email',
enterDashboard: 'Enter dashboard',
signInAnotherDevice: 'Sign in on another device',
reviewWorkspace: 'Workspace',
reviewPlan: 'Plan',
reviewOwnerEmail: 'Owner email',
reviewCompanyEmail: 'Company email',
reviewPhone: 'Phone',
reviewLegalName: 'Legal name',
reviewRegistration: 'RC / ICE / IF',
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: {
'': 'Select legal form',
SARL: 'SARL',
'SARL AU': 'SARL AU',
SA: 'SA',
SAS: 'SAS',
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
EI: 'Sole proprietorship',
OTHER: 'Other',
} as Record<string, string>,
yearsActiveOptions: {
'': 'Select years active',
LESS_THAN_1: 'Less than 1 year',
ONE_TO_FIVE: '1 to 5 years',
MORE_THAN_5: 'More than 5 years',
} as Record<string, string>,
planDescriptions: {
STARTER: 'Core fleet, offers, and bookings.',
GROWTH: 'Featured marketplace placement and more room to scale.',
PRO: 'Full white-label and premium controls.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'Monthly',
ANNUAL: 'Annual',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
fr: {
brand: 'RentalDriveGo',
pageTitle: 'Lancez votre espace de location',
pageSubtitle: 'Créez le compte utilisateur, complétez les informations de lentreprise et lancez votre espace.',
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérification'],
ownerTitle: 'Compte utilisateur',
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
partnerTitle: 'Infos entreprise',
partnerBody: 'Complétez les informations de lentreprise requises pour créer lespace.',
planTitle: 'Choisissez votre formule',
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
reviewTitle: 'Vérification et lancement',
reviewBody: 'Nous allons créer immédiatement lespace entreprise et démarrer lessai de 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',
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, lentreprise et le responsable.',
password: 'Mot de passe',
confirmPassword: 'Confirmer le mot de passe',
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
companyName: 'Nom commercial',
legalName: 'Raison sociale',
legalForm: 'Forme juridique',
registrationNumber: 'Registre du commerce (RC)',
iceNumber: 'Numéro ICE',
taxId: 'Identifiant fiscal (IF)',
operatingLicenseNumber: 'Numéro dagrément',
operatingLicenseIssuedAt: 'Date de délivrance',
operatingLicenseIssuedBy: 'Autorité délivrante',
streetAddress: 'Adresse',
city: 'Ville',
country: 'Pays',
zipCode: 'Code postal',
companyPhone: 'Téléphone',
companyEmail: 'E-mail de contact de lentreprise',
fax: 'Fax (optionnel)',
yearsActive: 'Années dactivité',
representativeName: 'Nom et prénom du représentant',
representativeTitle: 'Fonction du représentant',
responsibleName: 'Nom et prénom du responsable',
responsibleRole: 'Qualité du responsable',
responsibleIdentityNumber: 'N° de pièce didentité',
responsibleQualification: 'Qualification / diplôme / expérience',
responsiblePhone: 'Téléphone du responsable',
responsibleEmail: 'E-mail du responsable',
identitySection: 'Identité juridique',
contactSection: 'Coordonnées de lentreprise',
representativeSection: 'Représentant légal',
responsibleSection: 'Responsable désigné',
continue: 'Continuer',
back: 'Retour',
working: 'Traitement…',
createWorkspace: 'Créer lespace',
workspaceReady: 'Espace prêt',
workspaceReadyBody: 'est maintenant actif et le-mail propriétaire',
enterDashboard: 'Accéder au tableau de bord',
signInAnotherDevice: 'Se connecter sur un autre appareil',
reviewWorkspace: 'Espace',
reviewPlan: 'Formule',
reviewOwnerEmail: 'E-mail propriétaire',
reviewCompanyEmail: 'E-mail entreprise',
reviewPhone: 'Téléphone',
reviewLegalName: 'Raison sociale',
reviewRegistration: 'RC / ICE / IF',
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: {
'': 'Sélectionner la forme juridique',
SARL: 'SARL',
'SARL AU': 'SARL AU',
SA: 'SA',
SAS: 'SAS',
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
EI: 'Entreprise individuelle',
OTHER: 'Autre',
} as Record<string, string>,
yearsActiveOptions: {
'': 'Sélectionner les années dactivité',
LESS_THAN_1: 'Moins de 1 an',
ONE_TO_FIVE: '1 an à 5 ans',
MORE_THAN_5: 'Plus de 5 ans',
} as Record<string, string>,
planDescriptions: {
STARTER: 'Flotte, offres et réservations essentielles.',
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
PRO: 'White-label complet et contrôles premium.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'Mensuel',
ANNUAL: 'Annuel',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
ar: {
brand: 'RentalDriveGo',
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
ownerTitle: 'حساب المستخدم',
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
partnerTitle: 'معلومات الشركة',
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
planTitle: 'اختر خطتك',
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
reviewTitle: 'راجع وأطلق',
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
firstName: 'الاسم الأول للمدير/المالك',
lastName: 'اسم العائلة للمدير/المالك',
ownerEmail: 'بريد المدير/المالك الإلكتروني',
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
password: 'كلمة المرور',
confirmPassword: 'تأكيد كلمة المرور',
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
companyName: 'الاسم التجاري',
legalName: 'الاسم القانوني للشركة',
legalForm: 'الشكل القانوني',
registrationNumber: 'السجل التجاري (RC)',
iceNumber: 'رقم ICE',
taxId: 'المعرف الضريبي (IF)',
operatingLicenseNumber: 'رقم الترخيص',
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
operatingLicenseIssuedBy: 'الجهة المانحة',
streetAddress: 'عنوان الشارع',
city: 'المدينة',
country: 'الدولة',
zipCode: 'الرمز البريدي',
companyPhone: 'الهاتف',
companyEmail: 'بريد الشركة للتواصل',
fax: 'الفاكس (اختياري)',
yearsActive: 'سنوات النشاط',
representativeName: 'اسم الممثل القانوني',
representativeTitle: 'صفة الممثل القانوني',
responsibleName: 'اسم المسؤول',
responsibleRole: 'صفة المسؤول',
responsibleIdentityNumber: 'رقم وثيقة الهوية',
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
responsiblePhone: 'هاتف المسؤول',
responsibleEmail: 'بريد المسؤول الإلكتروني',
identitySection: 'الهوية القانونية',
contactSection: 'بيانات الشركة',
representativeSection: 'الممثل القانوني',
responsibleSection: 'الشخص المسؤول',
continue: 'متابعة',
back: 'رجوع',
working: 'جارٍ التنفيذ…',
createWorkspace: 'إنشاء المساحة',
workspaceReady: 'المساحة جاهزة',
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
enterDashboard: 'الدخول إلى لوحة التحكم',
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
reviewWorkspace: 'المساحة',
reviewPlan: 'الخطة',
reviewOwnerEmail: 'بريد المالك',
reviewCompanyEmail: 'بريد الشركة',
reviewPhone: 'الهاتف',
reviewLegalName: 'الاسم القانوني',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
missingRequired: 'أكمل جميع الحقول الإلزامية.',
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
preferredLanguageLabel: 'اللغة المفضلة',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'شركتك',
couldNotCreate: 'تعذر إنشاء المساحة',
legalFormOptions: {
'': 'اختر الشكل القانوني',
SARL: 'شركة ذات مسؤولية محدودة',
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
SA: 'شركة مساهمة',
SAS: 'شركة مساهمة مبسطة',
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
EI: 'مؤسسة فردية',
OTHER: 'أخرى',
} as Record<string, string>,
yearsActiveOptions: {
'': 'اختر سنوات النشاط',
LESS_THAN_1: 'أقل من سنة',
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
MORE_THAN_5: 'أكثر من 5 سنوات',
} as Record<string, string>,
planDescriptions: {
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'شهري',
ANNUAL: 'سنوي',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
}[language]
function handleStep1Continue() {
if (!form.preferredLanguage) {
setError(dict.missingLanguage)
return
}
const ownerEmail = normalizeEmail(form.email)
setForm((current) => ({
...current,
companyEmail: current.companyEmail || ownerEmail,
responsibleEmail: current.responsibleEmail || ownerEmail,
}))
setError(null)
setStep(2)
}
function handleStep2Continue() {
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setError(null)
setStep(3)
}
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.invalidPassword)
return
}
if (form.password !== form.confirmPassword) {
setError(dict.passwordMismatch)
return
}
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setLoading(true)
setError(null)
try {
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
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,
preferredLanguage: form.preferredLanguage || 'en',
companyName: form.companyName.fr || form.companyName.ar,
legalName: form.legalName,
companyNameAr: form.companyName.ar || undefined,
legalForm: form.legalForm,
registrationNumber: form.registrationNumber,
iceNumber: form.iceNumber,
taxId: form.taxId,
operatingLicenseNumber: form.operatingLicenseNumber,
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
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,
companyEmail: form.companyEmail,
fax: form.fax || undefined,
yearsActive: form.yearsActive,
representativeName: form.representativeName || undefined,
representativeTitle: form.representativeTitle || undefined,
responsibleName: form.responsibleName,
responsibleRole: form.responsibleRole,
responsibleIdentityNumber: form.responsibleIdentityNumber,
responsibleQualification: form.responsibleQualification || undefined,
responsiblePhone: form.responsiblePhone,
responsibleEmail: form.responsibleEmail,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
}),
})
setCompletedSignup({
companyName: form.companyName.fr || form.companyName.ar,
email: form.email,
emailWarning: getEmailWarning(response.emailDelivery, language),
})
} catch (err) {
setError(getErrorMessage(err, dict.couldNotCreate))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return ( return (
<PublicShell> <Suspense fallback={null}>
<main className="flex-1 px-4 py-16"> <SignUpForm />
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm"> </Suspense>
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
<span className="font-semibold text-slate-900"> {completedSignup.email}</span>.
</p>
{completedSignup.emailWarning ? (
<div className="mt-6 rounded-2xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
{completedSignup.emailWarning}
</div>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<Link href="/" className="btn-primary justify-center">
{dict.enterDashboard}
</Link>
<a href="/sign-in" className="btn-secondary justify-center">
{dict.signInAnotherDevice}
</a>
</div>
</div>
</main>
</PublicShell>
)
}
return (
<PublicShell>
<main className="px-4 py-12">
<div className="mx-auto max-w-4xl space-y-8">
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
/>
</a>
</div>
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
</div>
<div className="grid gap-3 sm:grid-cols-4">
{dict.steps.map((label, index) => (
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-blue-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
{label}
</div>
))}
</div>
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
{step === 1 ? (
<div className="space-y-5">
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
<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) }))} />
<p className="text-xs text-slate-500">{dict.emailReuseHint}</p>
<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-blue-900 bg-blue-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={handleStep1Continue} className="btn-primary">{dict.continue}</button>
</div>
</div>
) : null}
{step === 2 ? (
<div className="space-y-5">
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
<div className="space-y-4">
<FormSubsection title={dict.identitySection} />
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<LabeledInput label={dict.legalName} required maxLength={160} value={form.legalName} onChange={(value) => setForm((current) => ({ ...current, legalName: normalizeTrimmed(value) }))} />
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeUpper(value) }))} />
<LabeledInput label={dict.iceNumber} required maxLength={120} value={form.iceNumber} onChange={(value) => setForm((current) => ({ ...current, iceNumber: normalizeUpper(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.taxId} required maxLength={120} value={form.taxId} onChange={(value) => setForm((current) => ({ ...current, taxId: normalizeUpper(value) }))} />
<LabeledInput label={dict.operatingLicenseNumber} required maxLength={120} value={form.operatingLicenseNumber} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseNumber: normalizeUpper(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.operatingLicenseIssuedAt} required type="date" value={form.operatingLicenseIssuedAt} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedAt: value }))} />
<LabeledInput label={dict.operatingLicenseIssuedBy} required maxLength={160} value={form.operatingLicenseIssuedBy} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedBy: normalizeTrimmed(value) }))} />
</div>
<FormSubsection title={dict.contactSection} />
<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.companyEmail} required type="email" maxLength={254} value={form.companyEmail} onChange={(value) => setForm((current) => ({ ...current, companyEmail: normalizeEmail(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: 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">
<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() }))} />
<FormSubsection title={dict.representativeSection} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.representativeName} maxLength={160} value={form.representativeName} onChange={(value) => setForm((current) => ({ ...current, representativeName: normalizeTrimmed(value) }))} />
<LabeledInput label={dict.representativeTitle} maxLength={120} value={form.representativeTitle} onChange={(value) => setForm((current) => ({ ...current, representativeTitle: normalizeTrimmed(value) }))} />
</div>
<FormSubsection title={dict.responsibleSection} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsibleName} required maxLength={160} value={form.responsibleName} onChange={(value) => setForm((current) => ({ ...current, responsibleName: normalizeTrimmed(value) }))} />
<LabeledInput label={dict.responsibleRole} required maxLength={120} value={form.responsibleRole} onChange={(value) => setForm((current) => ({ ...current, responsibleRole: normalizeTrimmed(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsibleIdentityNumber} required maxLength={120} value={form.responsibleIdentityNumber} onChange={(value) => setForm((current) => ({ ...current, responsibleIdentityNumber: normalizeUpper(value) }))} />
<LabeledInput label={dict.responsibleQualification} maxLength={200} value={form.responsibleQualification} onChange={(value) => setForm((current) => ({ ...current, responsibleQualification: normalizeTrimmed(value) }))} />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.responsiblePhone} required maxLength={80} value={form.responsiblePhone} onChange={(value) => setForm((current) => ({ ...current, responsiblePhone: value }))} />
<LabeledInput label={dict.responsibleEmail} required type="email" maxLength={254} value={form.responsibleEmail} onChange={(value) => setForm((current) => ({ ...current, responsibleEmail: normalizeEmail(value) }))} />
</div>
</div>
<NavActions onBack={() => setStep(1)} onNext={handleStep2Continue} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
</div>
) : null}
{step === 3 ? (
<div className="space-y-5">
<SectionIntro title={dict.planTitle} body={dict.planBody} />
<div className="grid gap-4 sm:grid-cols-3">
{(['STARTER', 'GROWTH', 'PRO'] as const).map((plan) => (
<button
key={plan}
type="button"
onClick={() => setForm((current) => ({ ...current, plan }))}
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-blue-900 bg-blue-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
>
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
<p className="mt-3 text-sm opacity-80">{dict.planDescriptions[plan]}</p>
</button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
</div>
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
</div>
) : null}
{step === 4 ? (
<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.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.reviewLegalName}:</span> {form.legalName || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {getCurrencyLabel(language)}</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.reviewCompanyEmail}:</span> {form.companyEmail || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewRegistration}:</span> {form.registrationNumber || '—'} / {form.iceNumber || '—'} / {form.taxId || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel={dict.createWorkspace}
backLabel={dict.back}
loadingLabel={dict.working}
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
) )
} }
function hasRequiredPartnerFields(form: SignupForm) {
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.legalName,
form.legalForm,
form.registrationNumber,
form.iceNumber,
form.taxId,
form.operatingLicenseNumber,
form.operatingLicenseIssuedAt,
form.operatingLicenseIssuedBy,
form.zipCode,
form.companyPhone,
form.companyEmail,
form.yearsActive,
form.responsibleName,
form.responsibleRole,
form.responsibleIdentityNumber,
form.responsiblePhone,
form.responsibleEmail,
]
.every((v) => v.trim().length > 0)
)
}
function SectionIntro({ title, body }: { title: string; body: string }) {
return (
<div>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
</div>
)
}
function FormSubsection({ title }: { title: string }) {
return <h3 className="pt-2 text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</h3>
}
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
if (!emailDelivery) return null
if (emailDelivery.success) return null
if (emailDelivery.attempted) {
if (language === 'fr') return `Le compte a été créé, mais le-mail de confirmation na pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
}
if (language === 'fr') return 'Le compte a été créé, mais lenvoi de-mails nest pas encore configuré sur cet environnement.'
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
return 'The account was created, but email sending is not configured on this environment yet.'
}
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
return fallback
}
function normalizeEmail(value: string) {
return value.replace(/\s+/g, '').toLowerCase()
}
function normalizeTrimmed(value: string) {
return value.replace(/^\s+/, '')
}
function normalizeUpper(value: string) {
return value.replace(/^\s+/, '').toUpperCase()
}
function LabeledInput({
label,
value,
onChange,
type = 'text',
required = false,
maxLength,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
required?: boolean
maxLength?: number
}) {
return (
<label className="block">
<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>
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function LabeledSelect({
label,
value,
options,
onChange,
labels,
required = false,
}: {
label: string
value: string
options: readonly string[]
onChange: (value: string) => void
labels?: Record<string, string>
required?: boolean
}) {
return (
<label className="block">
<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>
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
))}
</select>
</label>
)
}
function NavActions({
onBack,
onNext,
loading = false,
nextLabel = 'Continue',
backLabel = 'Back',
loadingLabel = 'Working…',
}: {
onBack?: () => void
onNext: () => void
loading?: boolean
nextLabel?: string
backLabel?: string
loadingLabel?: string
}) {
return (
<div className="flex gap-3">
{onBack ? (
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
{backLabel}
</button>
) : null}
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{loading ? loadingLabel : nextLabel}
</button>
</div>
)
}
function normalizePlan(value: string | null): SignupForm['plan'] {
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
return 'STARTER'
}
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
}
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
return 'MAD'
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => {
it('normalizes dashboard-prefixed menu routes before checking access', () => {
const routes = flattenInternalRoutes([
{
id: 'dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard',
children: [],
},
{
id: 'fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard/fleet',
children: [],
},
{
id: 'external',
itemType: 'EXTERNAL_LINK',
routeOrUrl: '/dashboard/dashboard',
children: [],
},
])
expect(routes).toEqual(['/', '/fleet'])
expect(isAllowedRoute('/', routes)).toBe(true)
expect(isAllowedRoute('/fleet/123', routes)).toBe(true)
})
it('does not redirect forever when an authenticated employee has no visible menu routes', () => {
expect(resolveAccessRedirect('/', [])).toBeNull()
expect(resolveAccessRedirect('/fleet', [])).toBeNull()
})
it('redirects disallowed routes to the first visible internal route', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
})
})
@@ -3,7 +3,7 @@
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { apiFetch } from '@/lib/api' import { apiFetch } from '@/lib/api'
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths' import { toDashboardAppPath } from '@/lib/dashboardPaths'
type GeneratedMenuItem = { type GeneratedMenuItem = {
id: string id: string
@@ -16,27 +16,35 @@ type EmployeeMenuResponse = {
items: GeneratedMenuItem[] items: GeneratedMenuItem[]
} }
function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] { export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
const routes: string[] = [] const routes: string[] = []
const walk = (entry: GeneratedMenuItem) => { const walk = (entry: GeneratedMenuItem) => {
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) { if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
routes.push(entry.routeOrUrl) routes.push(toDashboardAppPath(entry.routeOrUrl))
} }
entry.children.forEach(walk) entry.children.forEach(walk)
} }
items.forEach(walk) items.forEach(walk)
return routes return Array.from(new Set(routes))
} }
function isAllowedRoute(currentPath: string, allowedRoutes: string[]) { export function isAllowedRoute(currentPath: string, allowedRoutes: string[]) {
return allowedRoutes.some((route) => { return allowedRoutes.some((route) => {
if (route === '/') return currentPath === '/' if (route === '/') return currentPath === '/'
return currentPath === route || currentPath.startsWith(`${route}/`) return currentPath === route || currentPath.startsWith(`${route}/`)
}) })
} }
export function resolveAccessRedirect(currentPath: string, allowedRoutes: string[]) {
if (allowedRoutes.length === 0) return null
if (isAllowedRoute(currentPath, allowedRoutes)) return null
const fallbackRoute = allowedRoutes[0] ?? '/'
return fallbackRoute === currentPath ? null : fallbackRoute
}
export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) { export default function DashboardAccessGuard({ children }: { children: React.ReactNode }) {
const pathname = usePathname() const pathname = usePathname()
const router = useRouter() const router = useRouter()
@@ -53,10 +61,10 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
if (cancelled) return if (cancelled) return
const allowedRoutes = flattenInternalRoutes(menu.items) const allowedRoutes = flattenInternalRoutes(menu.items)
const fallbackRoute = allowedRoutes[0] ?? '/' const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
if (!isAllowedRoute(currentPath, allowedRoutes)) { if (redirectPath) {
router.replace(toPublicDashboardPath(fallbackRoute)) router.replace(redirectPath)
return return
} }
@@ -65,7 +73,7 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
if (cancelled) return if (cancelled) return
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) { if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
router.replace(toPublicDashboardPath('/')) router.replace('/')
return return
} }
@@ -2,7 +2,6 @@
import { ChevronDown } from 'lucide-react' import { ChevronDown } from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
@@ -102,7 +101,7 @@ export default function PublicHeader() {
const signUpParams = new URLSearchParams() const signUpParams = new URLSearchParams()
signUpParams.set('lang', language) signUpParams.set('lang', language)
signUpParams.set('theme', theme) signUpParams.set('theme', theme)
const signUpHref = `/sign-up?${signUpParams.toString()}` const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
return ( return (
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]"> <header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
@@ -130,9 +129,9 @@ export default function PublicHeader() {
<a href={signInHref} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm"> <a href={signInHref} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
{dict.signIn} {dict.signIn}
</a> </a>
<Link href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm"> <a href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
{dict.createAccount} {dict.createAccount}
</Link> </a>
</nav> </nav>
<div className="shrink-0"> <div className="shrink-0">
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1"> <div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { hasRenderableMenuItems } from './Sidebar'
describe('Sidebar menu rendering helpers', () => {
it('treats an empty employee menu as non-renderable so fallback navigation remains available', () => {
expect(hasRenderableMenuItems(null)).toBe(false)
expect(hasRenderableMenuItems([])).toBe(false)
})
it('ignores structural-only menu responses without clickable entries', () => {
expect(hasRenderableMenuItems([
{
id: 'section',
systemKey: null,
label: 'Workspace',
itemType: 'SECTION_LABEL',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [],
},
{
id: 'divider',
systemKey: null,
label: '',
itemType: 'DIVIDER',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 2,
children: [],
},
])).toBe(false)
})
it('detects nested clickable menu entries', () => {
expect(hasRenderableMenuItems([
{
id: 'parent',
systemKey: null,
label: 'Operations',
itemType: 'PARENT_MENU',
routeOrUrl: null,
icon: null,
parentId: null,
openInNewTab: false,
displayOrder: 1,
children: [
{
id: 'fleet',
systemKey: 'fleet',
label: 'Fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/fleet',
icon: 'Car',
parentId: 'parent',
openInNewTab: false,
displayOrder: 1,
children: [],
},
],
},
])).toBe(true)
})
})
@@ -128,13 +128,23 @@ function hasMinRole(employeeRole: string, minRole: string): boolean {
return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0) return (ROLE_RANK[employeeRole] ?? 0) >= (ROLE_RANK[minRole] ?? 0)
} }
export function hasRenderableMenuItems(items: GeneratedMenuItem[] | null): boolean {
if (!items?.length) return false
return items.some((item) => {
if (item.itemType === 'DIVIDER' || item.itemType === 'SECTION_LABEL') return false
if (item.itemType === 'PARENT_MENU') return hasRenderableMenuItems(item.children)
return Boolean(item.routeOrUrl)
})
}
function notifyParent(message: Record<string, unknown>) { function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*') window.parent.postMessage(message, '*')
} }
export default function Sidebar() { export default function Sidebar() {
const { dict, language, setLanguage, theme } = useDashboardI18n() const { dict, language, setLanguage } = useDashboardI18n()
const isRtl = language === 'ar' const isRtl = language === 'ar'
const pathname = usePathname() const pathname = usePathname()
const appPath = toDashboardAppPath(pathname) const appPath = toDashboardAppPath(pathname)
@@ -248,8 +258,9 @@ export default function Sidebar() {
const isGeneratedItemActive = (item: GeneratedMenuItem) => { const isGeneratedItemActive = (item: GeneratedMenuItem) => {
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
if (item.routeOrUrl === '/') return appPath === '/' const route = toDashboardAppPath(item.routeOrUrl)
return appPath.startsWith(item.routeOrUrl) if (route === '/') return appPath === '/'
return appPath === route || appPath.startsWith(`${route}/`)
} }
const fallbackMenuItems = NAV_ITEMS const fallbackMenuItems = NAV_ITEMS
@@ -267,7 +278,8 @@ export default function Sidebar() {
children: [], children: [],
})) }))
const resolvedMenuItems = menuItems ?? fallbackMenuItems const useGeneratedMenu = hasRenderableMenuItems(menuItems)
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode { function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
return items.map((item) => { return items.map((item) => {
@@ -276,7 +288,7 @@ export default function Sidebar() {
const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null const Icon = item.icon && item.icon in ICON_MAP ? ICON_MAP[item.icon as keyof typeof ICON_MAP] : null
if (item.itemType === 'DIVIDER') { if (item.itemType === 'DIVIDER') {
return <div key={item.id} className="my-3 border-t border-blue-900/50" /> return <div key={item.id} className="mx-3 my-3 h-px bg-gradient-to-r from-transparent via-blue-400/20 to-transparent" />
} }
if (item.itemType === 'SECTION_LABEL') { if (item.itemType === 'SECTION_LABEL') {
@@ -290,7 +302,7 @@ export default function Sidebar() {
if (item.itemType === 'PARENT_MENU') { if (item.itemType === 'PARENT_MENU') {
return ( return (
<div key={item.id} className="space-y-1"> <div key={item.id} className="space-y-1">
<div className={`flex items-center gap-3 rounded-2xl px-3 py-2 text-sm font-medium text-slate-300 ${paddingClass}`}> <div className={`flex items-center gap-3 rounded-lg px-3.5 py-2.5 text-sm font-medium text-slate-600 dark:text-slate-300 ${paddingClass}`}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null} {Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label} {label}
</div> </div>
@@ -301,9 +313,11 @@ export default function Sidebar() {
const active = mounted && isGeneratedItemActive(item) const active = mounted && isGeneratedItemActive(item)
const className = [ const className = [
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors', 'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
paddingClass, paddingClass,
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white', active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ') ].join(' ')
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) { if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
@@ -321,8 +335,10 @@ export default function Sidebar() {
) )
} }
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
return ( return (
<Link key={item.id} href={item.routeOrUrl || '/'} className={className}> <Link key={item.id} href={href} className={className}>
{Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null} {Icon ? <Icon className="h-4 w-4 flex-shrink-0" /> : null}
{label} {label}
</Link> </Link>
@@ -351,6 +367,7 @@ export default function Sidebar() {
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */} {/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
{!open && ( {!open && (
<button <button
type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
aria-label="Open sidebar" aria-label="Open sidebar"
className={[ className={[
@@ -364,28 +381,32 @@ export default function Sidebar() {
<aside <aside
className={[ className={[
'fixed inset-y-0 z-40 flex w-64 flex-col border-r border-blue-900/50 bg-[#0a1128]/94 backdrop-blur-xl transition-transform duration-300', 'fixed inset-y-0 z-40 flex w-64 flex-col border-r border-blue-200/70 bg-white/90 text-blue-950 shadow-[8px_0_32px_rgba(15,23,42,0.08)] backdrop-blur-xl transition-[transform,background-color,border-color,color,box-shadow] duration-300 dark:border-blue-400/10 dark:bg-[#0a0f1a]/95 dark:text-slate-100 dark:shadow-[8px_0_32px_rgba(0,0,0,0.22)]',
isRtl ? 'right-0' : 'left-0', isRtl ? 'right-0' : 'left-0',
'lg:translate-x-0', 'lg:translate-x-0',
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'), open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
].join(' ')} ].join(' ')}
> >
<a href={marketplaceUrl} target="_top" className="flex items-center gap-3 border-b border-blue-900/50 px-6 py-5"> <a
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-orange-400"> href={marketplaceUrl}
target="_top"
className="flex items-center gap-3 border-b border-blue-200/70 px-5 py-5 transition-colors dark:border-blue-400/10"
>
<div className="flex h-[38px] w-[38px] flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 shadow-[0_4px_14px_rgba(59,130,246,0.34)]">
{brand?.logoUrl ? ( {brand?.logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" /> <img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
) : ( ) : (
<Car className="h-4 w-4 text-blue-950" /> <Car className="h-4 w-4 text-white" />
)} )}
</div> </div>
<span className={`truncate text-sm font-bold tracking-wide ${theme === 'light' ? 'text-blue-600' : 'text-white'}`}> <span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
{brand?.displayName ?? 'RentalDriveGo'} {brand?.displayName ?? 'RentalDriveGo'}
</span> </span>
</a> </a>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4"> <nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
{menuItems ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => { {useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
const Icon = item.icon const Icon = item.icon
const active = mounted && isActive(item) const active = mounted && isActive(item)
return ( return (
@@ -393,35 +414,48 @@ export default function Sidebar() {
key={item.href} key={item.href}
href={item.href} href={item.href}
className={[ className={[
'flex items-center gap-3 rounded-2xl px-3 py-2.5 text-sm font-medium transition-colors', 'relative flex items-center gap-3 rounded-lg px-3.5 py-3 text-sm font-medium transition-all duration-200',
active ? 'bg-orange-500 text-white' : 'text-slate-400 hover:bg-blue-900/40 hover:text-white', active
? 'bg-blue-500/10 text-blue-950 shadow-[inset_2px_0_0_#3b82f6,0_0_18px_rgba(59,130,246,0.10)] dark:text-slate-50'
: 'text-slate-500 hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-50',
].join(' ')} ].join(' ')}
> >
<Icon className="h-4 w-4 flex-shrink-0" /> <Icon className={['h-[18px] w-[18px] flex-shrink-0', active ? 'text-blue-700 dark:text-blue-300' : ''].join(' ')} />
{dict.nav[item.key]} {dict.nav[item.key]}
{item.key === 'dashboard' ? (
<span className="ml-auto rounded-full border border-blue-400/30 bg-blue-500/15 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:text-blue-300">
Live
</span>
) : null}
{item.key === 'notifications' && active ? (
<span className="ml-auto h-1.5 w-1.5 rounded-full bg-orange-400 shadow-[0_0_10px_rgba(249,115,22,0.5)]" />
) : null}
</Link> </Link>
) )
})} })}
</nav> </nav>
<div className="border-t border-blue-900/50 px-3 py-4"> <div className="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
<div className="flex items-center gap-3 rounded-2xl px-3 py-2"> <div className="rounded-xl border border-blue-200/70 bg-blue-50/75 p-3 backdrop-blur dark:border-blue-400/10 dark:bg-blue-500/[0.06]">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-orange-500 text-xs font-semibold text-white"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
{user.initials} {user.initials}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">{user.displayName}</p> <p className="truncate text-sm font-medium text-blue-950 dark:text-white">{user.displayName}</p>
<p className="truncate text-xs text-slate-400">{user.subtitle}</p> <p className="truncate text-xs text-slate-500 dark:text-slate-400">{user.subtitle}</p>
</div>
</div> </div>
</div> </div>
<button <button
type="button"
onClick={signOut} onClick={signOut}
className="mt-2 flex w-full items-center gap-3 rounded-2xl px-3 py-2 text-sm text-slate-400 transition-colors hover:bg-blue-900/40 hover:text-white" className="mt-2 flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-white"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
{dict.signOut} {dict.signOut}
</button> </button>
<div className="mt-3 flex items-center gap-2 border-t border-blue-900/50 pt-3"> <div className="mt-3 flex items-center gap-2 border-t border-blue-200/70 pt-3 dark:border-blue-400/10">
<DashboardLanguageSwitcher /> <DashboardLanguageSwitcher />
<DashboardThemeSwitcher /> <DashboardThemeSwitcher />
</div> </div>
@@ -429,6 +463,7 @@ export default function Sidebar() {
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */} {/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
<button <button
type="button"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
aria-label="Close sidebar" aria-label="Close sidebar"
className={[ className={[
+43 -15
View File
@@ -1,6 +1,7 @@
'use client' 'use client'
import { Bell } from 'lucide-react' import Link from 'next/link'
import { Bell, Search, Settings } from 'lucide-react'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
@@ -48,6 +49,7 @@ export default function TopBar() {
createdAt: string createdAt: string
}>>([]) }>>([])
const [loadingNotifs, setLoadingNotifs] = useState(false) const [loadingNotifs, setLoadingNotifs] = useState(false)
const [socketEnabled, setSocketEnabled] = useState(false)
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, []) useEffect(() => { setMounted(true) }, [])
@@ -63,8 +65,10 @@ export default function TopBar() {
try { try {
const data = await apiFetch<{ unread: number }>('/notifications/unread-count') const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
setUnreadCount(data.unread) setUnreadCount(data.unread)
setSocketEnabled(true)
} catch { } catch {
setUnreadCount(0) setUnreadCount(0)
setSocketEnabled(false)
} }
} }
@@ -81,6 +85,8 @@ export default function TopBar() {
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!socketEnabled) return
const socketOrigin = resolveSocketOrigin() const socketOrigin = resolveSocketOrigin()
if (!socketOrigin) return if (!socketOrigin) return
@@ -106,7 +112,7 @@ export default function TopBar() {
socket.off('notification', handleNotification) socket.off('notification', handleNotification)
socket.disconnect() socket.disconnect()
} }
}, []) }, [socketEnabled])
useEffect(() => { useEffect(() => {
if (!showNotifs) return if (!showNotifs) return
@@ -188,40 +194,54 @@ export default function TopBar() {
} }
return ( return (
<header className="relative z-[70] flex h-16 items-center justify-between border-b border-stone-200/80 bg-white/78 px-6 backdrop-blur-xl transition-colors dark:border-blue-900 dark:bg-blue-950/76"> <header className="relative z-[70] flex h-16 items-center justify-between border-b border-blue-200/70 bg-white/82 px-6 text-blue-950 backdrop-blur-xl transition-colors dark:border-blue-400/10 dark:bg-[#0a0f1a]/90 dark:text-slate-100">
<h1 className="text-lg font-semibold text-blue-950 dark:text-stone-50">{title}</h1> <div className="flex items-center gap-4 min-w-0">
<h1 className="shrink-0 text-lg font-semibold text-blue-950 dark:text-slate-50">{title}</h1>
<div className="relative hidden w-[min(380px,38vw)] sm:block">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
type="search"
placeholder="Search vehicles, bookings, customers..."
className="h-10 w-full rounded-lg border border-blue-200/80 bg-blue-50/70 pl-10 pr-4 text-sm text-blue-950 outline-none transition-all placeholder:text-slate-500 focus:border-blue-400/45 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-blue-400/15 dark:bg-blue-500/[0.06] dark:text-slate-100 dark:focus:bg-blue-500/10"
/>
</div>
<div className="hidden items-center gap-2 text-sm text-slate-400 xl:flex">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 shadow-[0_0_0_5px_rgba(16,185,129,0.10)]" />
System operational
</div>
</div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowNotifs(!showNotifs)} onClick={() => setShowNotifs(!showNotifs)}
className="relative rounded-2xl p-2 text-stone-500 transition-colors hover:bg-stone-100 hover:text-stone-700 dark:text-stone-400 dark:hover:bg-blue-900/40 dark:hover:text-stone-100" className="relative rounded-lg p-2 text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-100"
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
{unreadCount > 0 ? ( {unreadCount > 0 ? (
<span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-bold text-white"> <span className="absolute right-1 top-1 flex h-4 min-w-[16px] items-center justify-center rounded-full bg-orange-500 px-1 text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#0a0f1a]">
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
) : null} ) : null}
</button> </button>
{showNotifs ? ( {showNotifs ? (
<div className="absolute right-0 top-full z-[80] mt-2 w-80 rounded-[1.75rem] border border-stone-200/80 bg-white/95 p-4 shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]"> <div className="absolute right-0 top-full z-[80] mt-2 w-80 rounded-xl border border-blue-200/80 bg-white/95 p-4 shadow-[0_20px_60px_rgba(15,23,42,0.14)] backdrop-blur-xl dark:border-blue-400/15 dark:bg-[#0d1728]/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<p className="text-sm font-medium text-blue-950 dark:text-stone-100">{dict.notifications}</p> <p className="text-sm font-medium text-blue-950 dark:text-slate-100">{dict.notifications}</p>
<button <button
type="button" type="button"
onClick={() => openNotificationsInbox()} onClick={() => openNotificationsInbox()}
className="text-xs font-medium text-orange-700 hover:text-blue-900 dark:text-orange-300 dark:hover:text-white" className="text-xs font-medium text-orange-700 hover:text-blue-950 dark:text-orange-300 dark:hover:text-white"
> >
View all View all
</button> </button>
</div> </div>
{loadingNotifs ? ( {loadingNotifs ? (
<p className="text-sm text-stone-500 dark:text-stone-400">Loading</p> <p className="text-sm text-slate-500 dark:text-slate-400">Loading</p>
) : notifications.length === 0 ? ( ) : notifications.length === 0 ? (
<p className="text-sm text-stone-500 dark:text-stone-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
{unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)} {unreadCount === 0 ? dict.noNewNotifications : dict.unreadNotifications(unreadCount)}
</p> </p>
) : ( ) : (
@@ -232,14 +252,14 @@ export default function TopBar() {
type="button" type="button"
onClick={() => openNotificationsInbox(notification.id)} onClick={() => openNotificationsInbox(notification.id)}
className={[ className={[
'w-full rounded-2xl px-3 py-2 text-left transition-colors hover:bg-stone-100 dark:hover:bg-blue-900/40', 'w-full rounded-lg px-3 py-2 text-left transition-colors hover:bg-blue-500/10',
notification.readAt ? 'opacity-80' : '', notification.readAt ? 'opacity-80' : '',
].join(' ')} ].join(' ')}
> >
<p className="truncate text-sm font-medium text-blue-950 dark:text-stone-100"> <p className="truncate text-sm font-medium text-blue-950 dark:text-slate-100">
{notification.title} {notification.title}
</p> </p>
<p className="truncate text-xs text-stone-500 dark:text-stone-400"> <p className="truncate text-xs text-slate-500 dark:text-slate-400">
{notification.body} {notification.body}
</p> </p>
</button> </button>
@@ -250,7 +270,15 @@ export default function TopBar() {
) : null} ) : null}
</div> </div>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-400 text-xs font-semibold text-blue-950"> <Link
href="/settings"
className="hidden rounded-lg p-2 text-slate-500 transition-colors hover:bg-blue-500/10 hover:text-blue-950 dark:text-slate-400 dark:hover:text-slate-100 sm:block"
aria-label="Settings"
>
<Settings className="h-5 w-5" />
</Link>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-blue-500 to-orange-500 text-xs font-semibold text-white">
{userInitials} {userInitials}
</div> </div>
</div> </div>
+41 -7
View File
@@ -9,6 +9,14 @@ interface StatCardProps {
icon: LucideIcon icon: LucideIcon
iconColor?: string iconColor?: string
iconBg?: string iconBg?: string
/** Optional target value to show a progress bar beneath the value */
target?: number
/** Current progress (0100) when target is set */
progressPercent?: number
/** Show a subtle pulse/glow animation on the card */
pulse?: boolean
/** Apply a gradient to the value text */
valueGradient?: boolean
} }
export default function StatCard({ export default function StatCard({
@@ -19,31 +27,57 @@ export default function StatCard({
icon: Icon, icon: Icon,
iconColor = 'text-orange-700 dark:text-orange-300', iconColor = 'text-orange-700 dark:text-orange-300',
iconBg = 'bg-orange-50 dark:bg-orange-950/30', iconBg = 'bg-orange-50 dark:bg-orange-950/30',
target,
progressPercent,
pulse,
valueGradient,
}: StatCardProps) { }: StatCardProps) {
const isPositive = change !== undefined && change >= 0 const isPositive = change !== undefined && change >= 0
const isNegative = change !== undefined && change < 0 const isNegative = change !== undefined && change < 0
return ( return (
<div className="card p-6"> <div className={`card p-5 ${pulse ? 'pulse-glow' : ''}`}>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-stone-500 dark:text-stone-400">{title}</p> <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400">{title}</p>
<p className="mt-1 text-2xl font-bold text-blue-950 dark:text-stone-50">{value}</p> <p
className={[
'mt-2 text-[34px] font-light leading-none tracking-normal',
valueGradient
? 'bg-gradient-to-br from-blue-950 via-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-slate-100 dark:via-blue-200 dark:to-orange-300'
: 'bg-gradient-to-br from-blue-950 to-slate-700 bg-clip-text text-transparent dark:from-slate-100 dark:to-slate-300',
].join(' ')}
>
{value}
</p>
{target !== undefined && progressPercent !== undefined && (
<div className="mt-2 flex items-center gap-2">
<div className="progress-bar flex-1">
<div
className="progress-fill orange"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
</div>
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
{Math.round(progressPercent)}%
</span>
</div>
)}
{change !== undefined && ( {change !== undefined && (
<div className="mt-2 flex items-center gap-1"> <div className="mt-2 flex items-center gap-1">
<span <span
className={[ className={[
'text-xs font-medium', 'text-xs font-medium',
isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-stone-500 dark:text-stone-400', isPositive ? 'text-emerald-600 dark:text-emerald-300' : isNegative ? 'text-red-600 dark:text-red-300' : 'text-slate-500 dark:text-slate-400',
].join(' ')} ].join(' ')}
> >
{isPositive ? '+' : ''}{change.toFixed(1)}% {isPositive ? '+' : ''}{change.toFixed(1)}%
</span> </span>
<span className="text-xs text-stone-400 dark:text-stone-500">{vsLastMonthLabel}</span> <span className="text-xs text-slate-500 dark:text-slate-500">{vsLastMonthLabel}</span>
</div> </div>
)} )}
</div> </div>
<div className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-[1.25rem] ${iconBg}`}> <div className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg ${iconBg}`}>
<Icon className={`h-6 w-6 ${iconColor}`} /> <Icon className={`h-6 w-6 ${iconColor}`} />
</div> </div>
</div> </div>
+7 -9
View File
@@ -54,6 +54,13 @@ afterEach(() => {
}) })
describe('dashboard middleware', () => { describe('dashboard middleware', () => {
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/dashboard?x=1') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard?x=1' })
})
it('rejects middleware subrequest headers at the app layer', async () => { it('rejects middleware subrequest headers at the app layer', async () => {
const { default: middleware } = await loadMiddleware() const { default: middleware } = await loadMiddleware()
@@ -64,15 +71,6 @@ describe('dashboard middleware', () => {
expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 }) expect(response).toEqual({ kind: 'response', body: 'Unsupported internal request header', status: 400 })
}) })
it('deduplicates accidental repeated /dashboard prefixes before auth handling', async () => {
const { default: middleware } = await loadMiddleware()
const response = middleware(request('https://workspace.example.com/dashboard/dashboard/fleet') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://workspace.example.com/dashboard/fleet' })
expect(nextServer.redirect).toHaveBeenCalledTimes(1)
expect(nextServer.next).not.toHaveBeenCalled()
})
it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => { it('redirects unauthenticated protected internal-host requests to dashboard sign-in on the resolved origin', async () => {
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example') const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
+54 -32
View File
@@ -1,17 +1,51 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server' 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' const MARKETPLACE_URL = process.env.NEXT_PUBLIC_MARKETPLACE_URL ?? 'http://localhost:3000'
const DASHBOARD_BASE_PATH = '/dashboard'
function toDashboardAppPath(pathname: string): string {
let normalized = pathname || '/'
function rejectInternalSubrequest(req: NextRequest): NextResponse | null { while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
if (!req.headers.has('x-middleware-subrequest')) return null normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
return new NextResponse('Unsupported internal request header', { status: 400 }) }
return normalized
} }
function dedupeDashboardPath(pathname: string): string { function toPublicDashboardPath(pathname: string): string {
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard') const appPath = toDashboardAppPath(pathname)
return appPath === '/' ? DASHBOARD_BASE_PATH : `${DASHBOARD_BASE_PATH}${appPath}`
}
function deduplicatePublicDashboardPath(pathname: string): string | null {
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
let normalized = pathname
while (normalized.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length)
}
return normalized || DASHBOARD_BASE_PATH
}
function resolveProxyUrl(req: NextRequest, pathname: string): URL {
const forwardedHost = req.headers.get('x-forwarded-host')
const forwardedProto = req.headers.get('x-forwarded-proto')
const marketplaceOrigin = new URL(MARKETPLACE_URL)
const url = new URL(pathname, req.nextUrl.origin)
if (forwardedHost && !isInternalHost(forwardedHost)) {
url.host = forwardedHost
url.protocol = (forwardedProto ?? 'http') + ':'
} else if (!isInternalHost(req.nextUrl.host)) {
url.host = req.nextUrl.host
url.protocol = req.nextUrl.protocol
} else {
url.host = marketplaceOrigin.host
url.protocol = marketplaceOrigin.protocol
}
return url
} }
function isInternalHost(host: string | null): boolean { function isInternalHost(host: string | null): boolean {
@@ -21,38 +55,25 @@ function isInternalHost(host: string | null): boolean {
} }
function isProtectedRoute(req: NextRequest) { function isProtectedRoute(req: NextRequest) {
const { pathname } = req.nextUrl const pathname = toDashboardAppPath(req.nextUrl.pathname)
if (PUBLIC_DASHBOARD_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + '/'))) return false if (['/sign-in', '/sign-up', '/forgot-password', '/reset-password', '/onboarding'].some((p) => pathname === p || pathname.startsWith(p + '/'))) return false
return pathname === '/dashboard' || pathname.startsWith('/dashboard/') return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
} }
function localJwtMiddleware(req: NextRequest): NextResponse { function localJwtMiddleware(req: NextRequest): NextResponse {
const token = req.cookies.get('employee_session')?.value const token = req.cookies.get('employee_session')?.value
if (token && req.nextUrl.pathname === '/dashboard/sign-in') { const pathname = toDashboardAppPath(req.nextUrl.pathname)
const dashboardUrl = req.nextUrl.clone()
dashboardUrl.pathname = '/dashboard' // Redirect signed-in users from sign-in to dashboard (through marketplace proxy)
dashboardUrl.search = '' if (token && pathname === '/sign-in') {
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
return NextResponse.redirect(dashboardUrl) return NextResponse.redirect(dashboardUrl)
} }
if (!isProtectedRoute(req)) return NextResponse.next() if (!isProtectedRoute(req)) return NextResponse.next()
if (!token) { if (!token) {
const forwardedHost = req.headers.get('x-forwarded-host') const signInUrl = resolveProxyUrl(req, toPublicDashboardPath('/sign-in'))
const forwardedProto = req.headers.get('x-forwarded-proto')
const marketplaceOrigin = new URL(MARKETPLACE_URL)
const signInUrl = req.nextUrl.clone()
if (forwardedHost && !isInternalHost(forwardedHost)) {
signInUrl.host = forwardedHost
signInUrl.protocol = (forwardedProto ?? 'http') + ':'
} else if (isInternalHost(signInUrl.host)) {
signInUrl.host = marketplaceOrigin.host
signInUrl.protocol = marketplaceOrigin.protocol
}
const isMarketplaceHost = signInUrl.host === marketplaceOrigin.host
signInUrl.pathname = isMarketplaceHost ? '/' : '/dashboard/sign-in'
signInUrl.searchParams.set('redirect', req.nextUrl.pathname) signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
return NextResponse.redirect(signInUrl) return NextResponse.redirect(signInUrl)
} }
@@ -60,11 +81,12 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
} }
export default function middleware(req: NextRequest) { export default function middleware(req: NextRequest) {
const rejected = rejectInternalSubrequest(req) if (req.headers.has('x-middleware-subrequest')) {
if (rejected) return rejected return new NextResponse('Unsupported internal request header', { status: 400 })
}
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname) const dedupedPath = deduplicatePublicDashboardPath(req.nextUrl.pathname)
if (dedupedPath !== req.nextUrl.pathname) { if (dedupedPath) {
const redirectUrl = req.nextUrl.clone() const redirectUrl = req.nextUrl.clone()
redirectUrl.pathname = dedupedPath redirectUrl.pathname = dedupedPath
return NextResponse.redirect(redirectUrl) return NextResponse.redirect(redirectUrl)
+27
View File
@@ -0,0 +1,27 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
describe('dashboard next config', () => {
it('allows generated static media assets to be loaded across local app origins', async () => {
const nextConfig = require('../next.config.js')
const headers = await nextConfig.headers()
expect(headers).toEqual(
expect.arrayContaining([
{
source: '/_next/static/media/:path*',
headers: expect.arrayContaining([
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
]),
},
]),
)
})
})
+1
View File
@@ -17,6 +17,7 @@ const config: Config = {
}, },
fontFamily: { fontFamily: {
sans: ['Inter', 'ui-sans-serif', 'system-ui'], sans: ['Inter', 'ui-sans-serif', 'system-ui'],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace'],
}, },
}, },
}, },
+23 -2
View File
@@ -1,11 +1,15 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const { buildSecurityHeaders } = require('../../config/nextSecurityHeaders') const { buildSecurityHeaders, normalizeAssetPrefix } = require('../../config/nextSecurityHeaders')
// The marketplace proxies /dashboard and /admin to their own Next dev servers. // The marketplace proxies /dashboard and /admin to their own Next dev servers.
// Allow those asset-prefix origins so proxied pages can load chunks and HMR. // Allow those asset-prefix origins so proxied pages can load chunks and HMR.
const dashboardAssetSource = normalizeAssetPrefix(
process.env.DASHBOARD_ASSET_PREFIX ?? (process.env.NODE_ENV !== 'production' ? 'http://localhost:3001' : undefined),
'/dashboard',
)
const securityHeaders = buildSecurityHeaders({ const securityHeaders = buildSecurityHeaders({
assetSources: [process.env.DASHBOARD_ASSET_PREFIX, process.env.ADMIN_ASSET_PREFIX], assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
}) })
const nextConfig = { const nextConfig = {
images: { images: {
@@ -25,11 +29,27 @@ const nextConfig = {
}, },
] ]
}, },
async redirects() {
return [
{
source: '/dashboard/dashboard',
destination: '/dashboard',
permanent: false,
},
{
source: '/dashboard/dashboard/:path*',
destination: '/dashboard/:path*',
permanent: false,
},
]
},
async rewrites() { async rewrites() {
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001' const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002' const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
return [ return [
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
// so the dashboard can strip it and route internally.
{ {
source: '/dashboard', source: '/dashboard',
destination: `${dashboardOrigin}/dashboard`, destination: `${dashboardOrigin}/dashboard`,
@@ -38,6 +58,7 @@ const nextConfig = {
source: '/dashboard/:path*', source: '/dashboard/:path*',
destination: `${dashboardOrigin}/dashboard/:path*`, destination: `${dashboardOrigin}/dashboard/:path*`,
}, },
// Admin routes (also uses basePath)
{ {
source: '/admin', source: '/admin',
destination: `${adminOrigin}/admin`, destination: `${adminOrigin}/admin`,
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#0f172a" />
<text
x="16"
y="21"
text-anchor="middle"
font-family="Inter, Arial, sans-serif"
font-size="18"
font-weight="700"
fill="#ffffff"
>
R
</text>
</svg>

After

Width:  |  Height:  |  Size: 302 B

+43 -1
View File
@@ -8,13 +8,19 @@ const nextServer = vi.hoisted(() => {
set: vi.fn(), set: vi.fn(),
}, },
})) }))
const redirect = vi.fn((url: URL, status?: number) => ({
kind: 'redirect',
url: url.toString(),
status,
}))
const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({ const MockNextResponse = vi.fn((body?: string, init?: { status?: number }) => ({
kind: 'response', kind: 'response',
body, body,
status: init?.status, status: init?.status,
})) as any })) as any
MockNextResponse.next = next MockNextResponse.next = next
return { next, MockNextResponse } MockNextResponse.redirect = redirect
return { next, redirect, MockNextResponse }
}) })
vi.mock('next/server', () => ({ vi.mock('next/server', () => ({
@@ -25,6 +31,7 @@ function request(options: { cookies?: Record<string, string>; headers?: Record<s
const cookies = new Map(Object.entries(options.cookies ?? {})) const cookies = new Map(Object.entries(options.cookies ?? {}))
const headers = new Headers(options.headers ?? {}) const headers = new Headers(options.headers ?? {})
return { return {
nextUrl: cloneableUrl('http://localhost:3000/'),
cookies: { cookies: {
get: vi.fn((name: string) => { get: vi.fn((name: string) => {
const value = cookies.get(name) const value = cookies.get(name)
@@ -35,8 +42,25 @@ function request(options: { cookies?: Record<string, string>; headers?: Record<s
} }
} }
function cloneableUrl(input: string): URL {
const url = new URL(input)
;(url as URL & { clone: () => URL }).clone = () => cloneableUrl(url.toString())
return url
}
function middlewareRequest(input: string) {
return {
cookies: {
get: vi.fn(),
},
headers: new Headers(),
nextUrl: cloneableUrl(input),
}
}
beforeEach(() => { beforeEach(() => {
nextServer.next.mockClear() nextServer.next.mockClear()
nextServer.redirect.mockClear()
}) })
describe('marketplace proxy language bootstrap', () => { describe('marketplace proxy language bootstrap', () => {
@@ -102,3 +126,21 @@ describe('marketplace proxy language bootstrap', () => {
expect(response.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=fr') expect(response.init?.request?.headers?.get('cookie')).toBe('rentaldrivego-language=fr')
}) })
}) })
describe('marketplace middleware dashboard path canonicalization', () => {
it('redirects duplicate dashboard prefixes to the clean dashboard URL', async () => {
const { proxy } = await import('./proxy')
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard') as never)
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard', status: 307 })
})
it('preserves nested dashboard paths while removing the duplicate prefix', async () => {
const { proxy } = await import('./proxy')
const response = proxy(middlewareRequest('http://localhost:3000/dashboard/dashboard/fleet?tab=active') as never)
expect(response).toEqual({ kind: 'redirect', url: 'http://localhost:3000/dashboard/fleet?tab=active', status: 307 })
})
})
+20 -1
View File
@@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language' const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language' const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
const DASHBOARD_BASE_PATH = '/dashboard'
function rejectInternalSubrequest(request: NextRequest): NextResponse | null { function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
if (!request.headers.has('x-middleware-subrequest')) return null if (!request.headers.has('x-middleware-subrequest')) return null
@@ -22,10 +23,27 @@ function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
return 'en' return 'en'
} }
function deduplicateDashboardPath(pathname: string): string | null {
if (!pathname.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) return null
let normalized = pathname
while (normalized.startsWith(`${DASHBOARD_BASE_PATH}${DASHBOARD_BASE_PATH}`)) {
normalized = normalized.slice(DASHBOARD_BASE_PATH.length)
}
return normalized || DASHBOARD_BASE_PATH
}
export function proxy(request: NextRequest) { export function proxy(request: NextRequest) {
const rejected = rejectInternalSubrequest(request) const rejected = rejectInternalSubrequest(request)
if (rejected) return rejected if (rejected) return rejected
const deduped = deduplicateDashboardPath(request.nextUrl.pathname)
if (deduped) {
const url = request.nextUrl.clone()
url.pathname = deduped
return NextResponse.redirect(url, 307)
}
const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value const sharedLang = request.cookies.get(SHARED_LANGUAGE_COOKIE)?.value
if (isValidLanguage(sharedLang)) return NextResponse.next() if (isValidLanguage(sharedLang)) return NextResponse.next()
@@ -53,6 +71,7 @@ export function proxy(request: NextRequest) {
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next/static|_next/image|favicon\.ico|.*\.(?:png|ico|jpg|jpeg|svg|webp)$).*)', '/dashboard/dashboard/:path*',
'/((?!_next/static|_next/image|_next/webpack-hmr|favicon\\.ico|.*\\.(?:png|ico|jpg|jpeg|svg|webp)$).*)',
], ],
} }
+256
View File
@@ -0,0 +1,256 @@
# Create Account Guide
How to create a new company workspace on RentalDriveGo.
---
## Overview
Creating an account registers a new rental company on the platform. The flow has **4 steps** presented as a multi-step form on the dashboard's public sign-up page. After completion, the company owner receives a confirmation email and can sign in to manage their workspace.
**Supported languages:** English, French, Arabic
**Billing currency:** MAD (Moroccan Dirham)
---
## 1. Accessing the Sign-up Page
### From the marketplace homepage (port 3000)
- Click **"Create Agency Space"** in the header navigation.
- Click **"Get Started"** on any pricing plan card (`/pricing` page).
Both actions navigate to:
```
http://localhost:3000/dashboard/sign-up
```
The marketplace proxies this request to the dashboard app (port 3001), which renders the multi-step form.
### URL parameters (optional)
| Parameter | Example | Purpose |
|---|---|---|
| `plan` | `STARTER`, `GROWTH`, `PRO` | Pre-selects a pricing plan |
| `billing` | `MONTHLY`, `ANNUAL` | Pre-selects billing period |
| `currency` | `MAD` | Pre-selects currency (only MAD) |
Example: `/dashboard/sign-up?plan=GROWTH&billing=ANNUAL&currency=MAD`
---
## 2. Form Steps
### Step 1 — Account User
Required fields marked with `*`:
| Field | Type | Details |
|---|---|---|
| Manager/Owner first name * | Bilingual (FR + AR) | Max 80 characters |
| Manager/Owner last name * | Bilingual (FR + AR) | Max 80 characters |
| Manager/Owner email * | Email | Used as login identifier |
| Password * | Password | Minimum 8 characters |
| Confirm password * | Password | Must match |
| Preferred language * | Selection | English / Français / العربية |
**Note:** The same email can be reused for the owner account, company contact, and responsible person.
### Step 2 — Company Info
#### Legal Identity section
| Field | Type | Details |
|---|---|---|
| Commercial name * | Bilingual (FR + AR) | The public-facing business name |
| Legal company name * | Text | Max 160 characters |
| Legal form * | Select | `SARL`, `SARL AU`, `SA`, `SAS`, `AUTO_ENTREPRENEUR`, `EI`, `OTHER` |
| Commercial Registry (RC) * | Text | Max 120 characters |
| ICE number * | Text | Max 120 characters |
| Fiscal ID (IF) * | Text | Max 120 characters |
| Operating license number * | Text | Max 120 characters |
| License issue date * | Date | Format: YYYY-MM-DD |
| Issuing authority * | Text | Max 160 characters |
#### Company Contact section
| Field | Type | Details |
|---|---|---|
| Phone * | Text | Max 80 characters |
| Company contact email * | Email | Max 254 characters |
| Fax | Text | Optional |
| Years active * | Select | `Less than 1 year`, `1 to 5 years`, `More than 5 years` |
| Street Address * | Bilingual (FR + AR) | Max 200 characters |
| City * | Bilingual (FR + AR) | Max 120 characters |
| Country * | Bilingual (FR + AR) | Max 120 characters |
| Zip code * | Text | Max 40 characters |
#### Legal Representative section
| Field | Type | Details |
|---|---|---|
| Legal representative name | Text | Optional, max 160 characters |
| Legal representative title | Text | Optional, max 120 characters |
#### Responsible Person section
| Field | Type | Details |
|---|---|---|
| Responsible person name * | Text | Max 160 characters |
| Responsible person role * | Text | Max 120 characters |
| Responsible person ID number * | Text | Max 120 characters |
| Qualification / diploma * | Text | Optional, max 200 characters |
| Responsible person phone * | Text | Max 80 characters |
| Responsible person email * | Email | Max 254 characters |
### Step 3 — Choose Plan
Select one of three plans:
| Plan | Description |
|---|---|
| **STARTER** | Core fleet, offers, and bookings |
| **GROWTH** | Featured marketplace placement and more room to scale |
| **PRO** | Full white-label and premium controls |
Additional options:
| Field | Options |
|---|---|
| Billing period | Monthly / Annual |
| Primary provider | AmanPay / PayPal |
Currency is fixed to **MAD**.
### Step 4 — Review and Launch
Review all entered information. Click **"Create workspace"** to submit.
After successful submission, the page displays a confirmation with:
- The company name
- The owner email
- A link to **"Enter dashboard"** to start using the workspace
---
## 3. Backend API
### Endpoint
```
POST /api/v1/auth/company/signup
```
### Request body (validated by `companySignupSchema`)
```json
{
"firstName": "string (max 80)",
"lastName": "string (max 80)",
"email": "valid email",
"password": "string (8-128 chars)",
"companyName": "string (2-120)",
"legalName": "string (2-160)",
"legalForm": "string",
"registrationNumber": "string",
"iceNumber": "string",
"taxId": "string",
"operatingLicenseNumber": "string",
"operatingLicenseIssuedAt": "string",
"operatingLicenseIssuedBy": "string",
"streetAddress": "string",
"city": "string",
"country": "string",
"zipCode": "string",
"companyPhone": "string",
"companyEmail": "valid email",
"fax": "string (optional)",
"yearsActive": "string",
"representativeName": "string (optional)",
"representativeTitle": "string (optional)",
"responsibleName": "string",
"responsibleRole": "string",
"responsibleIdentityNumber": "string",
"responsibleQualification": "string (optional)",
"responsiblePhone": "string",
"responsibleEmail": "valid email",
"preferredLanguage": "en | fr | ar",
"plan": "STARTER | GROWTH | PRO",
"billingPeriod": "MONTHLY | ANNUAL",
"currency": "MAD",
"paymentProvider": "AMANPAY | PAYPAL"
}
```
### Response (201 Created)
```json
{
"companyId": "uuid",
"companyName": "string",
"slug": "string",
"trialEndsAt": "ISO date or null",
"nextStep": "onboarding",
"emailDelivery": {
"attempted": true,
"success": true,
"error": null
}
}
```
---
## 4. After Sign-up
### Sign in
Navigate to `/sign-in` (redirects to `/dashboard/sign-in`). Enter the owner email and the password set during sign-up.
### Onboarding
After first sign-in, the user is directed to the **onboarding** page (`/onboarding`) where they can:
1. **Step 1 — Company profile**: Set display name, tagline, brand color, and public location.
2. **Step 2 — Payments**: Configure AmanPay merchant ID / PayPal email and marketplace listing preference.
3. **Step 3 — Completion**: Redirect to the dashboard home.
### Subscription
New accounts start on a **90-day free trial** with the selected plan. The subscription status is shown in a banner on the dashboard home. Users can manage billing from the `/subscription` page.
---
## 5. Relevant Code Files
### Frontend
| File | Purpose |
|---|---|
| `apps/marketplace/src/app/renter/sign-up/page.tsx` | Marketplace redirect page to dashboard sign-up |
| `apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx` | Main multi-step sign-up form (4 steps, 937 lines) |
| `apps/dashboard/src/components/ui/BilingualInput.tsx` | Bilingual (FR/AR) input component |
| `apps/dashboard/src/components/layout/PublicShell.tsx` | Public layout wrapper for the sign-up page |
| `apps/dashboard/src/app/onboarding/page.tsx` | Post-sign-up onboarding (company profile, payments) |
| `apps/dashboard/src/app/forgot-password/page.tsx` | Password reset flow |
| `apps/dashboard/src/app/reset-password/page.tsx` | Reset password page |
| `apps/dashboard/src/app/onboarding/accept-invite/page.tsx` | Accept team invitation |
### Navigation links to sign-up
| File | Location |
|---|---|
| `apps/marketplace/src/components/MarketplaceHeader.tsx` | "Create Agency Space" button |
| `apps/marketplace/src/components/WorkspaceTabs.tsx` | "Owner sign in" link |
| `apps/marketplace/src/app/(public)/pricing/PricingClient.tsx` | "Get Started" pricing CTA |
| `apps/marketplace/src/app/(public)/HomeContent.tsx` | Homepage CTA |
### Backend
| File | Purpose |
|---|---|
| `apps/api/src/modules/auth/auth.company.routes.ts` | Express routes (`POST /signup`) |
| `apps/api/src/modules/auth/auth.company.service.ts` | Business logic: creates company, owner, workspace, sends email |
| `apps/api/src/modules/auth/auth.company.schemas.ts` | Zod validation schema for signup request body |
| `apps/api/src/lib/emailTranslations.ts` | Email templates in EN/FR/AR |
+1252
View File
File diff suppressed because it is too large Load Diff
+202
View File
@@ -0,0 +1,202 @@
# Progressive Account Creation — Redesign Plan
## 1. Problem Statement
The current sign-up flow (`apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx`) asks for **everything at once**:
- Owner identity & credentials
- Full legal/company identity (RC, ICE, IF, license, issuing authority…)
- Company contact & address (bilingual)
- Responsible person details
- Plan + billing + payment provider
That's ~30 required fields across 4 steps before a user has even seen the product. This is the classic cause of sign-up abandonment: the perceived effort to "just try the app" is too high.
**Goal:** Let someone create an account with almost nothing, start exploring the dashboard immediately, and only be asked for a specific piece of information **right when an action requires it** — never before.
This pattern is commonly called **progressive profiling** or **just-in-time (JIT) data collection**.
---
## 2. Core Principle
> Collect the minimum data needed to create an identity. Collect everything else lazily, tied to the action that needs it, not to a step number.
Two reframes that drive the whole design:
1. **Stop thinking in "steps."** Replace the 4-step wizard with a single **account state machine**. The account always exists in some state (Created → Active → Listing-Ready → Payable → Compliant → Subscribed), and each state unlocks more of the product.
2. **Stop thinking in "forms."** Replace big forms with **micro-prompts**: a single field, or a tiny group of 2-3 tightly related fields, asked contextually, usually as a modal/drawer over the action the user just tried to take.
---
## 3. New Sign-up Flow (Minimum Viable Signup)
### Step 0 — only step that blocks entry
| Field | Why it's required immediately |
|---|---|
| Owner email | Login identifier, needed to send confirmation |
| Password | Needed to create a session |
| Preferred language | Needed to render anything afterward correctly |
That's it. No name, no company, no legal data, no plan.
```
POST /api/v1/auth/account/start
{
"email": "string",
"password": "string (8-128)",
"preferredLanguage": "en | fr | ar"
}
```
Response creates:
- A `User` record (owner)
- A `Company` record in a `DRAFT` state with `name = null` and a system-generated placeholder slug
- A session/JWT so the user is **logged in immediately**
The user lands directly in the dashboard home, in a clearly "draft workspace" visual state (banner: *"Your workspace is being set up — finish anytime"*).
This single change — going from ~30 required fields to 3 — is the biggest win and should be shipped first even before the rest of the JIT system exists.
---
## 4. Account Maturity Model
Replace the rigid 4-step form with an explicit состояние/state on the `Company` record:
| State | Unlocked by | What it unlocks |
|---|---|---|
| `DRAFT` | Step 0 above | Browsing dashboard UI, exploring settings, reading docs |
| `IDENTIFIED` | Owner first/last name + company display name | Personalizing UI, inviting teammates |
| `LISTABLE` | Company contact info + address | Creating/saving a fleet item or offer as **draft** |
| `PUBLISHABLE` | Legal identity block (legal name, legal form, RC, ICE, IF, license) | Publishing a listing live on the marketplace |
| `PAYABLE` | Payment provider config (AmanPay/PayPal) + responsible person | Accepting bookings/payments |
| `SUBSCRIBED` | Plan + billing period selection | Past the 90-day trial, billed account |
Each state is just a derived boolean/computed field (`companyState`) based on which fields are filled — not a separate workflow table. The UI reads this state to decide what to show/lock.
This also directly maps the **existing legal/compliance fields** (RC, ICE, IF, operating license) to the **one moment they actually matter**: right before something goes live on the marketplace. Today they're collected on day 1, before the user has even decided to publish anything.
---
## 5. Just-In-Time Prompt Catalog
Map every current form field to the **specific user action** that should trigger asking for it. This is the heart of the redesign — a table the eng + product team should review and tune together.
| Action the user takes | Data requested at that moment | Fields involved |
|---|---|---|
| First login after signup | Display name | Owner first/last name, company commercial name |
| Click "Add a vehicle / offer" | Basic company contact | Phone, company email, address, city, country, zip |
| Click "Publish" on a listing | Legal identity | Legal name, legal form, RC, ICE, IF, license #, issue date, issuing authority |
| Click "Enable bookings" / "Connect payments" | Payment + responsible person | Payment provider, responsible person name/role/ID/phone/email |
| Visit `/subscription` or trial reaches day 75 | Plan selection | Plan, billing period |
| Click "Invite teammate" | (no new data) | — |
| Optional, never blocking | Fax, legal representative name/title, qualification/diploma | These stay fully optional, surfaced only in Settings |
Design rule: **never ask for more than 1 group of tightly-related fields in a single prompt.** E.g. "Legal identity" can still be ~8 fields, but it's one cohesive concept the user understands ("info needed to verify your business"), not an arbitrary step number.
---
## 6. UX Pattern: Contextual Prompt, Not a Form Page
When a user triggers an action that needs missing data:
1. The action is allowed to start optimistically (e.g. they can build a full listing draft).
2. On the actual gating action (Publish, Enable bookings…), intercept with a **slide-over panel or modal**, not a full-page redirect.
3. The modal:
- States *why* in one line: "We need your business registration details before this listing can go public."
- Shows only the missing fields (if some were already filled earlier, don't ask again).
- Has a "Save and publish" primary action, and a "Save as draft, finish later" secondary action.
4. On submit, persist via a generic incremental-update endpoint, recompute `companyState`, and continue the original action automatically.
This keeps the user inside their task instead of bouncing them to a separate "complete your profile" flow.
A persistent, low-key "Workspace setup: 60% complete" indicator (e.g. in the sidebar) lets motivated users finish everything in one sitting if they prefer — progressive disclosure should never *block* someone who wants to do it all at once.
---
## 7. Backend Changes
### 7.1 Schema
- Make nearly all `Company` fields nullable (`legalName`, `registrationNumber`, `iceNumber`, etc. all become optional at the DB level — required-ness becomes a **business rule per action**, not a DB constraint).
- Add `companyState` (computed/cached enum) or compute on read.
- Add `profileCompletionPercent` for the UI progress indicator.
### 7.2 API
Replace the single big `POST /auth/company/signup` with:
```
POST /api/v1/auth/account/start → minimal signup (Section 3)
PATCH /api/v1/company/profile → generic partial update, accepts any subset of fields
PATCH /api/v1/company/legal-identity → group endpoint for legal fields (own validation rules)
PATCH /api/v1/company/payment-setup → group endpoint for payment + responsible person
PATCH /api/v1/company/plan → plan/billing selection
GET /api/v1/company/state → returns companyState + missing fields per state
```
Each `PATCH` endpoint validates only its own slice with its own Zod schema (split `auth.company.schemas.ts` into per-group schemas), instead of one monolithic `companySignupSchema`.
### 7.3 Gating middleware
Add a small middleware/guard used on the actions that require a given state, e.g.:
```ts
requireCompanyState("PUBLISHABLE") // on POST /listings/:id/publish
requireCompanyState("PAYABLE") // on POST /bookings, /payments/connect
```
If the guard fails, return a structured error listing exactly which fields are missing, so the frontend can render the right contextual modal without guessing.
### 7.4 Email confirmation
Confirmation email still sends immediately after Step 0, but its copy changes from "your account is ready" to "your account is ready — finish setup anytime to start publishing," since there's much less to confirm.
---
## 8. Frontend Changes
- Replace the 937-line multi-step page with:
- A short `~/sign-up` page (Step 0 only).
- A set of small, reusable "completion modals," one per group in Section 5, each built from the existing `BilingualInput` and form components (these don't need to be rebuilt, just reorganized).
- Introduce a `useCompanyState()` hook that fetches `GET /company/state` and exposes `{state, missingFields, completionPercent}` to any component that needs to gate an action.
- Add a generic `<RequireCompanyState state="PUBLISHABLE">` wrapper component used to gate the relevant buttons/actions, opening the right modal when triggered.
---
## 9. Edge Cases & Risks
| Risk | Mitigation |
|---|---|
| Users abandon before ever reaching `PUBLISHABLE`/`PAYABLE` | That's expected and fine — they were never going to finish the old form either. Track funnel by state instead of all-or-nothing. |
| Marketplace ends up with unpublishable "ghost" companies | `DRAFT`/`IDENTIFIED` companies are simply never shown publicly — only `PUBLISHABLE`+ companies appear, so this is a non-issue by construction. |
| Legal/compliance requirements might mandate some data before *any* account exists, in some jurisdictions | Confirm with legal/compliance which fields (if any) are truly required by regulation before allowing a transaction vs. before allowing signup at all. Likely only the `PAYABLE` gate has real regulatory weight. |
| Users get repeatedly interrupted by modals | Batch by group (Section 5), not by individual field; show the completion indicator so users see how close they are; never re-ask for data already given. |
| Existing accounts (already fully filled out under the old flow) | No migration needed — they'll simply compute as `SUBSCRIBED`/fully complete already. |
| Analytics/CRM relying on full data at signup | Will need to adjust to track partial profiles; add a scheduled job/report on stalled accounts per state for sales/CS follow-up. |
---
## 10. Suggested Rollout Phases
1. **Phase 1 — Minimal signup.** Ship Section 3 (3-field signup) behind a flag. Move all other fields to a single post-signup "Settings → Complete your profile" page (not yet contextual, just relocated). This alone should meaningfully cut signup abandonment.
2. **Phase 2 — State machine + gating.** Add `companyState`, the `requireCompanyState` guard, and the `GET /company/state` endpoint. Gate "Publish" and "Enable bookings" only.
3. **Phase 3 — Contextual modals.** Replace the static Settings page prompts with the in-context slide-over modals described in Section 6.
4. **Phase 4 — Polish.** Add the sidebar completion indicator, refine copy per modal, A/B test prompt timing (e.g. ask for legal identity right after first draft listing vs. only at publish time).
---
## 11. Summary of File-Level Changes
| File | Change |
|---|---|
| `apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx` | Shrink to Step 0 only (~50-100 lines instead of 937) |
| `apps/api/src/modules/auth/auth.company.routes.ts` | Replace single signup route with `account/start` + add new `PATCH` group routes |
| `apps/api/src/modules/auth/auth.company.service.ts` | Split `createCompanyAndOwner` into minimal creation + separate update-group methods |
| `apps/api/src/modules/auth/auth.company.schemas.ts` | Split into `accountStartSchema`, `companyContactSchema`, `legalIdentitySchema`, `paymentSetupSchema`, `planSchema` |
| New: `apps/api/src/modules/company/company.state.ts` | Computes `companyState` + missing fields from a `Company` record |
| New: `apps/api/src/middleware/requireCompanyState.ts` | Gating middleware described in 7.3 |
| New: `apps/dashboard/src/components/profile/CompletionModal*.tsx` | Set of contextual prompt modals, one per group |
| New: `apps/dashboard/src/hooks/useCompanyState.ts` | Frontend hook described in Section 8 |
+27
View File
@@ -0,0 +1,27 @@
That diagram shows the core shift: right now your homepage is basically one flat layer (cards in a grid), and the redesign organizes it into four layers that sit at different depths and move at different speeds. Here's the full plan, grounded in what's actually in your codebase (HomeContent.tsx, StatsStrip, HowItWorks, TestimonialsSection, globals.css).
What's flat about it today
Every section is border + bg-white/80 + backdrop-blur repeated down the page — hero, surface card, pillars, features, testimonials all use the same recipe. The only motion in the entire codebase is the number counter in StatsStrip. No parallax, no scroll-triggered reveals, no cursor reactivity, no layering — it reads like a brochure, not a product.
Layer 1 — Background ambient
Replace the static site-glow radial gradients with a position:fixed canvas/SVG layer behind everything: a slow-drifting gradient mesh (CSS @keyframes on background-position, 2030s loop) plus a few large soft-edged blobs in your orange/blue brand colors that shift opacity per section as you scroll (driven by scroll progress, not JS-per-frame). This is pure atmosphere — zero interaction, lowest z-index, pointer-events:none.
Layer 2 — Motion / parallax
A position:absolute layer holding decorative shapes (car silhouette outlines, route-line squiggles, dashed "road" paths) that translate at a fraction of scroll speed (translateY(scrollY * 0.3)), so they appear to drift slower than the content in front of them. This is what makes a page feel "deep" instead of stacked. Good candidates: behind the hero, behind the stats strip, behind testimonials.
Layer 3 — Content (rebuilt, not just restyled)
This is your existing copy/cards, but reworked so each section enters with a scroll-triggered animation (fade + slight rise via IntersectionObserver, same pattern your Counter already uses — just extend it) rather than appearing fully rendered. Specific upgrades:
Hero: stagger the kicker → title → body → CTAs → metric cards (100ms apart) instead of all at once.
Pillars/features grid: cards tilt slightly on mouse-move (cheap CSS transform: perspective() tied to mouse position) instead of sitting static.
Stats strip: already has a counter — add a connecting animated line/progress bar that draws in alongside the counters.
Steps/How-it-works: convert from a static list into a scroll-linked progress rail — a vertical line that fills as the user scrolls past each step.
Layer 4 — Foreground interactive
Small elements that float above content and react to the user: a cursor-follow glow on hero CTAs, a sticky "live" badge that pulses (you already have dict.liveLabel — give it a pulse animation), micro-toasts like "3 companies just joined" drifting in periodically near the surface card. These sit at the highest z-index and are the layer most associated with "alive."
Implementation plan
Pick the motion engine. Since this is React/Next.js, the cleanest path is Framer Motion (npm install framer-motion) for entrance/scroll animations, plus plain CSS @keyframes for ambient/looping effects (cheaper, no JS per frame). Avoid heavy scroll libraries (GSAP ScrollTrigger) unless you want more cinematic control — Framer Motion's whileInView + useScroll/useTransform covers everything above.
Build a <BackgroundLayers /> component mounted once in (public)/layout.tsx, rendering the ambient + parallax layers as fixed/absolute siblings behind {children}. Keeps it out of every page's content logic.
Extract a reusable <Reveal> wrapper (IntersectionObserver + fade/rise) and wrap each existing section in HomeContent.tsx with it — minimal rewrite of your current JSX, just wrapping.
Upgrade StatsStrip and HowItWorks with the progress-rail/connecting-line treatment since they already have the data shape for it.
Add the foreground layer last — it's the smallest amount of code but the highest "feels dynamic" payoff, so don't front-load it; do it once the depth/motion skeleton works.
Respect prefers-reduced-motion throughout — wrap all loops/parallax in the media query so it degrades to the current static design for users who need that.
Suggested order of work: (1) background ambient layer, (2) Reveal wrapper on existing sections, (3) parallax shapes, (4) stats/steps progress treatment, (5) foreground micro-interactions, (6) cursor-tilt on cards.
+34
View File
@@ -0,0 +1,34 @@
Proposed plan
Phase 1 — Design tokens & primitives
Add .glass-card as the dark-mode companion to the existing .card (light mode) — same component, theme-aware via html.dark selector, so call sites don't need to branch.
Add --bg-elevated, --border-accent, --glass-bg, glow shadows for dark; pick matching light-mode equivalents (soft shadow/border tints) so cards feel like one consistent design language in either mode, not "light mode = old style, dark mode = new style."
Add .progress-bar / .progress-fill classes with light and dark variants (the mockup's blue/orange gradient fills work in both; just need lighter track colors for light mode).
Add JetBrains Mono via next/font for metric values — works the same in both modes.
Guard pulse/glow animations behind prefers-reduced-motion (applies regardless of theme).
Phase 2 — StatCard v2
Extend StatCard.tsx with optional target, progressPercent, pulse, valueGradient props, each with explicit light/dark Tailwind variants (dark: prefixes), backward-compatible with current usage.
Update StatCard.test.ts, including a check that dark-mode classes are present.
Phase 3 — Dashboard home restructure
Reorganize (dashboard)/page.tsx into a 2/3 + 1/3 grid: bookings chart + new "Branch/Location Performance" list (left), quick stats (right) — laid out identically in both modes, only colors/surfaces change.
New metrics (utilization rate, revenue/vehicle, fulfillment rate, branch breakdown) require backend fields on /analytics/dashboard (utilizationRate, fulfillmentRate, locationBreakdown[]) — flagged as a contract change, not faked.
Drop the fixed-airport SVG map; replace with a data-driven branch list with progress bars, styled for both themes.
Phase 4 — Sidebar & TopBar polish
Sidebar: glass background in dark mode, equivalent frosted-white treatment in light mode; gradient logo badge and active-item glow tuned per theme so contrast stays good in both.
TopBar: restyled search input with light/dark variants; keep only status indicators backed by real data.
Phase 5 — i18n & RTL pass
All new strings via the dictionary for fr/en/ar.
Verify glass-cards, progress bars, and branch list mirror correctly in dir="rtl", in both light and dark.
Phase 6 — QA
Update/extend tests.
Manual visual check of every changed component in 4 combinations: light+LTR, light+RTL, dark+LTR, dark+RTL.
+105
View File
@@ -10638,6 +10638,111 @@
"devDependencies": { "devDependencies": {
"typescript": "^5.4.0" "typescript": "^5.4.0"
} }
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.8.tgz",
"integrity": "sha512-R/MDVX4Cj+bzvTNkDWNyA09yCPBZB0J1m8P3VAmWAzx/J7DctA1wxya1LU3CcPv2szER3AImHEtsgScauH8SsQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.8.tgz",
"integrity": "sha512-XH2u8C/meLJmEZbN0G5MRZp8tvxBz5KQ+xv4vX34zL8gL3//5qkc5M/aLsvNAfg5/KbcjIVe3h6aiO0wJcNWKA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.8.tgz",
"integrity": "sha512-zyA8bCtlAXOzZD6ri6hIi5Z7hW5+07ea81KK0xdezmJHrbrAgvwIRd6VbkddJTapmZVSmWzbpAdYoi1HZNF7KA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.8.tgz",
"integrity": "sha512-SD/R9QU6+wUB3lH6InhOLu1eP3wle4sSjsNdMwk24yFwaWUB5TAtscvFbOqh1AYBsJvHRez3tYHETfbBFd4dRg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.8.tgz",
"integrity": "sha512-F1Al3cRKhYR7/V1RaXnHceqilNLFDe47w7Li/Asph1SklaHHm/b2FahLar2nWct9l9K143NlqbAUFQMjx30QYA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.8.tgz",
"integrity": "sha512-LxhCLToY0id1CkmYFi9sTM2RfoBWszr6mmMrBtmTFqKF0fXX3ZIbM8m1sKoNFIrZFn7gFroT7cnZea5neufSHg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.8.tgz",
"integrity": "sha512-xfGxs1zIkTOLzH8i1WWCeTqQhNqyrv3aVT55PMy7Ul3dfxlA3In7hNEJ3Uo2qpalEBOlkG6G6FLRMWbUrxL+qQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ ensureDatabaseUrl()
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function main() { async function main() {
const email = process.env.ADMIN_SEED_EMAIL ?? 'admin@rentaldrivego.com' const email = process.env.ADMIN_SEED_EMAIL ?? 'admin@rentaldrivego.ma'
const password = process.env.ADMIN_SEED_PASSWORD ?? 'changeme123' const password = process.env.ADMIN_SEED_PASSWORD ?? 'changeme123'
const firstName = process.env.ADMIN_SEED_FIRST_NAME ?? 'Super' const firstName = process.env.ADMIN_SEED_FIRST_NAME ?? 'Super'
const lastName = process.env.ADMIN_SEED_LAST_NAME ?? 'Admin' const lastName = process.env.ADMIN_SEED_LAST_NAME ?? 'Admin'