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
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
This commit is contained in:
+1
-1
@@ -32,7 +32,7 @@ ADMIN_SEED_LAST_NAME=Admin
|
||||
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||
CLERK_SECRET_KEY=placeholder
|
||||
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
|
||||
RESEND_API_KEY=placeholder
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ NEXT_PUBLIC_PUBLIC_SITE_DOMAIN=localhost:3003
|
||||
DASHBOARD_URL=http://localhost:3000/dashboard
|
||||
|
||||
# ─── 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_FIRST_NAME=Super
|
||||
ADMIN_SEED_LAST_NAME=Admin
|
||||
|
||||
@@ -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">
|
||||
<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="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>
|
||||
<nav className="flex-1 px-3 space-y-0.5">
|
||||
{navLinks.map((link) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Activity, ArrowRight, Building2, ClipboardList, ShieldCheck, Users, WalletCards } from 'lucide-react'
|
||||
import { useAdminI18n } from '@/components/I18nProvider'
|
||||
import { ADMIN_API_BASE } from '@/lib/api'
|
||||
import { canAccessAdminMetrics, useAdminSession } from './AdminSessionContext'
|
||||
@@ -15,35 +16,65 @@ interface Metrics {
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { language, dict } = useAdminI18n()
|
||||
const { language } = useAdminI18n()
|
||||
const admin = useAdminSession()
|
||||
const copy = {
|
||||
en: {
|
||||
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: [
|
||||
['Companies', 'List, search, suspend, reactivate, and review subscription state.', 'View all →'],
|
||||
['Renters', 'Support flows for blocking and unblocking marketplace renters.', 'View all →'],
|
||||
['Audit logs', 'Full trace of every admin action taken on the platform.', 'View logs →'],
|
||||
['Companies', 'Search operators, review subscription state, and manage account status.', 'View all'],
|
||||
['Renters', 'Support renter trust workflows, blocking, and account recovery.', 'View all'],
|
||||
['Audit logs', 'Trace every sensitive admin action across FleetOS.', 'View logs'],
|
||||
],
|
||||
health: ['Tenant accounts', 'Marketplace identity', 'Admin audit trail'],
|
||||
},
|
||||
fr: {
|
||||
kpis: ['Entreprises totales', 'Entreprises actives', 'Locataires totaux', 'Réservations totales'],
|
||||
platformOverview: 'Vue d’ensemble 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, l’activité 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: [
|
||||
['Entreprises', 'Lister, rechercher, suspendre, réactiver et consulter l’état des abonnements.', 'Voir tout →'],
|
||||
['Locataires', 'Gérer l’assistance liée au blocage et au déblocage des locataires de la marketplace.', 'Voir tout →'],
|
||||
['Journaux d’audit', 'Consulter la trace complète de chaque action d’administration sur la plateforme.', 'Voir les journaux →'],
|
||||
['Entreprises', 'Rechercher les opérateurs, vérifier les abonnements et gérer les statuts.', 'Voir tout'],
|
||||
['Locataires', 'Gérer les workflows de confiance, de blocage et de récupération.', 'Voir tout'],
|
||||
['Journaux d’audit', 'Tracer chaque action admin sensible dans FleetOS.', 'Voir les journaux'],
|
||||
],
|
||||
health: ['Comptes locataires', 'Identité marketplace', 'Piste d’audit admin'],
|
||||
},
|
||||
ar: {
|
||||
kpis: ['إجمالي الشركات', 'الشركات النشطة', 'إجمالي المستأجرين', 'إجمالي الحجوزات'],
|
||||
platformOverview: 'نظرة عامة على المنصة',
|
||||
brand: 'FleetOS',
|
||||
eyebrow: 'مركز العمليات',
|
||||
platformOverview: 'لوحة إدارة FleetOS',
|
||||
subtitle: 'راقب صحة السوق وحالة الاشتراكات ونشاط المستأجرين وإجراءات الإدارة من مساحة واحدة.',
|
||||
liveSignal: 'مؤشر المنصة المباشر',
|
||||
readiness: 'جاهزية التشغيل',
|
||||
readinessBody: 'مسارات الإدارة الأساسية متصلة وجاهزة لدعم المنصة.',
|
||||
quickActions: 'إجراءات سريعة',
|
||||
mrr: 'الإيراد الشهري المتكرر',
|
||||
noMetrics: 'المؤشرات محدودة لهذا الدور الإداري.',
|
||||
cards: [
|
||||
['الشركات', 'اعرض الشركات وابحث عنها وعلّقها وأعد تفعيلها وراجع حالة الاشتراك.', 'عرض الكل ←'],
|
||||
['المستأجرون', 'إدارة طلبات الدعم الخاصة بحظر مستأجري السوق وفك الحظر عنهم.', 'عرض الكل ←'],
|
||||
['سجلات التدقيق', 'مراجعة السجل الكامل لكل إجراء إداري تم على المنصة.', 'عرض السجلات ←'],
|
||||
['الشركات', 'ابحث عن المشغلين وراجع الاشتراكات وأدر حالة الحساب.', 'عرض الكل'],
|
||||
['المستأجرون', 'إدارة الثقة والحظر واستعادة الحسابات للمستأجرين.', 'عرض الكل'],
|
||||
['سجلات التدقيق', 'تتبع كل إجراء إداري حساس داخل FleetOS.', 'عرض السجلات'],
|
||||
],
|
||||
health: ['حسابات الشركات', 'هوية السوق', 'سجل تدقيق الإدارة'],
|
||||
},
|
||||
}[language]
|
||||
const [metrics, setMetrics] = useState<Metrics | null>(null)
|
||||
@@ -72,21 +103,90 @@ export default function AdminDashboardPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [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
|
||||
? [
|
||||
{ label: 'Total companies', value: metrics.totalCompanies },
|
||||
{ label: copy.kpis[1], value: metrics.activeCompanies },
|
||||
{ label: copy.kpis[2], value: metrics.totalRenters },
|
||||
{ label: copy.kpis[3], value: metrics.totalReservations },
|
||||
{ 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, 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, 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, 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 (
|
||||
<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>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-emerald-400">{dict.admin}</p>
|
||||
<h1 className="mt-1 text-3xl font-black">{copy.platformOverview}</h1>
|
||||
<p className="text-xs text-stone-500 dark:text-slate-400">{copy.mrr}</p>
|
||||
<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 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>
|
||||
))
|
||||
: kpis.map((kpi) => (
|
||||
<div key={kpi.label} className="panel p-6">
|
||||
<p className="text-xs text-zinc-500">{kpi.label}</p>
|
||||
<p className="mt-1 text-3xl font-black">{kpi.value?.toLocaleString() ?? '—'}</p>
|
||||
: kpis.length > 0 ? kpis.map((kpi) => {
|
||||
const Icon = kpi.icon
|
||||
return (
|
||||
<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 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 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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { AdminI18nProvider } from '@/components/I18nProvider'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Admin',
|
||||
description: 'Platform administration for RentalDriveGo.',
|
||||
title: 'FleetOS Admin',
|
||||
description: 'FleetOS platform administration.',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
+35
-2
@@ -1,5 +1,5 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import cors, { type CorsOptions } from 'cors'
|
||||
import helmet from 'helmet'
|
||||
import morgan from 'morgan'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
@@ -12,6 +12,7 @@ import { requestIdMiddleware } from './middleware/requestId'
|
||||
import webhookRouter from './modules/webhooks/webhook.routes'
|
||||
import companyAuthRouter from './modules/auth/auth.company.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 teamRouter from './modules/team/team.routes'
|
||||
import offersRouter from './modules/offers/offer.routes'
|
||||
@@ -40,8 +41,12 @@ const v1 = '/api/v1'
|
||||
|
||||
const defaultCorsOrigins = [
|
||||
'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',
|
||||
]
|
||||
|
||||
@@ -49,6 +54,33 @@ export const corsOrigins = process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean)
|
||||
: 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 = [
|
||||
{ method: 'GET', path: '/health', description: 'Health check' },
|
||||
{ method: 'GET', path: `${v1}/docs`, description: 'Machine-readable API index' },
|
||||
@@ -74,7 +106,7 @@ const routeDocs = [
|
||||
|
||||
export function createApp() {
|
||||
const app = express()
|
||||
const corsMiddleware = cors({ origin: corsOrigins, credentials: true })
|
||||
const corsMiddleware = cors(corsOptions)
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.set('trust proxy', 1)
|
||||
@@ -147,6 +179,7 @@ export function createApp() {
|
||||
app.use(express.json({ limit: '10mb' }))
|
||||
|
||||
// ─── API Routes ─────────────────────────────────────────────
|
||||
app.use(`${v1}/auth/account`, authLimiter, accountAuthRouter)
|
||||
app.use(`${v1}/auth/renter`, authLimiter, renterAuthRouter)
|
||||
app.use(`${v1}/auth/company`, authLimiter, companyAuthRouter)
|
||||
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')
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const res = await request(app).get('/storage/companies/company_1/customers/customer_1/passport.jpg')
|
||||
|
||||
|
||||
@@ -27,11 +27,21 @@ const storagePatterns = [
|
||||
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
// In Docker dev the dashboard app runs on port 3001 while the marketplace proxy
|
||||
// is served from port 3000. When DASHBOARD_ASSET_PREFIX is set, Next should emit
|
||||
// absolute chunk URLs so /dashboard pages load their own JS/CSS from port 3001.
|
||||
const assetPrefix = normalizeAssetPrefix(process.env.DASHBOARD_ASSET_PREFIX, DASHBOARD_BASE_PATH)
|
||||
const securityHeaders = buildSecurityHeaders({ assetSources: [assetPrefix] })
|
||||
// basePath is required so the client-side router knows it's mounted at /dashboard.
|
||||
// Without it, React cannot hydrate because the URL (/dashboard/sign-up) doesn't
|
||||
// match the route (/sign-up). The Turbopack double-basePath prefetch bug is
|
||||
// handled by the marketplace middleware (307 redirect /dashboard/dashboard → /dashboard).
|
||||
//
|
||||
// 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 = {
|
||||
basePath: DASHBOARD_BASE_PATH,
|
||||
@@ -48,12 +58,41 @@ const nextConfig = {
|
||||
transpilePackages: ['@rentaldrivego/types'],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/_next/static/media/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: '*',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
source: '/:path*',
|
||||
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() {
|
||||
const apiOrigin = (process.env.API_INTERNAL_URL ?? process.env.API_URL ?? 'http://localhost:4000').replace(/\/api\/v1\/?$/, '')
|
||||
return [
|
||||
|
||||
@@ -4,7 +4,7 @@ import TopBar from '@/components/layout/TopBar'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
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">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
|
||||
<Link
|
||||
key={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}
|
||||
<span className="text-sm font-medium">{action.label}</span>
|
||||
@@ -122,17 +122,17 @@ function SubscriptionBanner({
|
||||
|
||||
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
|
||||
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" />,
|
||||
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
|
||||
},
|
||||
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" />,
|
||||
message: pastDueMsg,
|
||||
},
|
||||
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" />,
|
||||
message: suspendedMsg,
|
||||
},
|
||||
@@ -164,15 +164,15 @@ function BranchPerformanceList({
|
||||
return (
|
||||
<div className="glass-card p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-orange-500" />
|
||||
<h2 className="text-base font-semibold text-blue-950 dark:text-stone-100">Location Performance</h2>
|
||||
<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-stone-100">{loc.name}</span>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<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>
|
||||
@@ -183,7 +183,7 @@ function BranchPerformanceList({
|
||||
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<span className="w-8 text-right text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||
{loc.utilizationPct}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -195,10 +195,16 @@ function BranchPerformanceList({
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const { dict, language, theme } = useDashboardI18n()
|
||||
const d = dict.dashboardPage
|
||||
const nav = dict.nav
|
||||
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 [loading, setLoading] = useState(true)
|
||||
@@ -236,7 +242,7 @@ export default function DashboardPage() {
|
||||
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -253,10 +259,10 @@ export default function DashboardPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<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}` : ''}!
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@@ -268,25 +274,25 @@ export default function DashboardPage() {
|
||||
icon: <Plus className="w-4 h-4" />,
|
||||
label: 'New Reservation',
|
||||
href: '/reservations/new',
|
||||
color: 'text-blue-600',
|
||||
color: 'text-blue-700 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
icon: <Car className="w-4 h-4" />,
|
||||
label: nav.fleet,
|
||||
href: '/fleet',
|
||||
color: 'text-green-600',
|
||||
color: 'text-emerald-700 dark:text-emerald-300',
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
label: nav.customers,
|
||||
href: '/customers',
|
||||
color: 'text-purple-600',
|
||||
color: 'text-orange-700 dark:text-orange-300',
|
||||
},
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: nav.reports,
|
||||
href: '/reports',
|
||||
color: 'text-orange-600',
|
||||
color: 'text-orange-700 dark:text-orange-300',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -311,8 +317,8 @@ export default function DashboardPage() {
|
||||
change={kpis.bookingsChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Calendar}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
pulse
|
||||
/>
|
||||
<StatCard
|
||||
@@ -321,8 +327,8 @@ export default function DashboardPage() {
|
||||
change={kpis.vehiclesChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Car}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
iconColor="text-emerald-700 dark:text-emerald-300"
|
||||
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
|
||||
/>
|
||||
{canViewRevenue ? (
|
||||
<StatCard
|
||||
@@ -331,8 +337,8 @@ export default function DashboardPage() {
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
iconColor="text-orange-700 dark:text-orange-300"
|
||||
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
|
||||
valueGradient
|
||||
/>
|
||||
) : null}
|
||||
@@ -342,8 +348,8 @@ export default function DashboardPage() {
|
||||
change={kpis.customersChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Users}
|
||||
iconColor="text-purple-600"
|
||||
iconBg="bg-purple-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -354,8 +360,8 @@ export default function DashboardPage() {
|
||||
title="Utilization Rate"
|
||||
value={`${kpis.utilizationRate}%`}
|
||||
icon={TrendingUp}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
progressPercent={kpis.utilizationRate}
|
||||
target={100}
|
||||
/>
|
||||
@@ -363,15 +369,15 @@ export default function DashboardPage() {
|
||||
title="Revenue / Vehicle"
|
||||
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
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-green-600"
|
||||
iconBg="bg-green-50"
|
||||
iconColor="text-emerald-700 dark:text-emerald-300"
|
||||
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
|
||||
progressPercent={kpis.fulfillmentRate ?? 0}
|
||||
target={100}
|
||||
/>
|
||||
@@ -382,19 +388,20 @@ export default function DashboardPage() {
|
||||
{/* Left column — 2/3: Booking Sources + Location Performance */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={chartGrid} />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
|
||||
<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="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>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -414,13 +421,13 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Right column — 1/3: Quick Stats */}
|
||||
<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">
|
||||
{(data?.sourceBreakdown ?? []).map((item) => (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,16 +441,16 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent Reservations */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
|
||||
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
<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-blue-950 dark:text-slate-100">{d.recentReservations}</h2>
|
||||
<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}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<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.colCustomer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
|
||||
@@ -452,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>
|
||||
</tr>
|
||||
</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) => (
|
||||
<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">
|
||||
<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}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500">
|
||||
<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 dark:text-slate-300">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatDate(res.startDate)} – {formatDateYear(res.endDate)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
@@ -470,7 +477,7 @@ export default function DashboardPage() {
|
||||
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
|
||||
</span>
|
||||
</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')}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -55,6 +55,169 @@ html.dark body {
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -14,6 +14,11 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
icons: {
|
||||
icon: '/dashboard/icon.svg',
|
||||
shortcut: '/dashboard/icon.svg',
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
|
||||
|
||||
@@ -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 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'
|
||||
import { Suspense } from 'react'
|
||||
import SignUpForm from './SignUpForm'
|
||||
|
||||
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 l’entreprise 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 l’entreprise requises pour créer l’espace.',
|
||||
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 l’espace entreprise et démarrer l’essai de 90 jours avec la formule choisie.',
|
||||
firstName: 'Prénom du gérant/propriétaire',
|
||||
lastName: 'Nom du gérant/propriétaire',
|
||||
ownerEmail: 'E-mail du gérant/propriétaire',
|
||||
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, l’entreprise 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 d’agré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 l’entreprise',
|
||||
fax: 'Fax (optionnel)',
|
||||
yearsActive: 'Années d’activité',
|
||||
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 d’identité',
|
||||
responsibleQualification: 'Qualification / diplôme / expérience',
|
||||
responsiblePhone: 'Téléphone du responsable',
|
||||
responsibleEmail: 'E-mail du responsable',
|
||||
identitySection: 'Identité juridique',
|
||||
contactSection: 'Coordonnées de l’entreprise',
|
||||
representativeSection: 'Représentant légal',
|
||||
responsibleSection: 'Responsable désigné',
|
||||
continue: 'Continuer',
|
||||
back: 'Retour',
|
||||
working: 'Traitement…',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
workspaceReady: 'Espace prêt',
|
||||
workspaceReadyBody: 'est maintenant actif et l’e-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 d’au 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 l’espace',
|
||||
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 d’activité',
|
||||
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 (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<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>
|
||||
<Suspense fallback={null}>
|
||||
<SignUpForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
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 l’e-mail de confirmation n’a 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 l’envoi d’e-mails n’est 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 { useEffect, useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { toDashboardAppPath, toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
import { toDashboardAppPath } from '@/lib/dashboardPaths'
|
||||
|
||||
type GeneratedMenuItem = {
|
||||
id: string
|
||||
@@ -16,27 +16,35 @@ type EmployeeMenuResponse = {
|
||||
items: GeneratedMenuItem[]
|
||||
}
|
||||
|
||||
function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
|
||||
export function flattenInternalRoutes(items: GeneratedMenuItem[]): string[] {
|
||||
const routes: string[] = []
|
||||
|
||||
const walk = (entry: GeneratedMenuItem) => {
|
||||
if (entry.itemType === 'INTERNAL_PAGE' && entry.routeOrUrl) {
|
||||
routes.push(entry.routeOrUrl)
|
||||
routes.push(toDashboardAppPath(entry.routeOrUrl))
|
||||
}
|
||||
entry.children.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) => {
|
||||
if (route === '/') return currentPath === '/'
|
||||
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 }) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
@@ -53,10 +61,10 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
|
||||
if (cancelled) return
|
||||
|
||||
const allowedRoutes = flattenInternalRoutes(menu.items)
|
||||
const fallbackRoute = allowedRoutes[0] ?? '/'
|
||||
const redirectPath = resolveAccessRedirect(currentPath, allowedRoutes)
|
||||
|
||||
if (!isAllowedRoute(currentPath, allowedRoutes)) {
|
||||
router.replace(toPublicDashboardPath(fallbackRoute))
|
||||
if (redirectPath) {
|
||||
router.replace(redirectPath)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -65,7 +73,7 @@ export default function DashboardAccessGuard({ children }: { children: React.Rea
|
||||
if (cancelled) return
|
||||
|
||||
if (error?.statusCode === 401 || error?.statusCode === 403 || error?.statusCode === 402) {
|
||||
router.replace(toPublicDashboardPath('/'))
|
||||
router.replace('/')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
@@ -102,7 +101,7 @@ export default function PublicHeader() {
|
||||
const signUpParams = new URLSearchParams()
|
||||
signUpParams.set('lang', language)
|
||||
signUpParams.set('theme', theme)
|
||||
const signUpHref = `/sign-up?${signUpParams.toString()}`
|
||||
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
|
||||
|
||||
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)]">
|
||||
@@ -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">
|
||||
{dict.signIn}
|
||||
</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}
|
||||
</Link>
|
||||
</a>
|
||||
</nav>
|
||||
<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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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>) {
|
||||
if (typeof window === 'undefined' || window.parent === window) return
|
||||
window.parent.postMessage(message, '*')
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { dict, language, setLanguage, theme } = useDashboardI18n()
|
||||
const { dict, language, setLanguage } = useDashboardI18n()
|
||||
const isRtl = language === 'ar'
|
||||
const pathname = usePathname()
|
||||
const appPath = toDashboardAppPath(pathname)
|
||||
@@ -248,8 +258,9 @@ export default function Sidebar() {
|
||||
|
||||
const isGeneratedItemActive = (item: GeneratedMenuItem) => {
|
||||
if (!item.routeOrUrl || item.itemType !== 'INTERNAL_PAGE') return false
|
||||
if (item.routeOrUrl === '/') return appPath === '/'
|
||||
return appPath.startsWith(item.routeOrUrl)
|
||||
const route = toDashboardAppPath(item.routeOrUrl)
|
||||
if (route === '/') return appPath === '/'
|
||||
return appPath === route || appPath.startsWith(`${route}/`)
|
||||
}
|
||||
|
||||
const fallbackMenuItems = NAV_ITEMS
|
||||
@@ -267,7 +278,8 @@ export default function Sidebar() {
|
||||
children: [],
|
||||
}))
|
||||
|
||||
const resolvedMenuItems = menuItems ?? fallbackMenuItems
|
||||
const useGeneratedMenu = hasRenderableMenuItems(menuItems)
|
||||
const resolvedMenuItems = useGeneratedMenu && menuItems ? menuItems : fallbackMenuItems
|
||||
|
||||
function renderGeneratedMenu(items: GeneratedMenuItem[], depth = 0): ReactNode {
|
||||
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
|
||||
|
||||
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') {
|
||||
@@ -290,7 +302,7 @@ export default function Sidebar() {
|
||||
if (item.itemType === 'PARENT_MENU') {
|
||||
return (
|
||||
<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}
|
||||
{label}
|
||||
</div>
|
||||
@@ -301,11 +313,11 @@ export default function Sidebar() {
|
||||
|
||||
const active = mounted && isGeneratedItemActive(item)
|
||||
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,
|
||||
active
|
||||
? 'bg-orange-500 text-white shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
? '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(' ')
|
||||
|
||||
if (item.itemType === 'EXTERNAL_LINK' && item.routeOrUrl) {
|
||||
@@ -323,8 +335,10 @@ export default function Sidebar() {
|
||||
)
|
||||
}
|
||||
|
||||
const href = item.itemType === 'INTERNAL_PAGE' ? toDashboardAppPath(item.routeOrUrl) : (item.routeOrUrl || '/')
|
||||
|
||||
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}
|
||||
{label}
|
||||
</Link>
|
||||
@@ -353,6 +367,7 @@ export default function Sidebar() {
|
||||
{/* Tab to open sidebar — only visible when sidebar is closed on mobile */}
|
||||
{!open && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="Open sidebar"
|
||||
className={[
|
||||
@@ -366,10 +381,7 @@ export default function Sidebar() {
|
||||
|
||||
<aside
|
||||
className={[
|
||||
'fixed inset-y-0 z-40 flex w-64 flex-col border-r transition-colors duration-300',
|
||||
theme === 'dark'
|
||||
? 'border-blue-900/50 bg-blue-950/80 backdrop-blur-xl'
|
||||
: 'border-stone-200/80 bg-white/92 backdrop-blur-xl shadow-[4px_0_20px_rgba(0,0,0,0.04)]',
|
||||
'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',
|
||||
'lg:translate-x-0',
|
||||
open ? 'translate-x-0' : (isRtl ? 'translate-x-full' : '-translate-x-full'),
|
||||
@@ -378,9 +390,9 @@ export default function Sidebar() {
|
||||
<a
|
||||
href={marketplaceUrl}
|
||||
target="_top"
|
||||
className="flex items-center gap-3 border-b px-6 py-5 transition-colors border-blue-900/50 dark:border-blue-900/50"
|
||||
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-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-orange-400 to-orange-500 shadow-sm">
|
||||
<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 ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={brand.logoUrl} alt={brand.displayName} className="h-8 w-8 object-cover" />
|
||||
@@ -388,15 +400,13 @@ export default function Sidebar() {
|
||||
<Car className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<span className={`truncate text-sm font-bold tracking-wide ${
|
||||
theme === 'dark' ? 'text-white' : 'text-blue-950'
|
||||
}`}>
|
||||
<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'}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
{menuItems ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
|
||||
<nav className="flex-1 space-y-1.5 overflow-y-auto px-3 py-5">
|
||||
{useGeneratedMenu ? renderGeneratedMenu(resolvedMenuItems) : NAV_ITEMS.filter((item) => !mounted || hasMinRole(role, item.minRole)).map((item) => {
|
||||
const Icon = item.icon
|
||||
const active = mounted && isActive(item)
|
||||
return (
|
||||
@@ -404,37 +414,48 @@ export default function Sidebar() {
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
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 shadow-[0_0_12px_rgba(249,115,22,0.3)] dark:shadow-[0_0_16px_rgba(249,115,22,0.35)]'
|
||||
: 'text-slate-400 hover:bg-blue-900/40 hover:text-white',
|
||||
? '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(' ')}
|
||||
>
|
||||
<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]}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-blue-900/50 px-3 py-4">
|
||||
<div className="flex items-center gap-3 rounded-2xl px-3 py-2">
|
||||
<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="border-t border-blue-200/70 px-3 py-4 dark:border-blue-400/10">
|
||||
<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 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}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">{user.displayName}</p>
|
||||
<p className="truncate text-xs text-slate-400">{user.subtitle}</p>
|
||||
<p className="truncate text-sm font-medium text-blue-950 dark:text-white">{user.displayName}</p>
|
||||
<p className="truncate text-xs text-slate-500 dark:text-slate-400">{user.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
{dict.signOut}
|
||||
</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 />
|
||||
<DashboardThemeSwitcher />
|
||||
</div>
|
||||
@@ -442,6 +463,7 @@ export default function Sidebar() {
|
||||
|
||||
{/* Arrow tab to collapse sidebar (mobile only, sticks to outer edge) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sidebar"
|
||||
className={[
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'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 { useState, useEffect } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
@@ -48,6 +49,7 @@ export default function TopBar() {
|
||||
createdAt: string
|
||||
}>>([])
|
||||
const [loadingNotifs, setLoadingNotifs] = useState(false)
|
||||
const [socketEnabled, setSocketEnabled] = useState(false)
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => { setMounted(true) }, [])
|
||||
@@ -63,8 +65,10 @@ export default function TopBar() {
|
||||
try {
|
||||
const data = await apiFetch<{ unread: number }>('/notifications/unread-count')
|
||||
setUnreadCount(data.unread)
|
||||
setSocketEnabled(true)
|
||||
} catch {
|
||||
setUnreadCount(0)
|
||||
setSocketEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +85,8 @@ export default function TopBar() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!socketEnabled) return
|
||||
|
||||
const socketOrigin = resolveSocketOrigin()
|
||||
if (!socketOrigin) return
|
||||
|
||||
@@ -106,7 +112,7 @@ export default function TopBar() {
|
||||
socket.off('notification', handleNotification)
|
||||
socket.disconnect()
|
||||
}
|
||||
}, [])
|
||||
}, [socketEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showNotifs) return
|
||||
@@ -188,52 +194,54 @@ export default function TopBar() {
|
||||
}
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<h1 className="text-lg font-semibold text-blue-950 dark:text-stone-50 shrink-0">{title}</h1>
|
||||
<div className="hidden sm:block relative max-w-xs w-full">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-stone-400 dark:text-stone-500 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
</svg>
|
||||
<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…"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-stone-50/60 pl-9 pr-4 py-2 text-sm text-stone-900 placeholder:text-stone-400 transition-colors focus:border-orange-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-orange-500/20 dark:border-blue-800 dark:bg-blue-950/30 dark:text-slate-100 dark:placeholder:text-slate-500 dark:focus:border-orange-400 dark:focus:bg-blue-950/50"
|
||||
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="relative">
|
||||
<button
|
||||
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" />
|
||||
{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}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{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">
|
||||
<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
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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 ? (
|
||||
<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)}
|
||||
</p>
|
||||
) : (
|
||||
@@ -244,14 +252,14 @@ export default function TopBar() {
|
||||
type="button"
|
||||
onClick={() => openNotificationsInbox(notification.id)}
|
||||
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' : '',
|
||||
].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}
|
||||
</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}
|
||||
</p>
|
||||
</button>
|
||||
@@ -262,7 +270,15 @@ export default function TopBar() {
|
||||
) : null}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -36,16 +36,16 @@ export default function StatCard({
|
||||
const isNegative = change !== undefined && change < 0
|
||||
|
||||
return (
|
||||
<div className={`card p-6 ${pulse ? 'pulse-glow' : ''}`}>
|
||||
<div className={`card p-5 ${pulse ? 'pulse-glow' : ''}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<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',
|
||||
'mt-2 text-[34px] font-light leading-none tracking-normal',
|
||||
valueGradient
|
||||
? 'bg-gradient-to-r from-blue-600 to-orange-500 bg-clip-text text-transparent dark:from-blue-400 dark:to-orange-300'
|
||||
: 'text-blue-950 dark:text-stone-50',
|
||||
? '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}
|
||||
@@ -58,7 +58,7 @@ export default function StatCard({
|
||||
style={{ width: `${Math.min(progressPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||
{Math.round(progressPercent)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -68,16 +68,16 @@ export default function StatCard({
|
||||
<span
|
||||
className={[
|
||||
'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(' ')}
|
||||
>
|
||||
{isPositive ? '+' : ''}{change.toFixed(1)}%
|
||||
</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 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}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,13 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
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 () => {
|
||||
const { default: middleware } = await loadMiddleware()
|
||||
@@ -64,15 +71,6 @@ describe('dashboard middleware', () => {
|
||||
|
||||
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 () => {
|
||||
const { default: middleware } = await loadMiddleware('https://rentaldrivego.example')
|
||||
|
||||
@@ -1,17 +1,51 @@
|
||||
import { NextResponse } 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 DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function toDashboardAppPath(pathname: string): string {
|
||||
let normalized = pathname || '/'
|
||||
|
||||
function rejectInternalSubrequest(req: NextRequest): NextResponse | null {
|
||||
if (!req.headers.has('x-middleware-subrequest')) return null
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
while (normalized === DASHBOARD_BASE_PATH || normalized.startsWith(`${DASHBOARD_BASE_PATH}/`)) {
|
||||
normalized = normalized.slice(DASHBOARD_BASE_PATH.length) || '/'
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function dedupeDashboardPath(pathname: string): string {
|
||||
return pathname.replace(/^\/dashboard\/dashboard(?=\/|$)/, '/dashboard')
|
||||
function toPublicDashboardPath(pathname: string): string {
|
||||
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 {
|
||||
@@ -21,38 +55,25 @@ function isInternalHost(host: string | null): boolean {
|
||||
}
|
||||
|
||||
function isProtectedRoute(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl
|
||||
if (PUBLIC_DASHBOARD_PREFIXES.some((p) => pathname === p || pathname.startsWith(p + '/'))) return false
|
||||
return pathname === '/dashboard' || pathname.startsWith('/dashboard/')
|
||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||
if (['/sign-in', '/sign-up', '/forgot-password', '/reset-password', '/onboarding'].some((p) => pathname === p || pathname.startsWith(p + '/'))) return false
|
||||
return pathname === '/' || (pathname.startsWith('/') && !pathname.startsWith('/_next'))
|
||||
}
|
||||
|
||||
function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
const token = req.cookies.get('employee_session')?.value
|
||||
if (token && req.nextUrl.pathname === '/dashboard/sign-in') {
|
||||
const dashboardUrl = req.nextUrl.clone()
|
||||
dashboardUrl.pathname = '/dashboard'
|
||||
dashboardUrl.search = ''
|
||||
const pathname = toDashboardAppPath(req.nextUrl.pathname)
|
||||
|
||||
// Redirect signed-in users from sign-in to dashboard (through marketplace proxy)
|
||||
if (token && pathname === '/sign-in') {
|
||||
const dashboardUrl = resolveProxyUrl(req, DASHBOARD_BASE_PATH)
|
||||
return NextResponse.redirect(dashboardUrl)
|
||||
}
|
||||
|
||||
if (!isProtectedRoute(req)) return NextResponse.next()
|
||||
|
||||
if (!token) {
|
||||
const forwardedHost = req.headers.get('x-forwarded-host')
|
||||
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'
|
||||
const signInUrl = resolveProxyUrl(req, toPublicDashboardPath('/sign-in'))
|
||||
signInUrl.searchParams.set('redirect', req.nextUrl.pathname)
|
||||
return NextResponse.redirect(signInUrl)
|
||||
}
|
||||
@@ -60,11 +81,12 @@ function localJwtMiddleware(req: NextRequest): NextResponse {
|
||||
}
|
||||
|
||||
export default function middleware(req: NextRequest) {
|
||||
const rejected = rejectInternalSubrequest(req)
|
||||
if (rejected) return rejected
|
||||
if (req.headers.has('x-middleware-subrequest')) {
|
||||
return new NextResponse('Unsupported internal request header', { status: 400 })
|
||||
}
|
||||
|
||||
const dedupedPath = dedupeDashboardPath(req.nextUrl.pathname)
|
||||
if (dedupedPath !== req.nextUrl.pathname) {
|
||||
const dedupedPath = deduplicatePublicDashboardPath(req.nextUrl.pathname)
|
||||
if (dedupedPath) {
|
||||
const redirectUrl = req.nextUrl.clone()
|
||||
redirectUrl.pathname = dedupedPath
|
||||
return NextResponse.redirect(redirectUrl)
|
||||
|
||||
@@ -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: '*',
|
||||
},
|
||||
]),
|
||||
},
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -1,11 +1,15 @@
|
||||
/** @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.
|
||||
// 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({
|
||||
assetSources: [process.env.DASHBOARD_ASSET_PREFIX, process.env.ADMIN_ASSET_PREFIX],
|
||||
assetSources: [dashboardAssetSource, process.env.ADMIN_ASSET_PREFIX],
|
||||
})
|
||||
const nextConfig = {
|
||||
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() {
|
||||
const dashboardOrigin = process.env.DASHBOARD_INTERNAL_URL ?? 'http://dashboard:3001'
|
||||
const adminOrigin = process.env.ADMIN_INTERNAL_URL ?? 'http://admin:3002'
|
||||
|
||||
return [
|
||||
// Dashboard has basePath '/dashboard'. The proxy preserves the prefix
|
||||
// so the dashboard can strip it and route internally.
|
||||
{
|
||||
source: '/dashboard',
|
||||
destination: `${dashboardOrigin}/dashboard`,
|
||||
@@ -38,6 +58,7 @@ const nextConfig = {
|
||||
source: '/dashboard/:path*',
|
||||
destination: `${dashboardOrigin}/dashboard/:path*`,
|
||||
},
|
||||
// Admin routes (also uses basePath)
|
||||
{
|
||||
source: '/admin',
|
||||
destination: `${adminOrigin}/admin`,
|
||||
|
||||
@@ -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 |
@@ -8,13 +8,19 @@ const nextServer = vi.hoisted(() => {
|
||||
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 }) => ({
|
||||
kind: 'response',
|
||||
body,
|
||||
status: init?.status,
|
||||
})) as any
|
||||
MockNextResponse.next = next
|
||||
return { next, MockNextResponse }
|
||||
MockNextResponse.redirect = redirect
|
||||
return { next, redirect, MockNextResponse }
|
||||
})
|
||||
|
||||
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 headers = new Headers(options.headers ?? {})
|
||||
return {
|
||||
nextUrl: cloneableUrl('http://localhost:3000/'),
|
||||
cookies: {
|
||||
get: vi.fn((name: string) => {
|
||||
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(() => {
|
||||
nextServer.next.mockClear()
|
||||
nextServer.redirect.mockClear()
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { NextRequest } from 'next/server'
|
||||
|
||||
const SHARED_LANGUAGE_COOKIE = 'rentaldrivego-language'
|
||||
const MARKETPLACE_LANGUAGE_COOKIE = 'marketplace-language'
|
||||
const DASHBOARD_BASE_PATH = '/dashboard'
|
||||
|
||||
function rejectInternalSubrequest(request: NextRequest): NextResponse | null {
|
||||
if (!request.headers.has('x-middleware-subrequest')) return null
|
||||
@@ -22,10 +23,27 @@ function detectFromAcceptLanguage(header: string | null): 'en' | 'fr' | 'ar' {
|
||||
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) {
|
||||
const rejected = rejectInternalSubrequest(request)
|
||||
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
|
||||
|
||||
if (isValidLanguage(sharedLang)) return NextResponse.next()
|
||||
@@ -53,6 +71,7 @@ export function proxy(request: NextRequest) {
|
||||
|
||||
export const config = {
|
||||
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)$).*)',
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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¤cy=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
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
@@ -7,7 +7,7 @@ ensureDatabaseUrl()
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
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 firstName = process.env.ADMIN_SEED_FIRST_NAME ?? 'Super'
|
||||
const lastName = process.env.ADMIN_SEED_LAST_NAME ?? 'Admin'
|
||||
|
||||
Reference in New Issue
Block a user