feat: add employee email verification on signup
Build & Deploy / Build & Push Docker Image (push) Failing after 41s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m1s
Test / Marketplace Unit Tests (push) Successful in 9m36s
Test / Admin Unit Tests (push) Successful in 9m38s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Has been cancelled

- Signup sends verification email instead of auto-login; login blocks unverified emails
- New endpoints: POST /verify-email and POST /resend-verification
- New verify-email page in dashboard with token-based email confirmation
- DB migration adds emailVerified and emailVerificationToken fields to Employee
- Update sign-in/sign-up/reset-password flows to handle verification states
- Minor UI polish across dashboard and marketplace components
- Refactor marketplace-homepage types
This commit is contained in:
root
2026-06-25 20:25:16 -04:00
parent cd67db99ed
commit c5f614b3e4
22 changed files with 594 additions and 190 deletions
@@ -1,7 +1,6 @@
import { Router } from 'express' import { Router } from 'express'
import { parseBody } from '../../http/validate' import { parseBody } from '../../http/validate'
import { created } from '../../http/respond' import { created, ok } from '../../http/respond'
import { setSessionCookie } from '../../security/sessionCookies'
import { accountStartSchema } from './auth.account.schemas' import { accountStartSchema } from './auth.account.schemas'
import * as service from './auth.account.service' import * as service from './auth.account.service'
@@ -11,14 +10,13 @@ const router = Router()
* POST /api/v1/auth/account/start * POST /api/v1/auth/account/start
* *
* Minimal signup — Step 0 of progressive profiling. * Minimal signup — Step 0 of progressive profiling.
* Creates a DRAFT company + OWNER employee and returns a JWT session. * Creates a DRAFT company + OWNER employee and sends a verification email.
* See progressive-signup-plan.md Section 3. * The user must verify their email before signing in.
*/ */
router.post('/start', async (req, res, next) => { router.post('/start', async (req, res, next) => {
try { try {
const body = parseBody(accountStartSchema, req) const body = parseBody(accountStartSchema, req)
const result = await service.startAccount(body) const result = await service.startAccount(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
created(res, result) created(res, result)
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
@@ -1,17 +1,30 @@
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors' import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { signActorToken } from '../../security/tokens' import { sendTransactionalEmail } from '../../services/notificationService'
import { presentEmployeeSession } from './auth.presenter'
import * as repo from './auth.company.repo' import * as repo from './auth.company.repo'
import type { output } from 'zod' import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas' import type { accountStartSchema } from './auth.account.schemas'
type AccountStartInput = output<typeof accountStartSchema> type AccountStartInput = output<typeof accountStartSchema>
function ensureDashboardBasePath(baseUrl: string) {
try {
const url = new URL(baseUrl)
const pathname = url.pathname.replace(/\/$/, '')
if (!pathname.endsWith('/dashboard')) url.pathname = `${pathname}/dashboard`
return url.toString().replace(/\/$/, '')
} catch {
const trimmed = baseUrl.replace(/\/$/, '')
return trimmed.endsWith('/dashboard') ? trimmed : `${trimmed}/dashboard`
}
}
/** /**
* Minimal account creation — Step 0 of progressive signup. * Minimal account creation — Step 0 of progressive signup.
* Creates a DRAFT company + OWNER employee, logs the user in immediately. * Creates a DRAFT company + OWNER employee, sends a verification email.
* The user must verify their email before they can log in.
*/ */
export async function startAccount(body: AccountStartInput) { export async function startAccount(body: AccountStartInput) {
if (await repo.findEmployeeByEmail(body.email)) { if (await repo.findEmployeeByEmail(body.email)) {
@@ -20,6 +33,7 @@ export async function startAccount(body: AccountStartInput) {
const slug = `company-${Date.now().toString(36)}` const slug = `company-${Date.now().toString(36)}`
const passwordHash = await bcrypt.hash(body.password, 12) const passwordHash = await bcrypt.hash(body.password, 12)
const verificationToken = crypto.randomBytes(32).toString('hex')
const result = await prisma.$transaction(async (tx: any) => { const result = await prisma.$transaction(async (tx: any) => {
const now = new Date() const now = new Date()
@@ -67,6 +81,7 @@ export async function startAccount(body: AccountStartInput) {
lastName: '', lastName: '',
email: body.email, email: body.email,
passwordHash, passwordHash,
emailVerificationToken: verificationToken,
role: 'OWNER', role: 'OWNER',
preferredLanguage: body.preferredLanguage, preferredLanguage: body.preferredLanguage,
isActive: true, isActive: true,
@@ -74,12 +89,43 @@ export async function startAccount(body: AccountStartInput) {
include: { company: true }, include: { company: true },
}) })
return employee return { employee, company }
}) })
const token = signActorToken(result.id, 'employee', { const dashboardUrl = ensureDashboardBasePath(
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as any, process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
}
const emailBodies: Record<string, string> = {
en: `<p>Welcome to FleetOS!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
fr: `<p>Bienvenue sur FleetOS !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
ar: `<p>مرحباً بك في FleetOS!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
}
const emailTexts: Record<string, string> = {
en: `Welcome to FleetOS!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
fr: `Bienvenue sur FleetOS !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
ar: `مرحباً بك في FleetOS!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
}
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
await sendTransactionalEmail({
to: body.email,
subject: emailSubjects[lang],
html: emailBodies[lang],
text: emailTexts[lang],
}).catch((err) => {
console.error('[AccountStart] Verification email delivery failed:', err?.message)
}) })
return { token, ...presentEmployeeSession(result as any, token) } return {
message: 'Account created. Please check your email to verify your address before signing in.',
email: body.email,
}
} }
@@ -70,3 +70,27 @@ export function resetPassword(id: string, passwordHash: string) {
}, },
}) })
} }
export function setEmailVerificationToken(id: string, token: string) {
return prisma.employee.update({
where: { id },
data: { emailVerificationToken: token },
})
}
export function findEmployeeByVerificationToken(token: string) {
return prisma.employee.findFirst({
where: { emailVerificationToken: token },
include: { company: true },
})
}
export function verifyEmployeeEmail(id: string) {
return prisma.employee.update({
where: { id },
data: {
emailVerified: new Date(),
emailVerificationToken: null,
},
})
}
@@ -61,4 +61,18 @@ router.post('/reset-password', async (req, res, next) => {
} catch (err) { next(err) } } catch (err) { next(err) }
}) })
router.post('/verify-email', async (req, res, next) => {
try {
const { token } = parseBody(employeeResetPasswordSchema.pick({ token: true }), req)
ok(res, await service.verifyEmail(token))
} catch (err) { next(err) }
})
router.post('/resend-verification', async (req, res, next) => {
try {
const { email } = parseBody(employeeForgotPasswordSchema, req)
ok(res, await service.resendVerification(email))
} catch (err) { next(err) }
})
export default router export default router
@@ -105,6 +105,10 @@ export async function login(body: EmployeeLoginInput) {
throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set') throw new AppError('This account does not have a password yet. Use the invitation or reset email to set one first.', 401, 'password_not_set')
} }
if (!employee.emailVerified) {
throw new AppError('Please verify your email address before signing in. Check your inbox for the verification link.', 401, 'email_not_verified')
}
const validPassword = await bcrypt.compare(body.password, employee.passwordHash) const validPassword = await bcrypt.compare(body.password, employee.passwordHash)
if (!validPassword) { if (!validPassword) {
throw new AppError('Invalid email or password', 401, 'invalid_credentials') throw new AppError('Invalid email or password', 401, 'invalid_credentials')
@@ -175,3 +179,53 @@ export async function resetPassword(token: string, password: string) {
await repo.resetPassword(employee.id, await bcrypt.hash(password, 12)) await repo.resetPassword(employee.id, await bcrypt.hash(password, 12))
return { message: 'Password updated successfully. You can now sign in.' } return { message: 'Password updated successfully. You can now sign in.' }
} }
export async function verifyEmail(token: string) {
const employee = await repo.findEmployeeByVerificationToken(token)
if (!employee) {
throw new AppError('Verification link is invalid or has expired.', 400, 'invalid_token')
}
await repo.verifyEmployeeEmail(employee.id)
return { message: 'Email verified successfully. You can now sign in.', email: employee.email }
}
export async function resendVerification(email: string) {
const employee = await repo.findActiveEmployeeByEmail(email)
if (!employee) {
return { message: 'If that email is registered and not yet verified, a new verification link has been sent.' }
}
if (employee.emailVerified) {
return { message: 'This email is already verified. You can sign in.' }
}
const verificationToken = crypto.randomBytes(32).toString('hex')
await repo.setEmailVerificationToken(employee.id, verificationToken)
const dashboardUrl = ensureDashboardBasePath(
process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard',
)
const verifyUrl = `${dashboardUrl}/verify-email?token=${encodeURIComponent(verificationToken)}`
const lang = (employee.preferredLanguage === 'ar' || employee.preferredLanguage === 'fr') ? employee.preferredLanguage : 'en'
const emailSubjects: Record<string, string> = {
en: 'Verify your email — FleetOS',
fr: 'Vérifiez votre email — FleetOS',
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
}
await sendTransactionalEmail({
to: employee.email,
subject: emailSubjects[lang],
html: `<p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p>`,
text: `Verify your email by visiting:\n${verifyUrl}`,
}).catch((err) => {
console.error('[ResendVerification] Email delivery failed:', err?.message)
})
return { message: 'A new verification link has been sent to your email.' }
}
@@ -657,7 +657,7 @@ export default function SettingsPage() {
<div className="mt-5 space-y-4"> <div className="mt-5 space-y-4">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div> <div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.FleetOS.com</div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label> <label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
@@ -17,6 +17,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
title: 'Forgot your password?', title: 'Forgot your password?',
subtitle: "Enter your work email and we'll send you a reset link.", subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email', email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Send reset link', submit: 'Send reset link',
submitting: 'Sending…', submitting: 'Sending…',
backToLogin: 'Back to sign in', backToLogin: 'Back to sign in',
@@ -28,6 +29,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
title: 'Mot de passe oublié ?', title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.", subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email', email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Envoyer le lien', submit: 'Envoyer le lien',
submitting: 'Envoi…', submitting: 'Envoi…',
backToLogin: 'Retour à la connexion', backToLogin: 'Retour à la connexion',
@@ -39,6 +41,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
title: 'نسيت كلمة المرور؟', title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.', subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
submit: 'إرسال رابط إعادة التعيين', submit: 'إرسال رابط إعادة التعيين',
submitting: 'جارٍ الإرسال…', submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول', backToLogin: 'العودة إلى تسجيل الدخول',
@@ -89,7 +92,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
<a href={marketplaceUrl} target="_top"> <a href={marketplaceUrl} target="_top">
<Image <Image
src={DASHBOARD_LOGO_SRC} src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo" alt="FleetOS"
width={104} width={104}
height={104} height={104}
priority priority
@@ -124,7 +127,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com" placeholder={dict.emailPlaceholder}
className="input-field" className="input-field"
/> />
</div> </div>
+1 -1
View File
@@ -12,7 +12,7 @@ const jetbrainsMono = JetBrains_Mono({
}) })
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard', title: 'FleetOS Dashboard',
description: 'Manage your rental car business', description: 'Manage your rental car business',
icons: { icons: {
icon: '/dashboard/icon.svg', icon: '/dashboard/icon.svg',
+2 -2
View File
@@ -96,7 +96,7 @@ export default function OnboardingPage() {
<main className="flex items-center justify-center px-4 py-12"> <main className="flex items-center justify-center px-4 py-12">
<div className="w-full max-w-lg"> <div className="w-full max-w-lg">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a> <a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">FleetOS</a>
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1> <h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p> <p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div> </div>
@@ -251,7 +251,7 @@ export default function OnboardingPage() {
checked={payments.isListedOnMarketplace} checked={payments.isListedOnMarketplace}
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })} onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
/> />
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span> <span className="text-sm text-slate-700">List my company on the FleetOS marketplace</span>
</label> </label>
<div className="flex gap-3"> <div className="flex gap-3">
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button> <button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
@@ -79,6 +79,8 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [confirm, setConfirm] = useState('') const [confirm, setConfirm] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -144,27 +146,65 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
)} )}
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.newPassword}</label> <label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.newPassword}</label>
<div className="relative">
<input <input
type="password" type={showPassword ? 'text' : 'password'}
required required
minLength={8} minLength={8}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="input-field" className="input-field pr-10"
/> />
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.confirmPassword}</label> <label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.confirmPassword}</label>
<div className="relative">
<input <input
type="password" type={showConfirm ? 'text' : 'password'}
required required
minLength={8} minLength={8}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="input-field" className="input-field pr-10"
/> />
<button
type="button"
onClick={() => setShowConfirm((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showConfirm ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div> </div>
<button <button
type="submit" type="submit"
@@ -200,7 +240,7 @@ function ResetShell({
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12"> <main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
<div className="w-full max-w-md"> <div className="w-full max-w-md">
<div className="mb-8 text-center"> <div className="mb-8 text-center">
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">RentalDriveGo</span> <span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">FleetOS</span>
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1> <h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1>
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>} {subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
</div> </div>
@@ -27,10 +27,11 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
const initializedFromQuery = useRef(false) const initializedFromQuery = useRef(false)
const dict = { const dict = {
en: { en: {
brandName: 'Space Agency', brandName: 'FleetOS',
title: 'Sign in', title: 'Sign in',
subtitle: 'Enter your credentials to access your account.', subtitle: 'Enter your credentials to access your account.',
email: 'Email', email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Password', password: 'Password',
signIn: 'Sign in', signIn: 'Sign in',
signingIn: 'Signing in…', signingIn: 'Signing in…',
@@ -38,18 +39,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'Verifying…', verifying: 'Verifying…',
authCode: 'Authentication code', authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.', enterCode: 'Enter the 6-digit code from your authenticator app.',
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
back: 'Back to credentials', back: 'Back to credentials',
forgotPassword: 'Forgot your password?', forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.', invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"', passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
tooManyRequests: 'Too many attempts. Please try again later.', tooManyRequests: 'Too many attempts. Please try again later.',
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
unexpectedError: 'Something went wrong. Please try again.', unexpectedError: 'Something went wrong. Please try again.',
}, },
fr: { fr: {
brandName: 'Espace agence', brandName: 'FleetOS',
title: 'Connexion', title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.', subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email', email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Mot de passe', password: 'Mot de passe',
signIn: 'Connexion', signIn: 'Connexion',
signingIn: 'Connexion…', signingIn: 'Connexion…',
@@ -57,18 +61,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'Vérification…', verifying: 'Vérification…',
authCode: "Code d'authentification", authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.", enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants', back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?', forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.', invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`, passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`,
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.', tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.', unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
}, },
ar: { ar: {
brandName: 'مساحة الوكالة', brandName: 'FleetOS',
title: 'تسجيل الدخول', title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.', subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني', email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
password: 'كلمة المرور', password: 'كلمة المرور',
signIn: 'تسجيل الدخول', signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…', signingIn: 'جارٍ تسجيل الدخول…',
@@ -76,11 +83,13 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'جارٍ التحقق…', verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة', authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.', enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
back: 'العودة إلى بيانات الدخول', back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟', forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.', invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"', passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.', tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
}, },
}[language] }[language]
@@ -149,7 +158,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
<a href={marketplaceUrl} target="_top"> <a href={marketplaceUrl} target="_top">
<Image <Image
src={DASHBOARD_LOGO_SRC} src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo" alt="FleetOS"
width={104} width={104}
height={104} height={104}
priority priority
@@ -158,7 +167,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
</a> </a>
</div> </div>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300"> <p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
RentalDriveGo FleetOS
</p> </p>
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50"> <h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.brandName} {dict.brandName}
@@ -210,6 +219,9 @@ function LocalSignInForm({
invalidCredentials: string invalidCredentials: string
passwordNotSet: string passwordNotSet: string
tooManyRequests: string tooManyRequests: string
emailPlaceholder: string
totpPlaceholder: string
emailNotVerified: string
unexpectedError: string unexpectedError: string
} }
}) { }) {
@@ -291,6 +303,11 @@ function LocalSignInForm({
return true return true
} }
if (empJson?.error === 'email_not_verified') {
setError(dict.emailNotVerified)
return true
}
if (empRes.status === 429) { if (empRes.status === 429) {
setError(dict.tooManyRequests) setError(dict.tooManyRequests)
return true return true
@@ -364,7 +381,7 @@ function LocalSignInForm({
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com" placeholder={dict.emailPlaceholder}
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" 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>
@@ -430,7 +447,7 @@ function LocalSignInForm({
required required
value={totpCode} value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())} onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
placeholder="000000 or XXXX-XXXX-XXXX" placeholder={dict.totpPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] 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" className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] 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>
@@ -6,23 +6,27 @@ import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell' import PublicShell from '@/components/layout/PublicShell'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls' import { marketplaceUrl } from '@/lib/urls'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
export default function SignUpForm() { export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n() const { language, setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language) const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const dict = { const dict = {
en: { en: {
title: 'Create your workspace', title: 'Create your workspace',
subtitle: 'Enter your email and password to get started. You can complete your profile after signing in.', subtitle: 'Enter your email and password to get started.',
email: 'Email', email: 'Email',
emailPlaceholder: 'you@company.com',
password: 'Password', password: 'Password',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 characters',
languageLabel: 'Preferred language', languageLabel: 'Preferred language',
create: 'Create workspace', create: 'Create workspace',
creating: 'Creating\u2026', creating: 'Creating\u2026',
@@ -30,12 +34,18 @@ export default function SignUpForm() {
signIn: 'Sign in', signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.', errorEmailTaken: 'An account with this email already exists.',
errorGeneric: 'Something went wrong. Please try again.', errorGeneric: 'Something went wrong. Please try again.',
successTitle: 'Check your email',
successMessage: 'We sent a verification link to {email}. Click the link to activate your account, then sign in.',
successSignIn: 'Go to sign in',
}, },
fr: { fr: {
title: 'Cr\u00e9ez votre espace', 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.', subtitle: 'Saisissez votre email et mot de passe pour commencer.',
email: 'Email', email: 'Email',
emailPlaceholder: 'vous@entreprise.com',
password: 'Mot de passe', password: 'Mot de passe',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 caract\u00e8res',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e', languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace', create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026', creating: 'Cr\u00e9ation\u2026',
@@ -43,12 +53,18 @@ export default function SignUpForm() {
signIn: 'Se connecter', signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.', errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.', errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
successTitle: 'V\u00e9rifiez votre email',
successMessage: 'Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.',
successSignIn: 'Aller \u00e0 la connexion',
}, },
ar: { ar: {
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643', 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.', 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.',
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a', email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
emailPlaceholder: 'you@company.com',
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631', password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: '\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641',
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629', 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', 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', creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
@@ -56,6 +72,9 @@ export default function SignUpForm() {
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644', 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.', 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.', 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.',
successTitle: '\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
successMessage: '\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
successSignIn: '\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
}, },
}[preferredLanguage] }[preferredLanguage]
@@ -65,17 +84,14 @@ export default function SignUpForm() {
setError(null) setError(null)
try { try {
const res = await apiFetch<{ token: string }>('/auth/account/start', { await apiFetch('/auth/account/start', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password, preferredLanguage }), body: JSON.stringify({ email, password, preferredLanguage }),
}) })
setLanguage(preferredLanguage) setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax` document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
setSuccess(true)
if (typeof window !== 'undefined' && res.token) {
window.location.href = toPublicDashboardPath('/')
}
} catch (err: any) { } catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') { if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
setError(dict.errorEmailTaken) setError(dict.errorEmailTaken)
@@ -87,6 +103,41 @@ export default function SignUpForm() {
} }
} }
if (success) {
return (
<PublicShell>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="FleetOS"
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"
/>
</div>
<div 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 dark:border-blue-900 dark:bg-blue-950/78">
<h1 className="text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.successTitle}
</h1>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
{dict.successMessage.replace('{email}', email)}
</p>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.successSignIn}
</a>
</div>
</div>
</main>
</PublicShell>
)
}
return ( return (
<PublicShell> <PublicShell>
<main className="flex flex-1 items-center justify-center px-4 py-16"> <main className="flex flex-1 items-center justify-center px-4 py-16">
@@ -96,7 +147,7 @@ export default function SignUpForm() {
<a href={marketplaceUrl} target="_top"> <a href={marketplaceUrl} target="_top">
<Image <Image
src="/dashboard/rentalcardrive.png" src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo" alt="FleetOS"
width={80} width={80}
height={80} height={80}
priority priority
@@ -129,7 +180,7 @@ export default function SignUpForm() {
required required
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com" placeholder={dict.emailPlaceholder}
autoFocus 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" 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"
/> />
@@ -139,16 +190,35 @@ export default function SignUpForm() {
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200"> <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> {dict.password} <span className="text-red-500">*</span>
</label> </label>
<div className="relative">
<input <input
type="password" type={showPassword ? 'text' : 'password'}
required required
minLength={8} minLength={8}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" 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" className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 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> <button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">{dict.passwordHint}</p>
</div> </div>
<div> <div>
@@ -0,0 +1,130 @@
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import Image from 'next/image'
import PublicShell from '@/components/layout/PublicShell'
import { useDashboardI18n } from '@/components/I18nProvider'
export default function VerifyEmailPage() {
const { language } = useDashboardI18n()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const dict = {
en: {
verifying: 'Verifying your email…',
success: 'Email verified! You can now sign in.',
error: 'This verification link is invalid or has expired.',
signIn: 'Go to sign in',
},
fr: {
verifying: 'Vérification de votre email…',
success: 'Email vérifié ! Vous pouvez maintenant vous connecter.',
error: 'Ce lien de vérification est invalide ou a expiré.',
signIn: 'Aller à la connexion',
},
ar: {
verifying: 'جارٍ التحقق من بريدك الإلكتروني…',
success: 'تم التحقق من البريد الإلكتروني! يمكنك الآن تسجيل الدخول.',
error: 'رابط التحقق هذا غير صالح أو منتهي الصلاحية.',
signIn: 'الذهاب إلى تسجيل الدخول',
},
}[language]
useEffect(() => {
if (!token) {
setStatus('error')
return
}
let cancelled = false
async function verify() {
try {
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api/v1'
const res = await fetch(`${API_BASE}/auth/employee/verify-email`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
})
if (cancelled) return
if (res.ok) {
setStatus('success')
} else {
setStatus('error')
}
} catch {
if (!cancelled) setStatus('error')
}
}
verify()
return () => {
cancelled = true
}
}, [token])
return (
<PublicShell>
<main className="flex flex-1 items-center justify-center px-4 py-16">
<div className="w-full max-w-md text-center">
<div className="flex justify-center mb-6">
<Image
src="/dashboard/rentalcardrive.png"
alt="FleetOS"
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"
/>
</div>
<div 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 dark:border-blue-900 dark:bg-blue-950/78">
{status === 'loading' && (
<>
<div className="mx-auto mb-4 h-6 w-6 animate-spin rounded-full border-2 border-orange-500 border-t-transparent" />
<p className="text-sm text-stone-600 dark:text-stone-300">{dict.verifying}</p>
</>
)}
{status === 'success' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/40">
<svg className="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.success}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
{status === 'error' && (
<>
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40">
<svg className="h-6 w-6 text-red-600 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<h1 className="text-xl font-black text-blue-950 dark:text-stone-50">{dict.error}</h1>
<a
href="/sign-in"
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.signIn}
</a>
</>
)}
</div>
</div>
</main>
</PublicShell>
)
}
@@ -165,7 +165,7 @@ export default function PublicFooter() {
</div> </div>
<p className="text-sm text-stone-500 dark:text-stone-400"> <p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel} &copy; {new Date().getFullYear()} FleetOS. {footerContent.rightsLabel}
</p> </p>
</div> </div>
</footer> </footer>
@@ -109,13 +109,13 @@ export default function PublicHeader() {
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]"> <a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]">
<Image <Image
src={DASHBOARD_LOGO_SRC} src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo" alt="FleetOS"
width={36} width={36}
height={36} height={36}
priority priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9" className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/> />
<span>RentalDriveGo</span> <span>FleetOS</span>
</a> </a>
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3"> <div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
@@ -401,7 +401,7 @@ export default function Sidebar() {
)} )}
</div> </div>
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300"> <span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
{brand?.displayName ?? 'RentalDriveGo'} {brand?.displayName ?? 'FleetOS'}
</span> </span>
</a> </a>
+1 -1
View File
@@ -5,7 +5,7 @@ import { getMarketplaceLanguage } from '@/lib/i18n.server'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'RentalDriveGo Marketplace', title: 'FleetOS Marketplace',
description: 'Discover vehicles from trusted rental companies.', description: 'Discover vehicles from trusted rental companies.',
icons: { icons: {
icon: '/rentalcardrive.png', icon: '/rentalcardrive.png',
@@ -101,7 +101,7 @@ export default function MarketplaceFooter({
</div> </div>
<p className="text-sm text-stone-500 dark:text-stone-400"> <p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {rightsLabel} &copy; {new Date().getFullYear()} FleetOS. {rightsLabel}
</p> </p>
</div> </div>
</footer> </footer>
@@ -107,13 +107,13 @@ export default function MarketplaceHeader({
<Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]"> <Link href="/" className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 dark:text-stone-100 sm:text-sm sm:tracking-[0.2em]">
<Image <Image
src="/rentalcardrive.png" src="/rentalcardrive.png"
alt="RentalDriveGo" alt="FleetOS"
width={36} width={36}
height={36} height={36}
priority priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9" className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/> />
<span>RentalDriveGo</span> <span>FleetOS</span>
</Link> </Link>
</div> </div>
@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "employees" ADD COLUMN "emailVerified" TIMESTAMP(3);
ALTER TABLE "employees" ADD COLUMN "emailVerificationToken" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "employees_emailVerificationToken_key" ON "employees"("emailVerificationToken");
+2
View File
@@ -1001,6 +1001,8 @@ model Employee {
passwordHash String? passwordHash String?
passwordResetToken String? @unique passwordResetToken String? @unique
passwordResetExpiresAt DateTime? passwordResetExpiresAt DateTime?
emailVerified DateTime?
emailVerificationToken String? @unique
role EmployeeRole @default(AGENT) role EmployeeRole @default(AGENT)
preferredLanguage String @default("en") preferredLanguage String @default("en")
isActive Boolean @default(true) isActive Boolean @default(true)
+137 -137
View File
@@ -97,298 +97,298 @@ export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[
export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = { export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
en: { en: {
sections: defaultMarketplaceHomepageSections, sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo', heroKicker: 'FleetOS',
heroTitle: 'Marketplace discovery with a sharper front door.', heroTitle: 'The operating system for modern fleet management.',
heroBody: heroBody:
'Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.', 'Run your entire rental operation from one platform — inventory, bookings, payments, and customer experience, all under your own brand.',
startTrial: 'Start free trial', startTrial: 'Start free trial',
exploreVehicles: 'Explore vehicles', exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface', surfaceLabel: 'Unified dashboard',
surfaceTitle: 'Designed for two audiences at once.', surfaceTitle: 'One platform. Every operation.',
surfaceBody: surfaceBody:
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.', 'Fleet operators need power, renters need simplicity. FleetOS delivers both without compromise — no templates, no generic workflows.',
liveLabel: 'Live network', liveLabel: 'Live platform',
trustedFleets: 'Trusted fleets', trustedFleets: 'Real-time fleet control',
brandedFlows: 'Branded booking flows', brandedFlows: 'White-label booking engine',
multiTenant: 'Multi-tenant operations', multiTenant: 'Multi-company architecture',
companyKicker: 'Operator workflow', companyKicker: 'Operator command center',
companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.', companyTitle: 'Manage inventory, pricing, contracts, and team from a single pane of glass.',
companyBody: companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.', "Publish once, control visibility per channel, and keep every company's data, payments, and reporting fully isolated.",
renterKicker: 'Renter experience', renterKicker: 'Customer experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.', renterTitle: 'Give renters a fast, transparent booking journey that ends on your branded checkout.',
renterBody: renterBody:
'The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.', 'FleetOS powers discovery and conversion — not a walled garden. Browse here, book there, and pay directly to the fleet owner.',
pillars: [ pillars: [
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'], ['Unified command', 'Vehicle inventory, dynamic pricing, and promotional offers flow from one admin hub into marketplace discovery and your own branded storefront.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'], ['Smart discovery', 'Location-aware search, reputation scoring, and AI-curated listings help renters find the right vehicle in seconds.'],
['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'], ['Direct revenue', 'Every booking routes through your own payment gateway — you own the customer relationship and the cash flow.'],
].map(([title, body]) => ({ title: title!, body: body! })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'Shared marketplace visibility'], ['01', 'Multi-tenant fleet operations'],
['02', 'Direct company checkout'], ['02', 'White-label booking experience'],
['03', 'Company-owned payments'], ['03', 'Direct-to-owner payments'],
].map(([value, label]) => ({ value: value!, label: label! })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'What the product covers', featureLabel: 'What FleetOS covers',
features: [ features: [
'Fleet management with multi-photo uploads', 'Fleet inventory with multi-photo uploads and condition tracking',
'Marketplace offers and redirect booking flow', 'Dynamic pricing engine with seasonal and promotional rules',
'Branded public booking site per company', 'White-label booking site per fleet operator',
'Customer CRM, analytics, and billing controls', 'Customer CRM, real-time analytics, and automated billing',
], ],
howitworksKicker: 'GETTING STARTED', howitworksKicker: 'GETTING STARTED',
howitworksTitle: 'How It Works', howitworksTitle: 'How It Works',
howitworksSteps: [ howitworksSteps: [
{ {
number: '1', number: '1',
title: 'Create Account', title: 'Create Your Workspace',
description: 'Sign up for your company workspace and choose your pricing plan for the 90-day free trial.', description: 'Sign up and launch your fleet workspace with a 90-day free trial — no credit card required.',
}, },
{ {
number: '2', number: '2',
title: 'Add Your Fleet', title: 'Onboard Your Fleet',
description: 'Upload vehicle photos, set prices, and create offers visible on the marketplace.', description: 'Upload vehicles, configure pricing, and publish offers that appear on the marketplace instantly.',
}, },
{ {
number: '3', number: '3',
title: 'Start Selling', title: 'Go Live',
description: 'Renters discover your fleet, and bookings flow directly to your branded checkout.', description: 'Customers discover your fleet, book through your branded site, and payments land directly in your account.',
}, },
], ],
stepsTitle: 'How companies launch', stepsTitle: 'How fleets launch on FleetOS',
steps: [ steps: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-day trial, and verify the owner account.'], ['1', 'Create your fleet workspace', 'Choose a plan, start your 90-day trial, and verify your operator account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'], ['2', 'Publish vehicles and offers', 'Upload vehicle data once and control exactly what appears on every channel.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'], ['3', 'Accept bookings on your terms', 'Customers discover your fleet on FleetOS, then complete the booking on your own branded checkout.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Step', stepLabel: 'Step',
testimonialsKicker: 'CUSTOMER STORIES', testimonialsKicker: 'CUSTOMER STORIES',
testimonialsTitle: 'Trusted by rental companies worldwide', testimonialsTitle: 'Trusted by fleet operators worldwide',
testimonials: [ testimonials: [
{ {
quote: quote:
'RentalDriveGo transformed how we manage our fleet and reach customers. The marketplace visibility has doubled our bookings.', 'FleetOS transformed how we manage our 200+ vehicle fleet. The unified dashboard cut our admin time by half and our bookings doubled in three months.',
author: 'Sarah Johnson', author: 'Sarah Johnson',
role: 'Fleet Manager', role: 'Fleet Director',
company: 'Premium Auto Rentals', company: 'Premium Auto Rentals',
}, },
{ {
quote: quote:
'The two-sided platform gives us control over our operations while renters get a seamless experience. Perfect balance.', 'The white-label booking engine is a game-changer. Our customers never leave our brand experience, yet we get marketplace-level visibility.',
author: 'Ahmed Hassan', author: 'Ahmed Hassan',
role: 'Operations Director', role: 'Operations Director',
company: 'Desert Wheels', company: 'Desert Wheels',
}, },
{ {
quote: quote:
'We appreciated how easy it was to set up our branded booking flow. Our customers love the direct payment experience.', 'We evaluated six platforms before FleetOS. The multi-tenant architecture and direct payment model made it the obvious choice for our growing operation.',
author: 'Marie Dubois', author: 'Marie Dubois',
role: 'Owner', role: 'CEO',
company: 'Provence Car Hire', company: 'Provence Car Hire',
}, },
], ],
readyKicker: 'Ready to launch', readyKicker: 'Ready to launch',
readyTitle: 'The marketplace homepage should explain the product in seconds.', readyTitle: 'Your fleet deserves an operating system, not just a listing site.',
readyBody: readyBody:
'Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.', 'FleetOS gives you the tools to run your entire rental business — from inventory to invoicing — while keeping your brand front and center at every touchpoint.',
viewPricing: 'View pricing', viewPricing: 'View pricing',
createWorkspace: 'Create workspace', createWorkspace: 'Create workspace',
}, },
fr: { fr: {
sections: defaultMarketplaceHomepageSections, sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo', heroKicker: 'FleetOS',
heroTitle: 'Une vitrine marketplace plus claire et plus percutante.', heroTitle: "Le système d'exploitation pour la gestion de flotte moderne.",
heroBody: heroBody:
"Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.", "Gérez l'ensemble de votre activité de location depuis une seule plateforme — inventaire, réservations, paiements et expérience client, le tout sous votre propre marque.",
startTrial: 'Commencer lessai gratuit', startTrial: "Commencer l'essai gratuit",
exploreVehicles: 'Explorer les véhicules', exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Vitrine marketplace', surfaceLabel: 'Tableau de bord unifié',
surfaceTitle: 'Pensée pour deux audiences à la fois.', surfaceTitle: 'Une plateforme. Toutes les opérations.',
surfaceBody: surfaceBody:
'Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.', 'Les opérateurs de flotte ont besoin de puissance, les clients ont besoin de simplicité. FleetOS offre les deux sans compromis — aucun modèle générique.',
liveLabel: 'Réseau actif', liveLabel: 'Plateforme active',
trustedFleets: 'Flottes fiables', trustedFleets: 'Contrôle de flotte en temps réel',
brandedFlows: 'Parcours de marque', brandedFlows: 'Moteur de réservation en marque blanche',
multiTenant: 'Opérations multi-tenant', multiTenant: 'Architecture multi-entreprise',
companyKicker: 'Flux opérateur', companyKicker: 'Centre de commande opérateur',
companyTitle: 'Pilotez linventaire, les offres, la facturation et léquipe depuis un seul centre de contrôle.', companyTitle: "Gérez l'inventaire, les tarifs, les contrats et l'équipe depuis une interface unique.",
companyBody: companyBody:
'Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.', 'Publiez une seule fois, contrôlez la visibilité par canal et gardez les données, les paiements et les rapports de chaque entreprise totalement isolés.',
renterKicker: 'Expérience client', renterKicker: 'Expérience client',
renterTitle: 'Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.', renterTitle: 'Offrez aux clients un parcours de réservation rapide et transparent qui se termine sur votre page de marque.',
renterBody: renterBody:
'La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.', 'FleetOS alimente la découverte et la conversion — pas un jardin fermé. Parcourez ici, réservez là-bas et payez directement au propriétaire de la flotte.',
pillars: [ pillars: [
['Publication unifiée', 'Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte marketplace et les pages de réservation de marque.'], ["Commande unifiée", "L'inventaire, la tarification dynamique et les offres promotionnelles circulent depuis un hub d'administration unique vers la marketplace et votre vitrine de marque."],
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'], ['Découverte intelligente', 'Recherche géolocalisée, score de réputation et annonces optimisées aident les clients à trouver le bon véhicule en quelques secondes.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise afin de préserver la relation client et lencaissement.'], ['Revenus directs', 'Chaque réservation passe par votre propre passerelle de paiement — vous gardez la relation client et la trésorerie.'],
].map(([title, body]) => ({ title: title!, body: body! })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'Visibilité marketplace partagée'], ['01', 'Opérations de flotte multi-tenant'],
['02', 'Paiement direct entreprise'], ['02', 'Expérience de réservation en marque blanche'],
['03', 'Paiements gérés par lentreprise'], ['03', 'Paiements directs au propriétaire'],
].map(([value, label]) => ({ value: value!, label: label! })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'Ce que couvre le produit', featureLabel: 'Ce que FleetOS couvre',
features: [ features: [
'Gestion de flotte avec téléversement de plusieurs photos', "Inventaire de flotte avec photos multiples et suivi d'état",
'Offres marketplace et redirection vers la réservation', 'Moteur de tarification dynamique avec règles saisonnières et promotionnelles',
'Site public de réservation par entreprise', 'Site de réservation en marque blanche par opérateur',
'CRM client, analytics et contrôle de facturation', 'CRM client, analytics en temps réel et facturation automatisée',
], ],
howitworksKicker: 'MISE EN ROUTE', howitworksKicker: 'MISE EN ROUTE',
howitworksTitle: 'Comment ça marche', howitworksTitle: 'Comment ça marche',
howitworksSteps: [ howitworksSteps: [
{ {
number: '1', number: '1',
title: 'Créer un compte', title: 'Créez votre espace',
description: 'Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l\'essai gratuit de 90 jours.', description: 'Inscrivez-vous et lancez votre espace de flotte avec un essai gratuit de 90 jours — sans carte bancaire.',
}, },
{ {
number: '2', number: '2',
title: 'Ajouter votre flotte', title: 'Intégrez votre flotte',
description: 'Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace.', description: 'Ajoutez vos véhicules, configurez les tarifs et publiez des offres visibles instantanément sur la marketplace.',
}, },
{ {
number: '3', number: '3',
title: 'Commencer à vendre', title: 'Passez en direct',
description: 'Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque.', description: 'Les clients découvrent votre flotte, réservent via votre site de marque et les paiements arrivent directement sur votre compte.',
}, },
], ],
stepsTitle: 'Comment les entreprises démarrent', stepsTitle: 'Comment les flottes se lancent sur FleetOS',
steps: [ steps: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire.'], ['1', 'Créez votre espace de flotte', 'Choisissez un plan, démarrez votre essai de 90 jours et vérifiez votre compte opérateur.'],
['2', 'Publiez véhicules et offres', 'Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque.'], ['2', 'Publiez véhicules et offres', 'Ajoutez vos données une seule fois et contrôlez précisément ce qui apparaît sur chaque canal.'],
['3', 'Acceptez les réservations sur votre site', 'Les clients découvrent votre flotte sur RentalDriveGo puis paient directement via votre parcours de réservation.'], ['3', 'Acceptez les réservations selon vos conditions', 'Les clients découvrent votre flotte sur FleetOS, puis finalisent sur votre propre page de réservation.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'Étape', stepLabel: 'Étape',
testimonialsKicker: 'HISTOIRES DE CLIENTS', testimonialsKicker: 'HISTOIRES DE CLIENTS',
testimonialsTitle: 'Approuvé par des entreprises de location du monde entier', testimonialsTitle: 'Approuvé par des opérateurs de flotte du monde entier',
testimonials: [ testimonials: [
{ {
quote: quote:
'RentalDriveGo a transformé la gestion de notre flotte et notre portée client. La visibilité marketplace a doublé nos réservations.', "FleetOS a transformé la gestion de notre flotte de plus de 200 véhicules. Le tableau de bord unifié a réduit notre temps d'administration de moitié et nos réservations ont doublé en trois mois.",
author: 'Sophie Martin', author: 'Sophie Martin',
role: 'Responsable de Flotte', role: 'Directrice de Flotte',
company: 'Location Premium', company: 'Location Premium',
}, },
{ {
quote: quote:
'La plateforme bilatérale nous donne le contrôle de nos opérations tandis que les clients bénéficient d\'une expérience transparente. L\'équilibre parfait.', 'Le moteur de réservation en marque blanche change la donne. Nos clients ne quittent jamais notre expérience de marque, tout en bénéficiant de la visibilité marketplace.',
author: 'Jean-Claude Barbier', author: 'Jean-Claude Barbier',
role: 'Directeur des Opérations', role: 'Directeur des Opérations',
company: 'Sahara Cars', company: 'Sahara Cars',
}, },
{ {
quote: quote:
'Nous avons apprécié la facilité de mise en place de notre parcours de réservation personnalisé. Nos clients adorent l\'expérience de paiement direct.', "Nous avons évalué six plateformes avant FleetOS. L'architecture multi-tenant et le modèle de paiement direct en ont fait le choix évident pour notre croissance.",
author: 'Isabelle Leclerc', author: 'Isabelle Leclerc',
role: 'Propriétaire', role: 'PDG',
company: 'Provence Auto', company: 'Provence Auto',
}, },
], ],
readyKicker: 'Prêt à démarrer', readyKicker: 'Prêt à vous lancer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.', readyTitle: "Votre flotte mérite un système d'exploitation, pas juste un site d'annonces.",
readyBody: readyBody:
'Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.', "FleetOS vous donne les outils pour gérer l'ensemble de votre activité de location — de l'inventaire à la facturation — tout en gardant votre marque au premier plan à chaque point de contact.",
viewPricing: 'Voir les tarifs', viewPricing: 'Voir les tarifs',
createWorkspace: 'Créer lespace', createWorkspace: "Créer l'espace",
}, },
ar: { ar: {
sections: defaultMarketplaceHomepageSections, sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo', heroKicker: 'FleetOS',
heroTitle: 'واجهة سوق أكثر وضوحاً وتأثيراً.', heroTitle: 'نظام التشغيل لإدارة الأساطيل الحديثة.',
heroBody: heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.', 'قم بإدارة كامل نشاط التأجير الخاص بك من منصة واحدة — المخزون، الحجوزات، المدفوعات وتجربة العملاء، كل ذلك تحت علامتك التجارية الخاصة.',
startTrial: 'ابدأ التجربة المجانية', startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات', exploreVehicles: 'استكشف المركبات',
surfaceLabel: 'واجهة السوق', surfaceLabel: 'لوحة تحكم موحدة',
surfaceTitle: صممة لجمهورين في الوقت نفسه.', surfaceTitle: نصة واحدة. كل العمليات.',
surfaceBody: surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.', 'مشغلو الأساطيل يحتاجون إلى القوة، والمستأجرون يحتاجون إلى البساطة. FleetOS يقدم كليهما بدون تنازلات — لا قوالب جاهزة ولا سير عمل عام.',
liveLabel: 'شبكة نشطة', liveLabel: 'منصة نشطة',
trustedFleets: 'أساطيل موثوقة', trustedFleets: 'تحكم فوري بالأسطول',
brandedFlows: سارات حجز مخصصة', brandedFlows: حرك حجز بالعلامة البيضاء',
multiTenant: 'عمليات متعددة الشركات', multiTenant: 'بنية متعددة الشركات',
companyKicker: 'سير عمل المشغل', companyKicker: 'مركز قيادة المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.', companyTitle: 'أدر المخزون والأسعار والعقود والفريق من لوحة تحكم واحدة.',
companyBody: companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.', 'انشر مرة واحدة، وتحكم في الظهور حسب القناة، واحتفظ ببيانات كل شركة ومدفوعاتها وتقاريرها معزولة تماماً.',
renterKicker: 'تجربة المستأجر', renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.', renterTitle: 'امنح المستأجرين رحلة حجز سريعة وشفافة تنتهي على صفحة الدفع الخاصة بك.',
renterBody: renterBody:
'هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.', 'FleetOS يشغّل الاكتشاف والتحويل — وليس حديقة مغلقة. تصفح هنا، احجز هناك، وادفع مباشرة لمالك الأسطول.',
pillars: [ pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'], ['قيادة موحدة', 'المخزون، التسعير الديناميكي والعروض الترويجية تتدفق من مركز إدارة واحد إلى marketplace السوق وواجهة متجرك الخاصة.'],
['اكتشاف مؤهل', 'تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة.'], ['اكتشاف ذكي', 'بحث حسب الموقع، تقييم السمعة وقوائم منسقة بالذكاء الاصطناعي تساعد المستأجرين في العثور على المركبة المناسبة بثوانٍ.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها.'], ['إيرادات مباشرة', 'كل حجز يمر عبر بوابة الدفع الخاصة بك — أنت تملك علاقة العميل والتدفق النقدي.'],
].map(([title, body]) => ({ title: title!, body: body! })), ].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [ metrics: [
['01', 'ظهور مشترك في السوق'], ['01', 'عمليات أسطول متعددة الشركات'],
['02', 'دفع مباشر للشركة'], ['02', 'تجربة حجز بالعلامة البيضاء'],
['03', 'مدفوعات مملوكة للشركة'], ['03', 'مدفوعات مباشرة للمالك'],
].map(([value, label]) => ({ value: value!, label: label! })), ].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'ما الذي يقدمه المنتج', featureLabel: 'ما الذي يغطيه FleetOS',
features: [ features: [
'إدارة الأسطول مع رفع عدة صور', 'مخزون الأسطول مع رفع صور متعددة وتتبع الحالة',
'عروض السوق والتحويل إلى مسار الحجز', 'محرك تسعير ديناميكي مع قواعد موسمية وترويجية',
'موقع حجز عام مخصص لكل شركة', 'موقع حجز بالعلامة البيضاء لكل مشغل أسطول',
'إدارة العملاء والتحليلات والفوترة', 'CRM عملاء، تحليلات فورية وفوترة آلية',
], ],
howitworksKicker: 'البدء', howitworksKicker: 'البدء',
howitworksTitle: 'كيف يعمل', howitworksTitle: 'كيف يعمل',
howitworksSteps: [ howitworksSteps: [
{ {
number: '1', number: '1',
title: 'إنشاء حساب', title: 'أنشئ مساحتك',
description: 'قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً.', description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 90 يوماً — بدون بطاقة ائتمان.',
}, },
{ {
number: '2', number: '2',
title: 'أضف أسطولك', title: 'أضف أسطولك',
description: 'قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق.', description: 'أضف المركبات، واضبط الأسعار، وانشر العروض التي تظهر فوراً في marketplace السوق.',
}, },
{ {
number: '3', number: '3',
title: 'ابدأ البيع', title: 'انطلق مباشرة',
description: 'يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة.', description: 'يكتشف العملاء أسطولك، ويحجزون عبر موقعك التجاري، وتصل المدفوعات مباشرة إلى حسابك.',
}, },
], ],
stepsTitle: 'كيف تبدأ الشركات', stepsTitle: 'كيف تنطلق الأساطيل على FleetOS',
steps: [ steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'], ['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 90 يوماً وتحقق من حساب المشغل الخاص بك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'], ['2', 'انشر المركبات والعروض', 'أضف بيانات المركبات مرة واحدة وتحكم بدقة فيما يظهر على كل قناة.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'], ['3', 'استقبل الحجوزات بشروطك', 'يكتشف العملاء أسطولك على FleetOS، ثم يكملون الحجز على صفحة الدفع الخاصة بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })), ].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة', stepLabel: 'الخطوة',
testimonialsKicker: 'قصص العملاء', testimonialsKicker: 'قصص العملاء',
testimonialsTitle: 'موثوقة من قبل شركات التأجير في جميع أنحاء العالم', testimonialsTitle: 'موثوق من قبل مشغلي الأساطيل حول العالم',
testimonials: [ testimonials: [
{ {
quote: quote:
'غيّرت RentalDriveGo طريقة إدارة أسطولنا والوصول إلى العملاء. الظهور في السوق قد ضاعف حجوزاتنا.', 'غيّر FleetOS طريقة إدارة أسطولنا المكون من أكثر من 200 مركبة. لوحة التحكم الموحدة قلصت وقت الإدارة إلى النصف وتضاعفت حجوزاتنا في ثلاثة أشهر.',
author: 'فاطمة الزهراء', author: 'فاطمة الزهراء',
role: 'مدير الأسطول', role: 'مديرة الأسطول',
company: 'تأجير السيارات الممتازة', company: 'تأجير السيارات الممتازة',
}, },
{ {
quote: quote:
'تمنحنا المنصة ثنائية الجانب التحكم في عملياتنا بينما يحصل المستأجرون على تجربة سلسة. التوازن المثالي.', 'محرك الحجز بالعلامة البيضاء يغير قواعد اللعبة. عملاؤنا لا يغادرون تجربة علامتنا التجارية أبداً، مع الاستفادة من ظهور marketplace السوق.',
author: 'محمد علي', author: 'محمد علي',
role: 'مدير العمليات', role: 'مدير العمليات',
company: 'صحراء ويلز', company: 'صحراء ويلز',
}, },
{ {
quote: quote:
'أقدرنا على سهولة إعداد مسار الحجز المخصص بنا. يحب عملاؤنا تجربة الدفع المباشر.', 'قيمنا ست منصات قبل FleetOS. البنية متعددة الشركات ونموذج الدفع المباشر جعلاه الخيار الواضح لنمونا المتسارع.',
author: 'ليلى حسن', author: 'ليلى حسن',
role: 'المالك', role: 'المديرة التنفيذية',
company: 'تأجير السيارات بالبحر الأبيض', company: 'تأجير سيارات البحر الأبيض',
}, },
], ],
readyKicker: 'جاهز للانطلاق', readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.', readyTitle: 'أسطولك يستحق نظام تشغيل، وليس مجرد موقع إعلانات.',
readyBody: readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.', 'يمنحك FleetOS الأدوات اللازمة لإدارة كامل نشاط التأجير — من المخزون إلى الفوترة — مع إبقاء علامتك التجارية في المقدمة عند كل نقطة اتصال.',
viewPricing: 'عرض الأسعار', viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة', createWorkspace: 'إنشاء المساحة',
}, },