chore: bulk commit of pre-existing changes
Build & Deploy / Build & Push Docker Image (push) Successful in 48s
Test / API Unit Tests (push) Successful in 9m48s
Test / Marketplace Unit Tests (push) Successful in 9m38s
Test / Admin Unit Tests (push) Successful in 9m33s
Build & Deploy / Deploy to VPS (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled

Includes:
- Admin dashboard layout and page updates
- API account auth module (routes, schemas, service)
- Dashboard sign-up form extraction, middleware refactor
- Marketplace proxy and middleware updates
- CORS origins expansion for dev environment
- Seed email domain correction (.com → .ma)
- Docs: create-account guide, fleet page, signup plan
- Various UI component and layout refinements
This commit is contained in:
root
2026-06-25 00:57:59 -04:00
parent 572d115003
commit 3b142ca4c7
36 changed files with 2987 additions and 1156 deletions
@@ -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>
+59 -52
View File
@@ -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>
+163
View File
@@ -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);
+5
View File
@@ -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 lentreprise et lancez votre espace.',
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérification'],
ownerTitle: 'Compte utilisateur',
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
partnerTitle: 'Infos entreprise',
partnerBody: 'Complétez les informations de lentreprise requises pour créer lespace.',
planTitle: 'Choisissez votre formule',
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
reviewTitle: 'Vérification et lancement',
reviewBody: 'Nous allons créer immédiatement lespace entreprise et démarrer lessai de 90 jours avec la formule choisie.',
firstName: 'Prénom du gérant/propriétaire',
lastName: 'Nom du gérant/propriétaire',
ownerEmail: 'E-mail du gérant/propriétaire',
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, lentreprise et le responsable.',
password: 'Mot de passe',
confirmPassword: 'Confirmer le mot de passe',
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
companyName: 'Nom commercial',
legalName: 'Raison sociale',
legalForm: 'Forme juridique',
registrationNumber: 'Registre du commerce (RC)',
iceNumber: 'Numéro ICE',
taxId: 'Identifiant fiscal (IF)',
operatingLicenseNumber: 'Numéro dagrément',
operatingLicenseIssuedAt: 'Date de délivrance',
operatingLicenseIssuedBy: 'Autorité délivrante',
streetAddress: 'Adresse',
city: 'Ville',
country: 'Pays',
zipCode: 'Code postal',
companyPhone: 'Téléphone',
companyEmail: 'E-mail de contact de lentreprise',
fax: 'Fax (optionnel)',
yearsActive: 'Années dactivité',
representativeName: 'Nom et prénom du représentant',
representativeTitle: 'Fonction du représentant',
responsibleName: 'Nom et prénom du responsable',
responsibleRole: 'Qualité du responsable',
responsibleIdentityNumber: 'N° de pièce didentité',
responsibleQualification: 'Qualification / diplôme / expérience',
responsiblePhone: 'Téléphone du responsable',
responsibleEmail: 'E-mail du responsable',
identitySection: 'Identité juridique',
contactSection: 'Coordonnées de lentreprise',
representativeSection: 'Représentant légal',
responsibleSection: 'Responsable désigné',
continue: 'Continuer',
back: 'Retour',
working: 'Traitement…',
createWorkspace: 'Créer lespace',
workspaceReady: 'Espace prêt',
workspaceReadyBody: 'est maintenant actif et le-mail propriétaire',
enterDashboard: 'Accéder au tableau de bord',
signInAnotherDevice: 'Se connecter sur un autre appareil',
reviewWorkspace: 'Espace',
reviewPlan: 'Formule',
reviewOwnerEmail: 'E-mail propriétaire',
reviewCompanyEmail: 'E-mail entreprise',
reviewPhone: 'Téléphone',
reviewLegalName: 'Raison sociale',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'Choisissez un mot de passe dau moins 8 caractères.',
passwordMismatch: 'Les mots de passe ne correspondent pas.',
missingRequired: 'Renseignez tous les champs obligatoires.',
missingLanguage: 'Veuillez sélectionner votre langue préférée.',
preferredLanguageLabel: 'Langue préférée',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'Votre entreprise',
couldNotCreate: 'Impossible de créer lespace',
legalFormOptions: {
'': 'Sélectionner la forme juridique',
SARL: 'SARL',
'SARL AU': 'SARL AU',
SA: 'SA',
SAS: 'SAS',
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
EI: 'Entreprise individuelle',
OTHER: 'Autre',
} as Record<string, string>,
yearsActiveOptions: {
'': 'Sélectionner les années dactivité',
LESS_THAN_1: 'Moins de 1 an',
ONE_TO_FIVE: '1 an à 5 ans',
MORE_THAN_5: 'Plus de 5 ans',
} as Record<string, string>,
planDescriptions: {
STARTER: 'Flotte, offres et réservations essentielles.',
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
PRO: 'White-label complet et contrôles premium.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'Mensuel',
ANNUAL: 'Annuel',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
ar: {
brand: 'RentalDriveGo',
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
ownerTitle: 'حساب المستخدم',
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
partnerTitle: 'معلومات الشركة',
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
planTitle: 'اختر خطتك',
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
reviewTitle: 'راجع وأطلق',
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
firstName: 'الاسم الأول للمدير/المالك',
lastName: 'اسم العائلة للمدير/المالك',
ownerEmail: 'بريد المدير/المالك الإلكتروني',
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
password: 'كلمة المرور',
confirmPassword: 'تأكيد كلمة المرور',
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
companyName: 'الاسم التجاري',
legalName: 'الاسم القانوني للشركة',
legalForm: 'الشكل القانوني',
registrationNumber: 'السجل التجاري (RC)',
iceNumber: 'رقم ICE',
taxId: 'المعرف الضريبي (IF)',
operatingLicenseNumber: 'رقم الترخيص',
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
operatingLicenseIssuedBy: 'الجهة المانحة',
streetAddress: 'عنوان الشارع',
city: 'المدينة',
country: 'الدولة',
zipCode: 'الرمز البريدي',
companyPhone: 'الهاتف',
companyEmail: 'بريد الشركة للتواصل',
fax: 'الفاكس (اختياري)',
yearsActive: 'سنوات النشاط',
representativeName: 'اسم الممثل القانوني',
representativeTitle: 'صفة الممثل القانوني',
responsibleName: 'اسم المسؤول',
responsibleRole: 'صفة المسؤول',
responsibleIdentityNumber: 'رقم وثيقة الهوية',
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
responsiblePhone: 'هاتف المسؤول',
responsibleEmail: 'بريد المسؤول الإلكتروني',
identitySection: 'الهوية القانونية',
contactSection: 'بيانات الشركة',
representativeSection: 'الممثل القانوني',
responsibleSection: 'الشخص المسؤول',
continue: 'متابعة',
back: 'رجوع',
working: 'جارٍ التنفيذ…',
createWorkspace: 'إنشاء المساحة',
workspaceReady: 'المساحة جاهزة',
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
enterDashboard: 'الدخول إلى لوحة التحكم',
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
reviewWorkspace: 'المساحة',
reviewPlan: 'الخطة',
reviewOwnerEmail: 'بريد المالك',
reviewCompanyEmail: 'بريد الشركة',
reviewPhone: 'الهاتف',
reviewLegalName: 'الاسم القانوني',
reviewRegistration: 'RC / ICE / IF',
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
missingRequired: 'أكمل جميع الحقول الإلزامية.',
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
preferredLanguageLabel: 'اللغة المفضلة',
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
companyFallback: 'شركتك',
couldNotCreate: 'تعذر إنشاء المساحة',
legalFormOptions: {
'': 'اختر الشكل القانوني',
SARL: 'شركة ذات مسؤولية محدودة',
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
SA: 'شركة مساهمة',
SAS: 'شركة مساهمة مبسطة',
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
EI: 'مؤسسة فردية',
OTHER: 'أخرى',
} as Record<string, string>,
yearsActiveOptions: {
'': 'اختر سنوات النشاط',
LESS_THAN_1: 'أقل من سنة',
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
MORE_THAN_5: 'أكثر من 5 سنوات',
} as Record<string, string>,
planDescriptions: {
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
} as Record<SignupForm['plan'], string>,
billingPeriodOptions: {
MONTHLY: 'شهري',
ANNUAL: 'سنوي',
} as Record<SignupForm['billingPeriod'], string>,
paymentProviderOptions: {
AMANPAY: 'AmanPay',
PAYPAL: 'PayPal',
} as Record<SignupForm['paymentProvider'], string>,
},
}[language]
function handleStep1Continue() {
if (!form.preferredLanguage) {
setError(dict.missingLanguage)
return
}
const ownerEmail = normalizeEmail(form.email)
setForm((current) => ({
...current,
companyEmail: current.companyEmail || ownerEmail,
responsibleEmail: current.responsibleEmail || ownerEmail,
}))
setError(null)
setStep(2)
}
function handleStep2Continue() {
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setError(null)
setStep(3)
}
async function submitSignup() {
if (form.password.length < 8) {
setError(dict.invalidPassword)
return
}
if (form.password !== form.confirmPassword) {
setError(dict.passwordMismatch)
return
}
if (!hasRequiredPartnerFields(form)) {
setError(dict.missingRequired)
return
}
setLoading(true)
setError(null)
try {
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
method: 'POST',
body: JSON.stringify({
firstName: form.firstName.fr || form.firstName.ar,
firstNameAr: form.firstName.ar || undefined,
lastName: form.lastName.fr || form.lastName.ar,
lastNameAr: form.lastName.ar || undefined,
email: form.email,
password: form.password,
preferredLanguage: form.preferredLanguage || 'en',
companyName: form.companyName.fr || form.companyName.ar,
legalName: form.legalName,
companyNameAr: form.companyName.ar || undefined,
legalForm: form.legalForm,
registrationNumber: form.registrationNumber,
iceNumber: form.iceNumber,
taxId: form.taxId,
operatingLicenseNumber: form.operatingLicenseNumber,
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
streetAddressAr: form.streetAddress.ar || undefined,
city: form.city.fr || form.city.ar,
cityAr: form.city.ar || undefined,
country: form.country.fr || form.country.ar,
countryAr: form.country.ar || undefined,
zipCode: form.zipCode,
companyPhone: form.companyPhone,
companyEmail: form.companyEmail,
fax: form.fax || undefined,
yearsActive: form.yearsActive,
representativeName: form.representativeName || undefined,
representativeTitle: form.representativeTitle || undefined,
responsibleName: form.responsibleName,
responsibleRole: form.responsibleRole,
responsibleIdentityNumber: form.responsibleIdentityNumber,
responsibleQualification: form.responsibleQualification || undefined,
responsiblePhone: form.responsiblePhone,
responsibleEmail: form.responsibleEmail,
plan: form.plan,
billingPeriod: form.billingPeriod,
currency: form.currency,
paymentProvider: form.paymentProvider,
}),
})
setCompletedSignup({
companyName: form.companyName.fr || form.companyName.ar,
email: form.email,
emailWarning: getEmailWarning(response.emailDelivery, language),
})
} catch (err) {
setError(getErrorMessage(err, dict.couldNotCreate))
} finally {
setLoading(false)
}
}
if (completedSignup) {
return (
<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 le-mail de confirmation na pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
}
if (language === 'fr') return 'Le compte a été créé, mais lenvoi de-mails nest pas encore configuré sur cet environnement.'
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
return 'The account was created, but email sending is not configured on this environment yet.'
}
function getErrorMessage(error: unknown, fallback: string) {
if (error instanceof Error && error.message) return error.message
return fallback
}
function normalizeEmail(value: string) {
return value.replace(/\s+/g, '').toLowerCase()
}
function normalizeTrimmed(value: string) {
return value.replace(/^\s+/, '')
}
function normalizeUpper(value: string) {
return value.replace(/^\s+/, '').toUpperCase()
}
function LabeledInput({
label,
value,
onChange,
type = 'text',
required = false,
maxLength,
}: {
label: string
value: string
onChange: (value: string) => void
type?: string
required?: boolean
maxLength?: number
}) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
</label>
)
}
function LabeledSelect({
label,
value,
options,
onChange,
labels,
required = false,
}: {
label: string
value: string
options: readonly string[]
onChange: (value: string) => void
labels?: Record<string, string>
required?: boolean
}) {
return (
<label className="block">
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{label}
{required ? <span className="ml-1 text-red-600">*</span> : null}
</span>
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
{options.map((option) => (
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
))}
</select>
</label>
)
}
function NavActions({
onBack,
onNext,
loading = false,
nextLabel = 'Continue',
backLabel = 'Back',
loadingLabel = 'Working…',
}: {
onBack?: () => void
onNext: () => void
loading?: boolean
nextLabel?: string
backLabel?: string
loadingLabel?: string
}) {
return (
<div className="flex gap-3">
{onBack ? (
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
{backLabel}
</button>
) : null}
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
{loading ? loadingLabel : nextLabel}
</button>
</div>
)
}
function normalizePlan(value: string | null): SignupForm['plan'] {
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
return 'STARTER'
}
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
}
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
return 'MAD'
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { flattenInternalRoutes, isAllowedRoute, resolveAccessRedirect } from './DashboardAccessGuard'
describe('DashboardAccessGuard route helpers', () => {
it('normalizes dashboard-prefixed menu routes before checking access', () => {
const routes = flattenInternalRoutes([
{
id: 'dashboard',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard',
children: [],
},
{
id: 'fleet',
itemType: 'INTERNAL_PAGE',
routeOrUrl: '/dashboard/fleet',
children: [],
},
{
id: 'external',
itemType: 'EXTERNAL_LINK',
routeOrUrl: '/dashboard/dashboard',
children: [],
},
])
expect(routes).toEqual(['/', '/fleet'])
expect(isAllowedRoute('/', routes)).toBe(true)
expect(isAllowedRoute('/fleet/123', routes)).toBe(true)
})
it('does not redirect forever when an authenticated employee has no visible menu routes', () => {
expect(resolveAccessRedirect('/', [])).toBeNull()
expect(resolveAccessRedirect('/fleet', [])).toBeNull()
})
it('redirects disallowed routes to the first visible internal route', () => {
expect(resolveAccessRedirect('/settings', ['/', '/fleet'])).toBe('/')
expect(resolveAccessRedirect('/fleet/123', ['/', '/fleet'])).toBeNull()
})
})
@@ -3,7 +3,7 @@
import { usePathname, useRouter } from 'next/navigation'
import { 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={[
+37 -21
View File
@@ -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>
+7 -9
View File
@@ -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')
+54 -32
View File
@@ -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)
+27
View File
@@ -0,0 +1,27 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
describe('dashboard next config', () => {
it('allows generated static media assets to be loaded across local app origins', async () => {
const nextConfig = require('../next.config.js')
const headers = await nextConfig.headers()
expect(headers).toEqual(
expect.arrayContaining([
{
source: '/_next/static/media/:path*',
headers: expect.arrayContaining([
{
key: 'Access-Control-Allow-Origin',
value: '*',
},
]),
},
]),
)
})
})