Files
carmanagement/apps/dashboard/src/app/sign-up/[[...sign-up]]/page.tsx
T
2026-05-25 03:12:19 -04:00

746 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { marketplaceUrl } from '@/lib/urls'
import { BilingualField, BilingualInput, bilingualPrimary, emptyBilingual } from '@/components/ui/BilingualInput'
type SignupForm = {
firstName: BilingualField
lastName: BilingualField
email: string
password: string
confirmPassword: string
preferredLanguage: 'en' | 'fr' | 'ar' | ''
companyName: BilingualField
legalForm: string
registrationNumber: string
streetAddress: BilingualField
city: BilingualField
country: BilingualField
zipCode: string
companyPhone: string
fax: string
yearsActive: string
plan: 'STARTER' | 'GROWTH' | 'PRO'
billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'
paymentProvider: 'AMANPAY' | 'PAYPAL'
}
type CompletedSignup = {
companyName: string
email: string
emailWarning?: string | null
}
type SignupResponse = {
companyId: string
companyName: string
slug: string
trialEndsAt: string | null
nextStep: string
emailDelivery?: {
attempted: boolean
success: boolean
error: string | null
} | null
}
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
export default function SignUpPage() {
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(),
legalForm: '',
registrationNumber: '',
streetAddress: emptyBilingual(),
city: emptyBilingual(),
country: emptyBilingual(),
zipCode: '',
companyPhone: '',
fax: '',
yearsActive: '',
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',
password: 'Password',
confirmPassword: 'Confirm password',
passwordHint: 'Use at least 8 characters for your company owner account.',
companyName: 'Company Name',
legalForm: 'Legal form',
registrationNumber: 'Commercial Registry (RC)',
streetAddress: 'Street Address',
city: 'City',
country: 'Country',
zipCode: 'Zip code',
companyPhone: 'Phone',
fax: 'Fax (optional)',
yearsActive: 'Years active',
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',
reviewPhone: 'Phone',
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',
password: 'Mot de passe',
confirmPassword: 'Confirmer le mot de passe',
passwordHint: 'Utilisez au moins 8 caractères pour le compte propriétaire.',
companyName: 'Nom de lentreprise',
legalForm: 'Forme juridique',
registrationNumber: 'Registre du commerce (RC)',
streetAddress: 'Adresse',
city: 'Ville',
country: 'Pays',
zipCode: 'Code postal',
companyPhone: 'Téléphone',
fax: 'Fax (optionnel)',
yearsActive: 'Années dactivité',
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',
reviewPhone: 'Téléphone',
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: 'بريد المدير/المالك الإلكتروني',
password: 'كلمة المرور',
confirmPassword: 'تأكيد كلمة المرور',
passwordHint: 'استخدم 8 أحرف على الأقل لحساب مالك الشركة.',
companyName: 'اسم الشركة',
legalForm: 'الشكل القانوني',
registrationNumber: 'السجل التجاري (RC)',
streetAddress: 'عنوان الشارع',
city: 'المدينة',
country: 'الدولة',
zipCode: 'الرمز البريدي',
companyPhone: 'الهاتف',
fax: 'الفاكس (اختياري)',
yearsActive: 'سنوات النشاط',
continue: 'متابعة',
back: 'رجوع',
working: 'جارٍ التنفيذ…',
createWorkspace: 'إنشاء المساحة',
workspaceReady: 'المساحة جاهزة',
workspaceReadyBody: 'أصبحت الآن نشطة وتم تسجيل بريد المالك',
enterDashboard: 'الدخول إلى لوحة التحكم',
signInAnotherDevice: 'تسجيل الدخول على جهاز آخر',
reviewWorkspace: 'المساحة',
reviewPlan: 'الخطة',
reviewOwnerEmail: 'بريد المالك',
reviewPhone: 'الهاتف',
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
}
setError(null)
setStep(2)
}
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,
companyNameAr: form.companyName.ar || undefined,
legalForm: form.legalForm,
registrationNumber: form.registrationNumber,
streetAddress: form.streetAddress.fr || form.streetAddress.ar,
streetAddressAr: form.streetAddress.ar || undefined,
city: form.city.fr || form.city.ar,
cityAr: form.city.ar || undefined,
country: form.country.fr || form.country.ar,
countryAr: form.country.ar || undefined,
zipCode: form.zipCode,
companyPhone: form.companyPhone,
fax: form.fax || undefined,
yearsActive: form.yearsActive,
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-slate-900"
/>
</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-slate-900"
/>
</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-slate-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) }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.password} required type="password" maxLength={128} value={form.password} onChange={(value) => setForm((current) => ({ ...current, password: value }))} />
<LabeledInput label={dict.confirmPassword} required type="password" maxLength={128} value={form.confirmPassword} onChange={(value) => setForm((current) => ({ ...current, confirmPassword: value }))} />
</div>
<p className="text-xs text-slate-500">{dict.passwordHint}</p>
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
{dict.preferredLanguageLabel} <span className="text-red-600">*</span>
</span>
<div className="grid grid-cols-3 gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => {
setForm((current) => ({ ...current, preferredLanguage: lang }))
setLanguage(lang)
}}
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
form.preferredLanguage === lang
? 'border-slate-900 bg-slate-900 text-white'
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
}`}
>
{dict.languageOptions[lang]}
</button>
))}
</div>
</div>
<div className="flex justify-end">
<button type="button" onClick={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">
<BilingualInput label={dict.companyName} required maxLength={120} value={form.companyName} onChange={(value) => setForm((current) => ({ ...current, companyName: value }))} />
<LabeledSelect label={dict.legalForm} required value={form.legalForm} options={['', ...LEGAL_FORM_OPTIONS]} labels={dict.legalFormOptions} onChange={(value) => setForm((current) => ({ ...current, legalForm: value }))} />
<LabeledInput label={dict.registrationNumber} required maxLength={120} value={form.registrationNumber} onChange={(value) => setForm((current) => ({ ...current, registrationNumber: normalizeTitleCase(value) }))} />
<div className="grid gap-4 sm:grid-cols-2">
<LabeledInput label={dict.companyPhone} required maxLength={80} value={form.companyPhone} onChange={(value) => setForm((current) => ({ ...current, companyPhone: value }))} />
<LabeledInput label={dict.fax} maxLength={80} value={form.fax} onChange={(value) => setForm((current) => ({ ...current, fax: value }))} />
</div>
<BilingualInput label={dict.streetAddress} required maxLength={200} value={form.streetAddress} onChange={(value) => setForm((current) => ({ ...current, streetAddress: value }))} />
<div className="grid gap-4 sm:grid-cols-2">
<BilingualInput label={dict.city} required maxLength={120} value={form.city} onChange={(value) => setForm((current) => ({ ...current, city: value }))} />
<BilingualInput label={dict.country} required maxLength={120} value={form.country} onChange={(value) => setForm((current) => ({ ...current, country: value }))} />
</div>
<LabeledInput label={dict.zipCode} required maxLength={40} value={form.zipCode} onChange={(value) => setForm((current) => ({ ...current, zipCode: value.toUpperCase() }))} />
<LabeledSelect label={dict.yearsActive} required value={form.yearsActive} options={['', ...YEARS_ACTIVE_OPTIONS]} labels={dict.yearsActiveOptions} onChange={(value) => setForm((current) => ({ ...current, yearsActive: value }))} />
</div>
<NavActions onBack={() => setStep(1)} onNext={() => setStep(3)} nextLabel={dict.continue} backLabel={dict.back} loadingLabel={dict.working} />
</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-slate-900 bg-slate-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.reviewPlan}:</span> {form.plan} · {dict.billingPeriodOptions[form.billingPeriod]} · MAD</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewOwnerEmail}:</span> {form.email || '—'}</p>
<p className="mt-2"><span className="font-semibold text-slate-900">{dict.reviewPhone}:</span> {form.companyPhone || '—'}</p>
</div>
<NavActions
onBack={() => setStep(3)}
onNext={submitSignup}
loading={loading}
nextLabel={dict.createWorkspace}
backLabel={dict.back}
loadingLabel={dict.working}
/>
</div>
) : null}
</div>
</div>
</main>
</PublicShell>
)
}
function hasRequiredPartnerFields(form: SignupForm) {
const bilingualFilled = (f: BilingualField) => f.fr.trim().length > 0 || f.ar.trim().length > 0
return (
bilingualFilled(form.companyName) &&
bilingualFilled(form.streetAddress) &&
bilingualFilled(form.city) &&
bilingualFilled(form.country) &&
[form.legalForm, form.registrationNumber, form.zipCode, form.companyPhone, form.yearsActive]
.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 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 normalizeTitleCase(value: string) {
if (!value) return value
const trimmedStart = value.replace(/^\s+/, '')
if (!trimmedStart) return ''
return trimmedStart.charAt(0).toUpperCase() + trimmedStart.slice(1).toLowerCase()
}
function normalizeEmail(value: string) {
return value.replace(/\s+/g, '').toLowerCase()
}
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'
}