chore: bulk commit of pre-existing changes
Build & Deploy / Build & Push Docker Image (push) Successful in 48s
Test / API Unit Tests (push) Successful in 9m48s
Test / Marketplace Unit Tests (push) Successful in 9m38s
Test / Admin Unit Tests (push) Successful in 9m33s
Build & Deploy / Deploy to VPS (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Build & Deploy / Build & Push Docker Image (push) Successful in 48s
Test / API Unit Tests (push) Successful in 9m48s
Test / Marketplace Unit Tests (push) Successful in 9m38s
Test / Admin Unit Tests (push) Successful in 9m33s
Build & Deploy / Deploy to VPS (push) Has been cancelled
Test / Dashboard Unit Tests (push) Has been cancelled
Test / API Integration Tests (push) Has been cancelled
Includes: - Admin dashboard layout and page updates - API account auth module (routes, schemas, service) - Dashboard sign-up form extraction, middleware refactor - Marketplace proxy and middleware updates - CORS origins expansion for dev environment - Seed email domain correction (.com → .ma) - Docs: create-account guide, fleet page, signup plan - Various UI component and layout refinements
This commit is contained in:
@@ -4,7 +4,7 @@ import TopBar from '@/components/layout/TopBar'
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] print:block print:min-h-0 print:bg-white">
|
||||
<div className="fleet-dashboard-shell flex min-h-screen transition-colors print:block print:min-h-0 print:bg-white">
|
||||
<div className="print:hidden">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@ function QuickActionsSection({ actions }: { actions: QuickAction[] }) {
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className={`flex items-center gap-2 px-4 py-3 rounded-lg border border-slate-200 hover:border-slate-300 hover:bg-slate-50 transition-all ${action.color}`}
|
||||
className={`flex items-center gap-2 rounded-lg border border-blue-400/15 bg-blue-500/[0.06] px-4 py-3 transition-all hover:border-blue-400/35 hover:bg-blue-500/10 ${action.color}`}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="text-sm font-medium">{action.label}</span>
|
||||
@@ -122,17 +122,17 @@ function SubscriptionBanner({
|
||||
|
||||
const configs: Record<string, { bg: string; icon: React.ReactNode; message: string }> = {
|
||||
TRIALING: {
|
||||
bg: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
bg: 'bg-blue-500/10 border-blue-400/25 text-blue-200',
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
message: trialEndsAt ? trialEndsMsg(localeDate) : trialActiveMsg,
|
||||
},
|
||||
PAST_DUE: {
|
||||
bg: 'bg-orange-50 border-orange-200 text-orange-800',
|
||||
bg: 'bg-orange-500/10 border-orange-400/25 text-orange-200',
|
||||
icon: <AlertTriangle className="w-4 h-4" />,
|
||||
message: pastDueMsg,
|
||||
},
|
||||
SUSPENDED: {
|
||||
bg: 'bg-red-50 border-red-200 text-red-800',
|
||||
bg: 'bg-red-500/10 border-red-400/25 text-red-200',
|
||||
icon: <XCircle className="w-4 h-4" />,
|
||||
message: suspendedMsg,
|
||||
},
|
||||
@@ -164,15 +164,15 @@ function BranchPerformanceList({
|
||||
return (
|
||||
<div className="glass-card p-6">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4 text-orange-500" />
|
||||
<h2 className="text-base font-semibold text-blue-950 dark:text-stone-100">Location Performance</h2>
|
||||
<MapPin className="h-4 w-4 text-orange-700 dark:text-orange-300" />
|
||||
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">Location Performance</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.name}>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-blue-950 dark:text-stone-100">{loc.name}</span>
|
||||
<span className="text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<span className="text-sm font-medium text-blue-950 dark:text-slate-100">{loc.name}</span>
|
||||
<span className="text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||
{loc.bookingCount} bookings · {formatCurrencyFn(loc.revenue, 'MAD')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -183,7 +183,7 @@ function BranchPerformanceList({
|
||||
style={{ width: `${(loc.bookingCount / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-mono text-stone-500 dark:text-stone-400">
|
||||
<span className="w-8 text-right text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||
{loc.utilizationPct}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -195,10 +195,16 @@ function BranchPerformanceList({
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { dict, language } = useDashboardI18n()
|
||||
const { dict, language, theme } = useDashboardI18n()
|
||||
const d = dict.dashboardPage
|
||||
const nav = dict.nav
|
||||
const localeCode = language === 'fr' ? 'fr-FR' : language === 'ar' ? 'ar-MA' : 'en-US'
|
||||
const isDark = theme === 'dark'
|
||||
const chartGrid = isDark ? 'rgba(59,130,246,0.14)' : 'rgba(37,99,235,0.14)'
|
||||
const chartAxis = isDark ? 'rgba(59,130,246,0.20)' : 'rgba(37,99,235,0.22)'
|
||||
const chartText = isDark ? '#8899b4' : '#64748b'
|
||||
const chartTooltipBg = isDark ? '#111827' : '#ffffff'
|
||||
const chartTooltipText = isDark ? '#e8edf5' : '#172554'
|
||||
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -236,7 +242,7 @@ export default function DashboardPage() {
|
||||
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
|
||||
return (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-slate-500 text-sm">{errorMsg}</p>
|
||||
<p className="text-slate-500 text-sm dark:text-slate-400">{errorMsg}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -253,10 +259,10 @@ export default function DashboardPage() {
|
||||
<div className="space-y-6">
|
||||
{/* Welcome Section */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-1">
|
||||
<h1 className="mb-1 text-2xl font-semibold text-blue-950 dark:text-slate-50">
|
||||
Welcome back{employeeName ? `, ${employeeName}` : ''}!
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
<p className="mb-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
Here's what's happening with your fleet today
|
||||
</p>
|
||||
</div>
|
||||
@@ -268,25 +274,25 @@ export default function DashboardPage() {
|
||||
icon: <Plus className="w-4 h-4" />,
|
||||
label: 'New Reservation',
|
||||
href: '/reservations/new',
|
||||
color: 'text-blue-600',
|
||||
color: 'text-blue-700 dark:text-blue-300',
|
||||
},
|
||||
{
|
||||
icon: <Car className="w-4 h-4" />,
|
||||
label: nav.fleet,
|
||||
href: '/fleet',
|
||||
color: 'text-green-600',
|
||||
color: 'text-emerald-700 dark:text-emerald-300',
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
label: nav.customers,
|
||||
href: '/customers',
|
||||
color: 'text-purple-600',
|
||||
color: 'text-orange-700 dark:text-orange-300',
|
||||
},
|
||||
{
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
label: nav.reports,
|
||||
href: '/reports',
|
||||
color: 'text-orange-600',
|
||||
color: 'text-orange-700 dark:text-orange-300',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -311,8 +317,8 @@ export default function DashboardPage() {
|
||||
change={kpis.bookingsChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Calendar}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
pulse
|
||||
/>
|
||||
<StatCard
|
||||
@@ -321,8 +327,8 @@ export default function DashboardPage() {
|
||||
change={kpis.vehiclesChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Car}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
iconColor="text-emerald-700 dark:text-emerald-300"
|
||||
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
|
||||
/>
|
||||
{canViewRevenue ? (
|
||||
<StatCard
|
||||
@@ -331,8 +337,8 @@ export default function DashboardPage() {
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
iconColor="text-orange-700 dark:text-orange-300"
|
||||
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
|
||||
valueGradient
|
||||
/>
|
||||
) : null}
|
||||
@@ -342,8 +348,8 @@ export default function DashboardPage() {
|
||||
change={kpis.customersChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={Users}
|
||||
iconColor="text-purple-600"
|
||||
iconBg="bg-purple-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -354,8 +360,8 @@ export default function DashboardPage() {
|
||||
title="Utilization Rate"
|
||||
value={`${kpis.utilizationRate}%`}
|
||||
icon={TrendingUp}
|
||||
iconColor="text-blue-600"
|
||||
iconBg="bg-blue-50"
|
||||
iconColor="text-blue-700 dark:text-blue-300"
|
||||
iconBg="bg-blue-100/80 dark:bg-blue-500/10"
|
||||
progressPercent={kpis.utilizationRate}
|
||||
target={100}
|
||||
/>
|
||||
@@ -363,15 +369,15 @@ export default function DashboardPage() {
|
||||
title="Revenue / Vehicle"
|
||||
value={formatCurrency(kpis.revenuePerVehicle ?? 0, 'MAD')}
|
||||
icon={DollarSign}
|
||||
iconColor="text-orange-600"
|
||||
iconBg="bg-orange-50"
|
||||
iconColor="text-orange-700 dark:text-orange-300"
|
||||
iconBg="bg-orange-100/80 dark:bg-orange-500/10"
|
||||
/>
|
||||
<StatCard
|
||||
title="Fulfillment Rate"
|
||||
value={`${kpis.fulfillmentRate ?? 0}%`}
|
||||
icon={BarChart3}
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
iconColor="text-emerald-700 dark:text-emerald-300"
|
||||
iconBg="bg-emerald-100/80 dark:bg-emerald-500/10"
|
||||
progressPercent={kpis.fulfillmentRate ?? 0}
|
||||
target={100}
|
||||
/>
|
||||
@@ -382,19 +388,20 @@ export default function DashboardPage() {
|
||||
{/* Left column — 2/3: Booking Sources + Location Performance */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.bookingSources}</h2>
|
||||
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.bookingSources}</h2>
|
||||
{data?.sourceBreakdown && data.sourceBreakdown.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={data.sourceBreakdown}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12 }} />
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={chartGrid} />
|
||||
<XAxis dataKey="source" tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 12, fill: chartText }} axisLine={{ stroke: chartAxis }} tickLine={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #e2e8f0', fontSize: '12px' }}
|
||||
contentStyle={{ borderRadius: '8px', border: `1px solid ${chartAxis}`, background: chartTooltipBg, color: chartTooltipText, fontSize: '12px' }}
|
||||
cursor={{ fill: 'rgba(59,130,246,0.08)' }}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
||||
<Legend wrapperStyle={{ fontSize: '12px', color: chartText }} />
|
||||
<Bar dataKey="count" fill="#3b82f6" name={d.barBookings} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#10b981" name={d.barRevenue} radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="revenue" fill="#f97316" name={d.barRevenue} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -414,13 +421,13 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Right column — 1/3: Quick Stats */}
|
||||
<div className="card p-6">
|
||||
<h2 className="text-base font-semibold text-slate-900 mb-4">{d.quickStats}</h2>
|
||||
<h2 className="mb-4 text-base font-semibold text-blue-950 dark:text-slate-100">{d.quickStats}</h2>
|
||||
<div className="space-y-4">
|
||||
{(data?.sourceBreakdown ?? []).map((item) => (
|
||||
<div key={item.source} className="flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600 capitalize">{item.source.toLowerCase()}</span>
|
||||
<span className="text-sm capitalize text-slate-500 dark:text-slate-400">{item.source.toLowerCase()}</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-semibold text-slate-900">{item.count}</span>
|
||||
<span className="text-sm font-semibold text-blue-950 dark:text-slate-100">{item.count}</span>
|
||||
<p className="text-xs text-slate-400">{formatCurrency(item.revenue, 'MAD')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -434,16 +441,16 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Recent Reservations */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">{d.recentReservations}</h2>
|
||||
<Link href="/reservations" className="text-sm text-blue-600 hover:text-blue-700 font-medium">
|
||||
<div className="flex items-center justify-between border-b border-blue-200/70 px-6 py-4 dark:border-blue-400/10">
|
||||
<h2 className="text-base font-semibold text-blue-950 dark:text-slate-100">{d.recentReservations}</h2>
|
||||
<Link href="/reservations" className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
|
||||
{d.viewAll}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<tr className="border-b border-blue-200/70 dark:border-blue-400/10">
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colBookingNum}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colCustomer}</th>
|
||||
<th className="text-left px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colVehicle}</th>
|
||||
@@ -452,17 +459,17 @@ export default function DashboardPage() {
|
||||
<th className="text-right px-6 py-3 text-xs font-medium text-slate-500 uppercase tracking-wide">{d.colTotal}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-blue-200/70 dark:divide-blue-400/10">
|
||||
{(data?.recentReservations ?? []).map((res) => (
|
||||
<tr key={res.id} className="hover:bg-slate-50 transition-colors">
|
||||
<tr key={res.id} className="transition-colors hover:bg-blue-500/[0.06]">
|
||||
<td className="px-6 py-3">
|
||||
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-600 hover:text-blue-700">
|
||||
<Link href={`/reservations/${res.id}`} className="text-sm font-medium text-blue-700 hover:text-blue-900 dark:text-blue-300 dark:hover:text-blue-200">
|
||||
#{res.bookingRef}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500">
|
||||
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.customerName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-700 dark:text-slate-300">{res.vehicleName}</td>
|
||||
<td className="px-6 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatDate(res.startDate)} – {formatDateYear(res.endDate)}
|
||||
</td>
|
||||
<td className="px-6 py-3">
|
||||
@@ -470,7 +477,7 @@ export default function DashboardPage() {
|
||||
{d.statusLabels[res.status as keyof typeof d.statusLabels] ?? res.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-3 text-sm font-semibold text-slate-900 text-right">
|
||||
<td className="px-6 py-3 text-right text-sm font-semibold text-blue-950 dark:text-slate-100">
|
||||
{formatCurrency(res.totalAmount, 'MAD')}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -55,6 +55,169 @@ html.dark body {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.fleet-dashboard-shell {
|
||||
--fleet-bg-primary: #f5f8ff;
|
||||
--fleet-bg-elevated: #ffffff;
|
||||
--fleet-glass-bg: rgb(255 255 255 / 0.82);
|
||||
--fleet-card-bg: rgb(255 255 255 / 0.72);
|
||||
--fleet-border-subtle: rgb(37 99 235 / 0.14);
|
||||
--fleet-border-accent: rgb(249 115 22 / 0.30);
|
||||
--fleet-text-primary: #172554;
|
||||
--fleet-text-secondary: #64748b;
|
||||
--fleet-accent-blue: #2563eb;
|
||||
--fleet-accent-blue-light: #2563eb;
|
||||
--fleet-accent-orange: #f97316;
|
||||
--fleet-accent-orange-light: #ea580c;
|
||||
--fleet-success: #059669;
|
||||
--fleet-danger: #dc2626;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgb(37 99 235 / 0.10) 0%, transparent 56%),
|
||||
radial-gradient(ellipse at 82% 72%, rgb(249 115 22 / 0.08) 0%, transparent 54%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f5f8ff 35%, #edf4ff 100%);
|
||||
color: var(--fleet-text-primary);
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell {
|
||||
--fleet-bg-primary: #0a0f1a;
|
||||
--fleet-bg-elevated: #111827;
|
||||
--fleet-glass-bg: rgb(15 25 45 / 0.66);
|
||||
--fleet-card-bg: rgb(30 58 95 / 0.28);
|
||||
--fleet-border-subtle: rgb(59 130 246 / 0.14);
|
||||
--fleet-text-primary: #e8edf5;
|
||||
--fleet-text-secondary: #8899b4;
|
||||
--fleet-accent-blue: #3b82f6;
|
||||
--fleet-accent-blue-light: #60a5fa;
|
||||
--fleet-accent-orange-light: #fb923c;
|
||||
--fleet-success: #10b981;
|
||||
--fleet-danger: #ef4444;
|
||||
background:
|
||||
radial-gradient(ellipse at 20% 20%, rgb(59 130 246 / 0.09) 0%, transparent 58%),
|
||||
radial-gradient(ellipse at 82% 72%, rgb(249 115 22 / 0.08) 0%, transparent 56%),
|
||||
linear-gradient(180deg, #0a0f1a 0%, #0d1728 46%, #08111f 100%);
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell * {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(59 130 246 / 0.22) transparent;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell *::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell *::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell *::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgb(59 130 246 / 0.22);
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .card,
|
||||
.fleet-dashboard-shell .glass-card {
|
||||
border-color: var(--fleet-border-subtle);
|
||||
background: var(--fleet-glass-bg);
|
||||
box-shadow: 0 18px 60px rgb(15 23 42 / 0.08);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
@apply rounded-xl border transition-colors;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .card:hover,
|
||||
.fleet-dashboard-shell .glass-card:hover {
|
||||
border-color: rgb(59 130 246 / 0.24);
|
||||
box-shadow: 0 18px 60px rgb(15 23 42 / 0.10), 0 0 20px rgb(59 130 246 / 0.10);
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell .card,
|
||||
html.dark .fleet-dashboard-shell .glass-card {
|
||||
box-shadow: 0 18px 60px rgb(0 0 0 / 0.26);
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell .card:hover,
|
||||
html.dark .fleet-dashboard-shell .glass-card:hover {
|
||||
box-shadow: 0 18px 60px rgb(0 0 0 / 0.26), 0 0 20px rgb(59 130 246 / 0.12);
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .btn-primary {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 10px rgb(59 130 246 / 0.30);
|
||||
@apply rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 text-white hover:from-blue-400 hover:to-blue-600;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .btn-secondary {
|
||||
border-color: rgb(59 130 246 / 0.18);
|
||||
background: rgb(59 130 246 / 0.08);
|
||||
color: var(--fleet-text-primary);
|
||||
@apply rounded-lg hover:border-blue-400/40 hover:bg-blue-500/15 hover:text-white;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .input-field {
|
||||
border-color: rgb(59 130 246 / 0.14);
|
||||
background: rgb(59 130 246 / 0.07);
|
||||
color: var(--fleet-text-primary);
|
||||
@apply rounded-lg placeholder:text-slate-500 focus:border-blue-400 focus:ring-blue-500/20;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .progress-bar {
|
||||
height: 6px;
|
||||
background: rgb(59 130 246 / 0.12);
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .progress-fill.blue {
|
||||
@apply bg-gradient-to-r from-blue-500 to-blue-400;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .progress-fill.orange {
|
||||
@apply bg-gradient-to-r from-orange-500 to-orange-600;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .badge-blue {
|
||||
border: 1px solid rgb(59 130 246 / 0.30);
|
||||
background: rgb(59 130 246 / 0.15);
|
||||
color: var(--fleet-accent-blue-light);
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .badge-amber {
|
||||
border: 1px solid rgb(249 115 22 / 0.30);
|
||||
background: rgb(249 115 22 / 0.15);
|
||||
color: var(--fleet-accent-orange-light);
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .badge-green {
|
||||
border: 1px solid rgb(16 185 129 / 0.28);
|
||||
background: rgb(16 185 129 / 0.13);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .badge-red {
|
||||
border: 1px solid rgb(239 68 68 / 0.30);
|
||||
background: rgb(239 68 68 / 0.13);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.fleet-dashboard-shell .badge-gray {
|
||||
border: 1px solid rgb(148 163 184 / 0.20);
|
||||
background: rgb(148 163 184 / 0.10);
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell .badge-green {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell .badge-red {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
html.dark .fleet-dashboard-shell .badge-gray {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
border-color: var(--border-accent);
|
||||
background-color: var(--glass-bg);
|
||||
|
||||
@@ -14,6 +14,11 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
export const metadata: Metadata = {
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
icons: {
|
||||
icon: '/dashboard/icon.svg',
|
||||
shortcut: '/dashboard/icon.svg',
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
function resolveInitialLanguage(value: string | undefined): 'en' | 'fr' | 'ar' {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
|
||||
export default function SignUpForm() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Create your workspace',
|
||||
subtitle: 'Enter your email and password to get started. You can complete your profile after signing in.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
languageLabel: 'Preferred language',
|
||||
create: 'Create workspace',
|
||||
creating: 'Creating\u2026',
|
||||
alreadyHave: 'Already have an account?',
|
||||
signIn: 'Sign in',
|
||||
errorEmailTaken: 'An account with this email already exists.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Cr\u00e9ez votre espace',
|
||||
subtitle: 'Saisissez votre email et mot de passe pour commencer. Vous pourrez compl\u00e9ter votre profil apr\u00e8s la connexion.',
|
||||
email: 'Email',
|
||||
password: 'Mot de passe',
|
||||
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
|
||||
create: 'Cr\u00e9er mon espace',
|
||||
creating: 'Cr\u00e9ation\u2026',
|
||||
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
|
||||
signIn: 'Se connecter',
|
||||
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
|
||||
},
|
||||
ar: {
|
||||
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
|
||||
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621. \u064a\u0645\u0643\u0646\u0643 \u0625\u0643\u0645\u0627\u0644 \u0645\u0644\u0641\u0643 \u0627\u0644\u0634\u062e\u0635\u064a \u0628\u0639\u062f \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
|
||||
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
|
||||
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
|
||||
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
|
||||
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
|
||||
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
|
||||
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
|
||||
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
|
||||
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
|
||||
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
|
||||
},
|
||||
}[preferredLanguage]
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ token: string }>('/auth/account/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, preferredLanguage }),
|
||||
})
|
||||
|
||||
setLanguage(preferredLanguage)
|
||||
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
|
||||
if (typeof window !== 'undefined' && res.token) {
|
||||
window.location.href = toPublicDashboardPath('/')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
|
||||
setError(dict.errorEmailTaken)
|
||||
} else {
|
||||
setError(dict.errorGeneric)
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src="/dashboard/rentalcardrive.png"
|
||||
alt="RentalDriveGo"
|
||||
width={80}
|
||||
height={80}
|
||||
priority
|
||||
className="h-20 w-20 rounded-[1.5rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/80"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="mt-4 text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
|
||||
{dict.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
|
||||
{error ? (
|
||||
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.email} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@company.com"
|
||||
autoFocus
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.password} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.languageLabel} <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setPreferredLanguage(lang)}
|
||||
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
|
||||
preferredLanguage === lang
|
||||
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
|
||||
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
|
||||
}`}
|
||||
>
|
||||
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
|
||||
>
|
||||
{loading ? dict.creating : dict.create}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
{dict.alreadyHave}{' '}
|
||||
<a
|
||||
href="/sign-in"
|
||||
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
|
||||
>
|
||||
{dict.signIn}
|
||||
</a>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -1,936 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { getCurrencyLabel } from '@rentaldrivego/types'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { BilingualField, BilingualInput, emptyBilingual } from '@/components/ui/BilingualInput'
|
||||
|
||||
type SignupForm = {
|
||||
firstName: BilingualField
|
||||
lastName: BilingualField
|
||||
email: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
preferredLanguage: 'en' | 'fr' | 'ar' | ''
|
||||
companyName: BilingualField
|
||||
legalName: string
|
||||
legalForm: string
|
||||
registrationNumber: string
|
||||
iceNumber: string
|
||||
taxId: string
|
||||
operatingLicenseNumber: string
|
||||
operatingLicenseIssuedAt: string
|
||||
operatingLicenseIssuedBy: string
|
||||
streetAddress: BilingualField
|
||||
city: BilingualField
|
||||
country: BilingualField
|
||||
zipCode: string
|
||||
companyPhone: string
|
||||
companyEmail: string
|
||||
fax: string
|
||||
yearsActive: string
|
||||
representativeName: string
|
||||
representativeTitle: string
|
||||
responsibleName: string
|
||||
responsibleRole: string
|
||||
responsibleIdentityNumber: string
|
||||
responsibleQualification: string
|
||||
responsiblePhone: string
|
||||
responsibleEmail: string
|
||||
plan: 'STARTER' | 'GROWTH' | 'PRO'
|
||||
billingPeriod: 'MONTHLY' | 'ANNUAL'
|
||||
currency: 'MAD'
|
||||
paymentProvider: 'AMANPAY' | 'PAYPAL'
|
||||
}
|
||||
|
||||
type CompletedSignup = {
|
||||
companyName: string
|
||||
email: string
|
||||
emailWarning?: string | null
|
||||
}
|
||||
|
||||
type SignupResponse = {
|
||||
companyId: string
|
||||
companyName: string
|
||||
slug: string
|
||||
trialEndsAt: string | null
|
||||
nextStep: string
|
||||
emailDelivery?: {
|
||||
attempted: boolean
|
||||
success: boolean
|
||||
error: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
|
||||
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
import { Suspense } from 'react'
|
||||
import SignUpForm from './SignUpForm'
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const initialPlan = normalizePlan(searchParams.get('plan'))
|
||||
const initialBillingPeriod = normalizeBillingPeriod(searchParams.get('billing'))
|
||||
const initialCurrency = normalizeCurrency(searchParams.get('currency'))
|
||||
const [step, setStep] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [completedSignup, setCompletedSignup] = useState<CompletedSignup | null>(null)
|
||||
const [form, setForm] = useState<SignupForm>({
|
||||
firstName: emptyBilingual(),
|
||||
lastName: emptyBilingual(),
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
preferredLanguage: '',
|
||||
companyName: emptyBilingual(),
|
||||
legalName: '',
|
||||
legalForm: '',
|
||||
registrationNumber: '',
|
||||
iceNumber: '',
|
||||
taxId: '',
|
||||
operatingLicenseNumber: '',
|
||||
operatingLicenseIssuedAt: '',
|
||||
operatingLicenseIssuedBy: '',
|
||||
streetAddress: emptyBilingual(),
|
||||
city: emptyBilingual(),
|
||||
country: emptyBilingual(),
|
||||
zipCode: '',
|
||||
companyPhone: '',
|
||||
companyEmail: '',
|
||||
fax: '',
|
||||
yearsActive: '',
|
||||
representativeName: '',
|
||||
representativeTitle: '',
|
||||
responsibleName: '',
|
||||
responsibleRole: '',
|
||||
responsibleIdentityNumber: '',
|
||||
responsibleQualification: '',
|
||||
responsiblePhone: '',
|
||||
responsibleEmail: '',
|
||||
plan: initialPlan,
|
||||
billingPeriod: initialBillingPeriod,
|
||||
currency: initialCurrency,
|
||||
paymentProvider: 'AMANPAY',
|
||||
})
|
||||
|
||||
const isArabic = language === 'ar'
|
||||
const dict = {
|
||||
en: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Launch your rental workspace',
|
||||
pageSubtitle: 'Create the user account, complete the company information, and launch your workspace.',
|
||||
steps: ['Account User', 'Company info', 'Plan', 'Verify'],
|
||||
ownerTitle: 'Account User',
|
||||
ownerBody: 'Create the primary user account that will manage this company workspace.',
|
||||
partnerTitle: 'Company info',
|
||||
partnerBody: 'Complete the company information required to create the workspace.',
|
||||
planTitle: 'Choose your plan',
|
||||
planBody: 'Your workspace starts immediately on a 90-day free trial in your preferred billing currency.',
|
||||
reviewTitle: 'Review and launch',
|
||||
reviewBody: 'We will create the company workspace immediately and start the 90-day trial with the plan you selected.',
|
||||
firstName: 'Manager/Owner first name',
|
||||
lastName: 'Manager/Owner last name',
|
||||
ownerEmail: 'Manager/Owner email',
|
||||
emailReuseHint: 'You can reuse the same email for the owner, company, and responsible person.',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordHint: 'Use at least 8 characters for your company owner account.',
|
||||
companyName: 'Commercial name',
|
||||
legalName: 'Legal company name',
|
||||
legalForm: 'Legal form',
|
||||
registrationNumber: 'Commercial Registry (RC)',
|
||||
iceNumber: 'ICE number',
|
||||
taxId: 'Fiscal ID (IF)',
|
||||
operatingLicenseNumber: 'Operating license number',
|
||||
operatingLicenseIssuedAt: 'License issue date',
|
||||
operatingLicenseIssuedBy: 'Issuing authority',
|
||||
streetAddress: 'Street Address',
|
||||
city: 'City',
|
||||
country: 'Country',
|
||||
zipCode: 'Zip code',
|
||||
companyPhone: 'Phone',
|
||||
companyEmail: 'Company contact email',
|
||||
fax: 'Fax (optional)',
|
||||
yearsActive: 'Years active',
|
||||
representativeName: 'Legal representative name',
|
||||
representativeTitle: 'Legal representative title',
|
||||
responsibleName: 'Responsible person name',
|
||||
responsibleRole: 'Responsible person role',
|
||||
responsibleIdentityNumber: 'Responsible person ID number',
|
||||
responsibleQualification: 'Qualification / diploma / experience',
|
||||
responsiblePhone: 'Responsible person phone',
|
||||
responsibleEmail: 'Responsible person email',
|
||||
identitySection: 'Legal identity',
|
||||
contactSection: 'Company contact',
|
||||
representativeSection: 'Legal representative',
|
||||
responsibleSection: 'Responsible person',
|
||||
continue: 'Continue',
|
||||
back: 'Back',
|
||||
working: 'Working…',
|
||||
createWorkspace: 'Create workspace',
|
||||
workspaceReady: 'Workspace ready',
|
||||
workspaceReadyBody: 'is now active and the owner email',
|
||||
enterDashboard: 'Enter dashboard',
|
||||
signInAnotherDevice: 'Sign in on another device',
|
||||
reviewWorkspace: 'Workspace',
|
||||
reviewPlan: 'Plan',
|
||||
reviewOwnerEmail: 'Owner email',
|
||||
reviewCompanyEmail: 'Company email',
|
||||
reviewPhone: 'Phone',
|
||||
reviewLegalName: 'Legal name',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choose a password with at least 8 characters.',
|
||||
passwordMismatch: 'Passwords do not match.',
|
||||
missingRequired: 'Fill in all required fields.',
|
||||
missingLanguage: 'Please select your preferred language.',
|
||||
preferredLanguageLabel: 'Preferred language',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'Your company',
|
||||
couldNotCreate: 'Could not create workspace',
|
||||
legalFormOptions: {
|
||||
'': 'Select legal form',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Sole proprietorship',
|
||||
OTHER: 'Other',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Select years active',
|
||||
LESS_THAN_1: 'Less than 1 year',
|
||||
ONE_TO_FIVE: '1 to 5 years',
|
||||
MORE_THAN_5: 'More than 5 years',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Core fleet, offers, and bookings.',
|
||||
GROWTH: 'Featured marketplace placement and more room to scale.',
|
||||
PRO: 'Full white-label and premium controls.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Monthly',
|
||||
ANNUAL: 'Annual',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
fr: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'Lancez votre espace de location',
|
||||
pageSubtitle: 'Créez le compte utilisateur, complétez les informations de l’entreprise et lancez votre espace.',
|
||||
steps: ['Compte utilisateur', 'Infos entreprise', 'Plan', 'Vérification'],
|
||||
ownerTitle: 'Compte utilisateur',
|
||||
ownerBody: 'Créez le compte utilisateur principal qui gérera cet espace entreprise.',
|
||||
partnerTitle: 'Infos entreprise',
|
||||
partnerBody: 'Complétez les informations de l’entreprise requises pour créer l’espace.',
|
||||
planTitle: 'Choisissez votre formule',
|
||||
planBody: 'Votre espace démarre immédiatement avec un essai gratuit de 90 jours dans la devise choisie.',
|
||||
reviewTitle: 'Vérification et lancement',
|
||||
reviewBody: 'Nous allons créer immédiatement l’espace entreprise et démarrer l’essai de 90 jours avec la formule choisie.',
|
||||
firstName: 'Prénom du gérant/propriétaire',
|
||||
lastName: 'Nom du gérant/propriétaire',
|
||||
ownerEmail: 'E-mail du gérant/propriétaire',
|
||||
emailReuseHint: 'Vous pouvez utiliser le même e-mail pour le propriétaire, l’entreprise et le responsable.',
|
||||
password: 'Mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
|
||||
companyName: 'Nom commercial',
|
||||
legalName: 'Raison sociale',
|
||||
legalForm: 'Forme juridique',
|
||||
registrationNumber: 'Registre du commerce (RC)',
|
||||
iceNumber: 'Numéro ICE',
|
||||
taxId: 'Identifiant fiscal (IF)',
|
||||
operatingLicenseNumber: 'Numéro d’agrément',
|
||||
operatingLicenseIssuedAt: 'Date de délivrance',
|
||||
operatingLicenseIssuedBy: 'Autorité délivrante',
|
||||
streetAddress: 'Adresse',
|
||||
city: 'Ville',
|
||||
country: 'Pays',
|
||||
zipCode: 'Code postal',
|
||||
companyPhone: 'Téléphone',
|
||||
companyEmail: 'E-mail de contact de l’entreprise',
|
||||
fax: 'Fax (optionnel)',
|
||||
yearsActive: 'Années d’activité',
|
||||
representativeName: 'Nom et prénom du représentant',
|
||||
representativeTitle: 'Fonction du représentant',
|
||||
responsibleName: 'Nom et prénom du responsable',
|
||||
responsibleRole: 'Qualité du responsable',
|
||||
responsibleIdentityNumber: 'N° de pièce d’identité',
|
||||
responsibleQualification: 'Qualification / diplôme / expérience',
|
||||
responsiblePhone: 'Téléphone du responsable',
|
||||
responsibleEmail: 'E-mail du responsable',
|
||||
identitySection: 'Identité juridique',
|
||||
contactSection: 'Coordonnées de l’entreprise',
|
||||
representativeSection: 'Représentant légal',
|
||||
responsibleSection: 'Responsable désigné',
|
||||
continue: 'Continuer',
|
||||
back: 'Retour',
|
||||
working: 'Traitement…',
|
||||
createWorkspace: 'Créer l’espace',
|
||||
workspaceReady: 'Espace prêt',
|
||||
workspaceReadyBody: 'est maintenant actif et l’e-mail propriétaire',
|
||||
enterDashboard: 'Accéder au tableau de bord',
|
||||
signInAnotherDevice: 'Se connecter sur un autre appareil',
|
||||
reviewWorkspace: 'Espace',
|
||||
reviewPlan: 'Formule',
|
||||
reviewOwnerEmail: 'E-mail propriétaire',
|
||||
reviewCompanyEmail: 'E-mail entreprise',
|
||||
reviewPhone: 'Téléphone',
|
||||
reviewLegalName: 'Raison sociale',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'Choisissez un mot de passe d’au moins 8 caractères.',
|
||||
passwordMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
missingRequired: 'Renseignez tous les champs obligatoires.',
|
||||
missingLanguage: 'Veuillez sélectionner votre langue préférée.',
|
||||
preferredLanguageLabel: 'Langue préférée',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'Votre entreprise',
|
||||
couldNotCreate: 'Impossible de créer l’espace',
|
||||
legalFormOptions: {
|
||||
'': 'Sélectionner la forme juridique',
|
||||
SARL: 'SARL',
|
||||
'SARL AU': 'SARL AU',
|
||||
SA: 'SA',
|
||||
SAS: 'SAS',
|
||||
AUTO_ENTREPRENEUR: 'Auto-entrepreneur',
|
||||
EI: 'Entreprise individuelle',
|
||||
OTHER: 'Autre',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'Sélectionner les années d’activité',
|
||||
LESS_THAN_1: 'Moins de 1 an',
|
||||
ONE_TO_FIVE: '1 an à 5 ans',
|
||||
MORE_THAN_5: 'Plus de 5 ans',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'Flotte, offres et réservations essentielles.',
|
||||
GROWTH: 'Mise en avant marketplace et plus de marge pour évoluer.',
|
||||
PRO: 'White-label complet et contrôles premium.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'Mensuel',
|
||||
ANNUAL: 'Annuel',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
ar: {
|
||||
brand: 'RentalDriveGo',
|
||||
pageTitle: 'أطلق مساحة التأجير الخاصة بك',
|
||||
pageSubtitle: 'أنشئ حساب المستخدم، وأكمل معلومات الشركة، ثم أطلق مساحة العمل.',
|
||||
steps: ['حساب المستخدم', 'معلومات الشركة', 'الخطة', 'المراجعة'],
|
||||
ownerTitle: 'حساب المستخدم',
|
||||
ownerBody: 'أنشئ حساب المستخدم الرئيسي الذي سيدير مساحة عمل الشركة.',
|
||||
partnerTitle: 'معلومات الشركة',
|
||||
partnerBody: 'أكمل معلومات الشركة المطلوبة لإنشاء مساحة العمل.',
|
||||
planTitle: 'اختر خطتك',
|
||||
planBody: 'تبدأ المساحة فوراً بفترة تجريبية مجانية لمدة 14 يوماً بالعملة التي تفضلها.',
|
||||
reviewTitle: 'راجع وأطلق',
|
||||
reviewBody: 'سننشئ مساحة الشركة فوراً ونبدأ الفترة التجريبية لمدة 14 يوماً بالخطة التي اخترتها.',
|
||||
firstName: 'الاسم الأول للمدير/المالك',
|
||||
lastName: 'اسم العائلة للمدير/المالك',
|
||||
ownerEmail: 'بريد المدير/المالك الإلكتروني',
|
||||
emailReuseHint: 'يمكنك استخدام نفس البريد الإلكتروني للمالك والشركة والمسؤول.',
|
||||
password: 'كلمة المرور',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
|
||||
companyName: 'الاسم التجاري',
|
||||
legalName: 'الاسم القانوني للشركة',
|
||||
legalForm: 'الشكل القانوني',
|
||||
registrationNumber: 'السجل التجاري (RC)',
|
||||
iceNumber: 'رقم ICE',
|
||||
taxId: 'المعرف الضريبي (IF)',
|
||||
operatingLicenseNumber: 'رقم الترخيص',
|
||||
operatingLicenseIssuedAt: 'تاريخ إصدار الترخيص',
|
||||
operatingLicenseIssuedBy: 'الجهة المانحة',
|
||||
streetAddress: 'عنوان الشارع',
|
||||
city: 'المدينة',
|
||||
country: 'الدولة',
|
||||
zipCode: 'الرمز البريدي',
|
||||
companyPhone: 'الهاتف',
|
||||
companyEmail: 'بريد الشركة للتواصل',
|
||||
fax: 'الفاكس (اختياري)',
|
||||
yearsActive: 'سنوات النشاط',
|
||||
representativeName: 'اسم الممثل القانوني',
|
||||
representativeTitle: 'صفة الممثل القانوني',
|
||||
responsibleName: 'اسم المسؤول',
|
||||
responsibleRole: 'صفة المسؤول',
|
||||
responsibleIdentityNumber: 'رقم وثيقة الهوية',
|
||||
responsibleQualification: 'المؤهل / الشهادة / الخبرة',
|
||||
responsiblePhone: 'هاتف المسؤول',
|
||||
responsibleEmail: 'بريد المسؤول الإلكتروني',
|
||||
identitySection: 'الهوية القانونية',
|
||||
contactSection: 'بيانات الشركة',
|
||||
representativeSection: 'الممثل القانوني',
|
||||
responsibleSection: 'الشخص المسؤول',
|
||||
continue: 'متابعة',
|
||||
back: 'رجوع',
|
||||
working: 'جارٍ التنفيذ…',
|
||||
createWorkspace: 'إنشاء المساحة',
|
||||
workspaceReady: 'المساحة جاهزة',
|
||||
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
|
||||
enterDashboard: 'الدخول إلى لوحة التحكم',
|
||||
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
|
||||
reviewWorkspace: 'المساحة',
|
||||
reviewPlan: 'الخطة',
|
||||
reviewOwnerEmail: 'بريد المالك',
|
||||
reviewCompanyEmail: 'بريد الشركة',
|
||||
reviewPhone: 'الهاتف',
|
||||
reviewLegalName: 'الاسم القانوني',
|
||||
reviewRegistration: 'RC / ICE / IF',
|
||||
invalidPassword: 'اختر كلمة مرور من 8 أحرف على الأقل.',
|
||||
passwordMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
missingRequired: 'أكمل جميع الحقول الإلزامية.',
|
||||
missingLanguage: 'يرجى اختيار اللغة المفضلة.',
|
||||
preferredLanguageLabel: 'اللغة المفضلة',
|
||||
languageOptions: { en: 'English', fr: 'Français', ar: 'العربية' } as Record<string, string>,
|
||||
companyFallback: 'شركتك',
|
||||
couldNotCreate: 'تعذر إنشاء المساحة',
|
||||
legalFormOptions: {
|
||||
'': 'اختر الشكل القانوني',
|
||||
SARL: 'شركة ذات مسؤولية محدودة',
|
||||
'SARL AU': 'شركة ذات مسؤولية محدودة لشخص واحد',
|
||||
SA: 'شركة مساهمة',
|
||||
SAS: 'شركة مساهمة مبسطة',
|
||||
AUTO_ENTREPRENEUR: 'مقاول ذاتي',
|
||||
EI: 'مؤسسة فردية',
|
||||
OTHER: 'أخرى',
|
||||
} as Record<string, string>,
|
||||
yearsActiveOptions: {
|
||||
'': 'اختر سنوات النشاط',
|
||||
LESS_THAN_1: 'أقل من سنة',
|
||||
ONE_TO_FIVE: 'من سنة إلى 5 سنوات',
|
||||
MORE_THAN_5: 'أكثر من 5 سنوات',
|
||||
} as Record<string, string>,
|
||||
planDescriptions: {
|
||||
STARTER: 'الأساسيات للأسطول والعروض والحجوزات.',
|
||||
GROWTH: 'ظهور مميز في السوق ومساحة أكبر للنمو.',
|
||||
PRO: 'تحكم كامل وواجهة بيضاء متقدمة.',
|
||||
} as Record<SignupForm['plan'], string>,
|
||||
billingPeriodOptions: {
|
||||
MONTHLY: 'شهري',
|
||||
ANNUAL: 'سنوي',
|
||||
} as Record<SignupForm['billingPeriod'], string>,
|
||||
paymentProviderOptions: {
|
||||
AMANPAY: 'AmanPay',
|
||||
PAYPAL: 'PayPal',
|
||||
} as Record<SignupForm['paymentProvider'], string>,
|
||||
},
|
||||
}[language]
|
||||
|
||||
function handleStep1Continue() {
|
||||
if (!form.preferredLanguage) {
|
||||
setError(dict.missingLanguage)
|
||||
return
|
||||
}
|
||||
const ownerEmail = normalizeEmail(form.email)
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
companyEmail: current.companyEmail || ownerEmail,
|
||||
responsibleEmail: current.responsibleEmail || ownerEmail,
|
||||
}))
|
||||
setError(null)
|
||||
setStep(2)
|
||||
}
|
||||
|
||||
function handleStep2Continue() {
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setStep(3)
|
||||
}
|
||||
|
||||
async function submitSignup() {
|
||||
if (form.password.length < 8) {
|
||||
setError(dict.invalidPassword)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError(dict.passwordMismatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasRequiredPartnerFields(form)) {
|
||||
setError(dict.missingRequired)
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await apiFetch<SignupResponse>('/auth/company/signup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstName: form.firstName.fr || form.firstName.ar,
|
||||
firstNameAr: form.firstName.ar || undefined,
|
||||
lastName: form.lastName.fr || form.lastName.ar,
|
||||
lastNameAr: form.lastName.ar || undefined,
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
preferredLanguage: form.preferredLanguage || 'en',
|
||||
companyName: form.companyName.fr || form.companyName.ar,
|
||||
legalName: form.legalName,
|
||||
companyNameAr: form.companyName.ar || undefined,
|
||||
legalForm: form.legalForm,
|
||||
registrationNumber: form.registrationNumber,
|
||||
iceNumber: form.iceNumber,
|
||||
taxId: form.taxId,
|
||||
operatingLicenseNumber: form.operatingLicenseNumber,
|
||||
operatingLicenseIssuedAt: form.operatingLicenseIssuedAt,
|
||||
operatingLicenseIssuedBy: form.operatingLicenseIssuedBy,
|
||||
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
|
||||
streetAddressAr: form.streetAddress.ar || undefined,
|
||||
city: form.city.fr || form.city.ar,
|
||||
cityAr: form.city.ar || undefined,
|
||||
country: form.country.fr || form.country.ar,
|
||||
countryAr: form.country.ar || undefined,
|
||||
zipCode: form.zipCode,
|
||||
companyPhone: form.companyPhone,
|
||||
companyEmail: form.companyEmail,
|
||||
fax: form.fax || undefined,
|
||||
yearsActive: form.yearsActive,
|
||||
representativeName: form.representativeName || undefined,
|
||||
representativeTitle: form.representativeTitle || undefined,
|
||||
responsibleName: form.responsibleName,
|
||||
responsibleRole: form.responsibleRole,
|
||||
responsibleIdentityNumber: form.responsibleIdentityNumber,
|
||||
responsibleQualification: form.responsibleQualification || undefined,
|
||||
responsiblePhone: form.responsiblePhone,
|
||||
responsibleEmail: form.responsibleEmail,
|
||||
plan: form.plan,
|
||||
billingPeriod: form.billingPeriod,
|
||||
currency: form.currency,
|
||||
paymentProvider: form.paymentProvider,
|
||||
}),
|
||||
})
|
||||
|
||||
setCompletedSignup({
|
||||
companyName: form.companyName.fr || form.companyName.ar,
|
||||
email: form.email,
|
||||
emailWarning: getEmailWarning(response.emailDelivery, language),
|
||||
})
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, dict.couldNotCreate))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (completedSignup) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
|
||||
<span className="font-semibold text-slate-900"> {completedSignup.email}</span>.
|
||||
</p>
|
||||
{completedSignup.emailWarning ? (
|
||||
<div className="mt-6 rounded-2xl border border-orange-200 bg-orange-50 px-4 py-3 text-sm text-orange-800">
|
||||
{completedSignup.emailWarning}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||
<Link href="/" className="btn-primary justify-center">
|
||||
{dict.enterDashboard}
|
||||
</Link>
|
||||
<a href="/sign-in" className="btn-secondary justify-center">
|
||||
{dict.signInAnotherDevice}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
|
||||
<div className="flex justify-center">
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-blue-950"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-4">
|
||||
{dict.steps.map((label, index) => (
|
||||
<div key={label} className={`rounded-full px-4 py-2 text-center text-sm font-semibold ${index + 1 <= step ? 'bg-blue-900 text-white' : 'border border-slate-200 bg-white text-slate-500'}`}>
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{error ? <div className="mb-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div> : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.ownerTitle} body={dict.ownerBody} />
|
||||
<BilingualInput label={dict.firstName} required maxLength={80} value={form.firstName} onChange={(value) => setForm((current) => ({ ...current, firstName: value }))} />
|
||||
<BilingualInput label={dict.lastName} required maxLength={80} value={form.lastName} onChange={(current) => setForm((f) => ({ ...f, lastName: current }))} />
|
||||
<LabeledInput label={dict.ownerEmail} required type="email" maxLength={254} value={form.email} onChange={(value) => setForm((current) => ({ ...current, email: normalizeEmail(value) }))} />
|
||||
<p className="text-xs text-slate-500">{dict.emailReuseHint}</p>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
|
||||
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
|
||||
<div>
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{dict.preferredLanguageLabel} <span className="text-red-600">*</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setForm((current) => ({ ...current, preferredLanguage: lang }))
|
||||
setLanguage(lang)
|
||||
}}
|
||||
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
|
||||
form.preferredLanguage === lang
|
||||
? 'border-blue-900 bg-blue-900 text-white'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{dict.languageOptions[lang]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={handleStep1Continue} className="btn-primary">{dict.continue}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.partnerTitle} body={dict.partnerBody} />
|
||||
<div className="space-y-4">
|
||||
<FormSubsection title={dict.identitySection} />
|
||||
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
|
||||
<LabeledInput label={dict.legalName} required maxLength={160} value={form.legalName} onChange={(value) => setForm((current) => ({ ...current, legalName: normalizeTrimmed(value) }))} />
|
||||
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.iceNumber} required maxLength={120} value={form.iceNumber} onChange={(value) => setForm((current) => ({ ...current, iceNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.taxId} required maxLength={120} value={form.taxId} onChange={(value) => setForm((current) => ({ ...current, taxId: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.operatingLicenseNumber} required maxLength={120} value={form.operatingLicenseNumber} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseNumber: normalizeUpper(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.operatingLicenseIssuedAt} required type="date" value={form.operatingLicenseIssuedAt} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedAt: value }))} />
|
||||
<LabeledInput label={dict.operatingLicenseIssuedBy} required maxLength={160} value={form.operatingLicenseIssuedBy} onChange={(value) => setForm((current) => ({ ...current, operatingLicenseIssuedBy: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
|
||||
<FormSubsection title={dict.contactSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
|
||||
<LabeledInput label={dict.companyEmail} required type="email" maxLength={254} value={form.companyEmail} onChange={(value) => setForm((current) => ({ ...current, companyEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
|
||||
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
|
||||
</div>
|
||||
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<BilingualInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
|
||||
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
|
||||
</div>
|
||||
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
|
||||
|
||||
<FormSubsection title={dict.representativeSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.representativeName} maxLength={160} value={form.representativeName} onChange={(value) => setForm((current) => ({ ...current, representativeName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.representativeTitle} maxLength={120} value={form.representativeTitle} onChange={(value) => setForm((current) => ({ ...current, representativeTitle: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
|
||||
<FormSubsection title={dict.responsibleSection} />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleName} required maxLength={160} value={form.responsibleName} onChange={(value) => setForm((current) => ({ ...current, responsibleName: normalizeTrimmed(value) }))} />
|
||||
<LabeledInput label={dict.responsibleRole} required maxLength={120} value={form.responsibleRole} onChange={(value) => setForm((current) => ({ ...current, responsibleRole: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsibleIdentityNumber} required maxLength={120} value={form.responsibleIdentityNumber} onChange={(value) => setForm((current) => ({ ...current, responsibleIdentityNumber: normalizeUpper(value) }))} />
|
||||
<LabeledInput label={dict.responsibleQualification} maxLength={200} value={form.responsibleQualification} onChange={(value) => setForm((current) => ({ ...current, responsibleQualification: normalizeTrimmed(value) }))} />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledInput label={dict.responsiblePhone} required maxLength={80} value={form.responsiblePhone} onChange={(value) => setForm((current) => ({ ...current, responsiblePhone: value }))} />
|
||||
<LabeledInput label={dict.responsibleEmail} required type="email" maxLength={254} value={form.responsibleEmail} onChange={(value) => setForm((current) => ({ ...current, responsibleEmail: normalizeEmail(value) }))} />
|
||||
</div>
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(1)} onNext={handleStep2Continue} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.planTitle} body={dict.planBody} />
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{(['STARTER', 'GROWTH', 'PRO'] as const).map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
type="button"
|
||||
onClick={() => setForm((current) => ({ ...current, plan }))}
|
||||
className={`rounded-3xl border p-5 text-left ${form.plan === plan ? 'border-blue-900 bg-blue-900 text-white' : 'border-slate-200 bg-white text-slate-900'}`}
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.16em]">{plan}</p>
|
||||
<p className="mt-3 text-sm opacity-80">{dict.planDescriptions[plan]}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<LabeledSelect label={language === 'fr' ? 'Période de facturation' : language === 'ar' ? 'فترة الفوترة' : 'Billing period'} value={form.billingPeriod} options={['MONTHLY', 'ANNUAL']} labels={dict.billingPeriodOptions} onChange={(value) => setForm((current) => ({ ...current, billingPeriod: value as SignupForm['billingPeriod'] }))} />
|
||||
<LabeledSelect label={language === 'fr' ? 'Prestataire principal' : language === 'ar' ? 'مزود الدفع الرئيسي' : 'Primary provider'} value={form.paymentProvider} options={['AMANPAY', 'PAYPAL']} labels={dict.paymentProviderOptions} onChange={(value) => setForm((current) => ({ ...current, paymentProvider: value as SignupForm['paymentProvider'] }))} />
|
||||
</div>
|
||||
<NavActions onBack={() => setStep(2)} onNext={() => setStep(4)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 4 ? (
|
||||
<div className="space-y-5">
|
||||
<SectionIntro title={dict.reviewTitle} body={dict.reviewBody} />
|
||||
<div className="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
|
||||
<p><span className="font-semibold text-slate-900">{dict.reviewWorkspace}:</span> {form.companyName.fr || form.companyName.ar || dict.companyFallback}{form.companyName.fr && form.companyName.ar ? <span className="ml-2 text-slate-400">/ {form.companyName.ar}</span> : null}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewLegalName}:</span> {form.legalName || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · {getCurrencyLabel(language)}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewCompanyEmail}:</span> {form.companyEmail || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
|
||||
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewRegistration}:</span> {form.registrationNumber || '—'} / {form.iceNumber || '—'} / {form.taxId || '—'}</p>
|
||||
</div>
|
||||
<NavActions
|
||||
onBack={() => setStep(3)}
|
||||
onNext={submitSignup}
|
||||
loading={loading}
|
||||
nextLabel={dict.createWorkspace}
|
||||
backLabel={dict.back}
|
||||
loadingLabel={dict.working}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
<Suspense fallback={null}>
|
||||
<SignUpForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function hasRequiredPartnerFields(form: SignupForm) {
|
||||
const bilingualFilled = (f: BilingualField) => f.fr.trim().length > 0 || f.ar.trim().length > 0
|
||||
return (
|
||||
bilingualFilled(form.companyName) &&
|
||||
bilingualFilled(form.streetAddress) &&
|
||||
bilingualFilled(form.city) &&
|
||||
bilingualFilled(form.country) &&
|
||||
[
|
||||
form.legalName,
|
||||
form.legalForm,
|
||||
form.registrationNumber,
|
||||
form.iceNumber,
|
||||
form.taxId,
|
||||
form.operatingLicenseNumber,
|
||||
form.operatingLicenseIssuedAt,
|
||||
form.operatingLicenseIssuedBy,
|
||||
form.zipCode,
|
||||
form.companyPhone,
|
||||
form.companyEmail,
|
||||
form.yearsActive,
|
||||
form.responsibleName,
|
||||
form.responsibleRole,
|
||||
form.responsibleIdentityNumber,
|
||||
form.responsiblePhone,
|
||||
form.responsibleEmail,
|
||||
]
|
||||
.every((v) => v.trim().length > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function SectionIntro({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-500">{body}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSubsection({ title }: { title: string }) {
|
||||
return <h3 className="pt-2 text-sm font-semibold uppercase tracking-[0.16em] text-slate-500">{title}</h3>
|
||||
}
|
||||
|
||||
function getEmailWarning(emailDelivery: SignupResponse['emailDelivery'], language: 'en' | 'fr' | 'ar') {
|
||||
if (!emailDelivery) return null
|
||||
if (emailDelivery.success) return null
|
||||
if (emailDelivery.attempted) {
|
||||
if (language === 'fr') return `Le compte a été créé, mais l’e-mail de confirmation n’a pas pu être envoyé${emailDelivery.error ? ` : ${emailDelivery.error}` : '.'}`
|
||||
if (language === 'ar') return `تم إنشاء الحساب، لكن تعذر إرسال رسالة التأكيد${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
return `The account was created, but the confirmation email could not be delivered${emailDelivery.error ? `: ${emailDelivery.error}` : '.'}`
|
||||
}
|
||||
if (language === 'fr') return 'Le compte a été créé, mais l’envoi d’e-mails n’est pas encore configuré sur cet environnement.'
|
||||
if (language === 'ar') return 'تم إنشاء الحساب، لكن إرسال البريد الإلكتروني غير مهيأ بعد في هذه البيئة.'
|
||||
return 'The account was created, but email sending is not configured on this environment yet.'
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (error instanceof Error && error.message) return error.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeEmail(value: string) {
|
||||
return value.replace(/\s+/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeTrimmed(value: string) {
|
||||
return value.replace(/^\s+/, '')
|
||||
}
|
||||
|
||||
function normalizeUpper(value: string) {
|
||||
return value.replace(/^\s+/, '').toUpperCase()
|
||||
}
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
type = 'text',
|
||||
required = false,
|
||||
maxLength,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
type?: string
|
||||
required?: boolean
|
||||
maxLength?: number
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<input type={type} maxLength={maxLength} className="input-field" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function LabeledSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
labels,
|
||||
required = false,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
options: readonly string[]
|
||||
onChange: (value: string) => void
|
||||
labels?: Record<string, string>
|
||||
required?: boolean
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{label}
|
||||
{required ? <span className="ml-1 text-red-600">*</span> : null}
|
||||
</span>
|
||||
<select className="input-field" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{options.map((option) => (
|
||||
<option key={option || 'empty'} value={option}>{labels?.[option] ?? option}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function NavActions({
|
||||
onBack,
|
||||
onNext,
|
||||
loading = false,
|
||||
nextLabel = 'Continue',
|
||||
backLabel = 'Back',
|
||||
loadingLabel = 'Working…',
|
||||
}: {
|
||||
onBack?: () => void
|
||||
onNext: () => void
|
||||
loading?: boolean
|
||||
nextLabel?: string
|
||||
backLabel?: string
|
||||
loadingLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{onBack ? (
|
||||
<button type="button" onClick={onBack} className="btn-secondary flex-1 justify-center">
|
||||
{backLabel}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={onNext} disabled={loading} className="btn-primary flex-1 justify-center disabled:cursor-not-allowed disabled:opacity-60">
|
||||
{loading ? loadingLabel : nextLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePlan(value: string | null): SignupForm['plan'] {
|
||||
if (value === 'GROWTH' || value === 'PRO' || value === 'STARTER') return value
|
||||
return 'STARTER'
|
||||
}
|
||||
|
||||
function normalizeBillingPeriod(value: string | null): SignupForm['billingPeriod'] {
|
||||
return value === 'ANNUAL' ? 'ANNUAL' : 'MONTHLY'
|
||||
}
|
||||
|
||||
function normalizeCurrency(_value: string | null): SignupForm['currency'] {
|
||||
return 'MAD'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user