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 { parseBody } from '../../http/validate'
import { created } from '../../http/respond'
import { setSessionCookie } from '../../security/sessionCookies'
import { created, ok } from '../../http/respond'
import { accountStartSchema } from './auth.account.schemas'
import * as service from './auth.account.service'
@@ -11,14 +10,13 @@ const router = Router()
* POST /api/v1/auth/account/start
*
* Minimal signup — Step 0 of progressive profiling.
* Creates a DRAFT company + OWNER employee and returns a JWT session.
* See progressive-signup-plan.md Section 3.
* Creates a DRAFT company + OWNER employee and sends a verification email.
* The user must verify their email before signing in.
*/
router.post('/start', async (req, res, next) => {
try {
const body = parseBody(accountStartSchema, req)
const result = await service.startAccount(body)
if ('token' in result) setSessionCookie(res, 'employee', result.token, 8 * 60 * 60 * 1000)
created(res, result)
} catch (err) { next(err) }
})
@@ -1,17 +1,30 @@
import bcrypt from 'bcryptjs'
import crypto from 'crypto'
import { AppError } from '../../http/errors'
import { prisma } from '../../lib/prisma'
import { signActorToken } from '../../security/tokens'
import { presentEmployeeSession } from './auth.presenter'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as repo from './auth.company.repo'
import type { output } from 'zod'
import type { accountStartSchema } from './auth.account.schemas'
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.
* 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) {
if (await repo.findEmployeeByEmail(body.email)) {
@@ -20,6 +33,7 @@ export async function startAccount(body: AccountStartInput) {
const slug = `company-${Date.now().toString(36)}`
const passwordHash = await bcrypt.hash(body.password, 12)
const verificationToken = crypto.randomBytes(32).toString('hex')
const result = await prisma.$transaction(async (tx: any) => {
const now = new Date()
@@ -67,6 +81,7 @@ export async function startAccount(body: AccountStartInput) {
lastName: '',
email: body.email,
passwordHash,
emailVerificationToken: verificationToken,
role: 'OWNER',
preferredLanguage: body.preferredLanguage,
isActive: true,
@@ -74,12 +89,43 @@ export async function startAccount(body: AccountStartInput) {
include: { company: true },
})
return employee
return { employee, company }
})
const token = signActorToken(result.id, 'employee', {
expiresIn: (process.env.JWT_EXPIRY ?? '8h') as any,
})
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)}`
return { token, ...presentEmployeeSession(result as any, token) }
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 {
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) }
})
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
@@ -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')
}
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)
if (!validPassword) {
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))
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>
<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>
<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?',
subtitle: "Enter your work email and we'll send you a reset link.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Send reset link',
submitting: 'Sending…',
backToLogin: 'Back to sign in',
@@ -28,6 +29,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
title: 'Mot de passe oublié ?',
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
email: 'Email',
emailPlaceholder: 'owner@company.com',
submit: 'Envoyer le lien',
submitting: 'Envoi…',
backToLogin: 'Retour à la connexion',
@@ -39,6 +41,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
title: 'نسيت كلمة المرور؟',
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
submit: 'إرسال رابط إعادة التعيين',
submitting: 'جارٍ الإرسال…',
backToLogin: 'العودة إلى تسجيل الدخول',
@@ -89,7 +92,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
alt="FleetOS"
width={104}
height={104}
priority
@@ -124,7 +127,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
placeholder={dict.emailPlaceholder}
className="input-field"
/>
</div>
+1 -1
View File
@@ -12,7 +12,7 @@ const jetbrainsMono = JetBrains_Mono({
})
export const metadata: Metadata = {
title: 'RentalDriveGo Dashboard',
title: 'FleetOS Dashboard',
description: 'Manage your rental car business',
icons: {
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">
<div className="w-full max-w-lg">
<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>
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
</div>
@@ -251,7 +251,7 @@ export default function OnboardingPage() {
checked={payments.isListedOnMarketplace}
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>
<div className="flex gap-3">
<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 [confirm, setConfirm] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const [loading, setLoading] = useState(false)
const [done, setDone] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -144,27 +146,65 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
)}
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.newPassword}</label>
<div className="relative">
<input
type="password"
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
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>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.confirmPassword}</label>
<div className="relative">
<input
type="password"
type={showConfirm ? 'text' : 'password'}
required
minLength={8}
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
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>
<button
type="submit"
@@ -200,7 +240,7 @@ function ResetShell({
<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="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>
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
</div>
@@ -27,10 +27,11 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
const initializedFromQuery = useRef(false)
const dict = {
en: {
brandName: 'Space Agency',
brandName: 'FleetOS',
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
@@ -38,18 +39,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
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.',
},
fr: {
brandName: 'Espace agence',
brandName: 'FleetOS',
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
emailPlaceholder: 'owner@company.com',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
@@ -57,18 +61,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
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é ? »`,
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.',
},
ar: {
brandName: 'مساحة الوكالة',
brandName: 'FleetOS',
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
emailPlaceholder: 'owner@company.com',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
@@ -76,11 +83,13 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
@@ -149,7 +158,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
alt="FleetOS"
width={104}
height={104}
priority
@@ -158,7 +167,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
</a>
</div>
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
RentalDriveGo
FleetOS
</p>
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.brandName}
@@ -210,6 +219,9 @@ function LocalSignInForm({
invalidCredentials: string
passwordNotSet: string
tooManyRequests: string
emailPlaceholder: string
totpPlaceholder: string
emailNotVerified: string
unexpectedError: string
}
}) {
@@ -291,6 +303,11 @@ function LocalSignInForm({
return true
}
if (empJson?.error === 'email_not_verified') {
setError(dict.emailNotVerified)
return true
}
if (empRes.status === 429) {
setError(dict.tooManyRequests)
return true
@@ -364,7 +381,7 @@ function LocalSignInForm({
required
value={email}
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"
/>
</div>
@@ -430,7 +447,7 @@ function LocalSignInForm({
required
value={totpCode}
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"
/>
</div>
@@ -6,23 +6,27 @@ import { apiFetch } from '@/lib/api'
import PublicShell from '@/components/layout/PublicShell'
import { useDashboardI18n } from '@/components/I18nProvider'
import { marketplaceUrl } from '@/lib/urls'
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
export default function SignUpForm() {
const { language, setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false)
const dict = {
en: {
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',
emailPlaceholder: 'you@company.com',
password: 'Password',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 characters',
languageLabel: 'Preferred language',
create: 'Create workspace',
creating: 'Creating\u2026',
@@ -30,12 +34,18 @@ export default function SignUpForm() {
signIn: 'Sign in',
errorEmailTaken: 'An account with this email already exists.',
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: {
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',
emailPlaceholder: 'vous@entreprise.com',
password: 'Mot de passe',
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
passwordHint: 'Minimum 8 caract\u00e8res',
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
create: 'Cr\u00e9er mon espace',
creating: 'Cr\u00e9ation\u2026',
@@ -43,12 +53,18 @@ export default function SignUpForm() {
signIn: 'Se connecter',
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
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: {
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',
emailPlaceholder: 'you@company.com',
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',
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',
@@ -56,6 +72,9 @@ export default function SignUpForm() {
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
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]
@@ -65,17 +84,14 @@ export default function SignUpForm() {
setError(null)
try {
const res = await apiFetch<{ token: string }>('/auth/account/start', {
await apiFetch('/auth/account/start', {
method: 'POST',
body: JSON.stringify({ email, password, preferredLanguage }),
})
setLanguage(preferredLanguage)
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
if (typeof window !== 'undefined' && res.token) {
window.location.href = toPublicDashboardPath('/')
}
setSuccess(true)
} catch (err: any) {
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
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 (
<PublicShell>
<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">
<Image
src="/dashboard/rentalcardrive.png"
alt="RentalDriveGo"
alt="FleetOS"
width={80}
height={80}
priority
@@ -129,7 +180,7 @@ export default function SignUpForm() {
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@company.com"
placeholder={dict.emailPlaceholder}
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"
/>
@@ -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">
{dict.password} <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type="password"
type={showPassword ? 'text' : 'password'}
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
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>
@@ -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>
<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>
</div>
</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]">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
alt="FleetOS"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
<span>FleetOS</span>
</a>
<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>
<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>
</a>
+1 -1
View File
@@ -5,7 +5,7 @@ import { getMarketplaceLanguage } from '@/lib/i18n.server'
import './globals.css'
export const metadata: Metadata = {
title: 'RentalDriveGo Marketplace',
title: 'FleetOS Marketplace',
description: 'Discover vehicles from trusted rental companies.',
icons: {
icon: '/rentalcardrive.png',
@@ -101,7 +101,7 @@ export default function MarketplaceFooter({
</div>
<p className="text-sm text-stone-500 dark:text-stone-400">
&copy; {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
&copy; {new Date().getFullYear()} FleetOS. {rightsLabel}
</p>
</div>
</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]">
<Image
src="/rentalcardrive.png"
alt="RentalDriveGo"
alt="FleetOS"
width={36}
height={36}
priority
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
/>
<span>RentalDriveGo</span>
<span>FleetOS</span>
</Link>
</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?
passwordResetToken String? @unique
passwordResetExpiresAt DateTime?
emailVerified DateTime?
emailVerificationToken String? @unique
role EmployeeRole @default(AGENT)
preferredLanguage String @default("en")
isActive Boolean @default(true)
+137 -137
View File
@@ -97,298 +97,298 @@ export const defaultMarketplaceHomepageSections: MarketplaceHomepageSectionType[
export const defaultMarketplaceHomepageContent: MarketplaceHomepageConfig = {
en: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Marketplace discovery with a sharper front door.',
heroKicker: 'FleetOS',
heroTitle: 'The operating system for modern fleet management.',
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',
exploreVehicles: 'Explore vehicles',
surfaceLabel: 'Marketplace surface',
surfaceTitle: 'Designed for two audiences at once.',
surfaceLabel: 'Unified dashboard',
surfaceTitle: 'One platform. Every operation.',
surfaceBody:
'Operators need control, renters need confidence. The marketplace should show both without feeling like a template.',
liveLabel: 'Live network',
trustedFleets: 'Trusted fleets',
brandedFlows: 'Branded booking flows',
multiTenant: 'Multi-tenant operations',
companyKicker: 'Operator workflow',
companyTitle: 'Manage inventory, offers, billing, and staff from one control layer.',
'Fleet operators need power, renters need simplicity. FleetOS delivers both without compromise — no templates, no generic workflows.',
liveLabel: 'Live platform',
trustedFleets: 'Real-time fleet control',
brandedFlows: 'White-label booking engine',
multiTenant: 'Multi-company architecture',
companyKicker: 'Operator command center',
companyTitle: 'Manage inventory, pricing, contracts, and team from a single pane of glass.',
companyBody:
'Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.',
renterKicker: 'Renter experience',
renterTitle: 'Let renters compare quickly, then hand them off to the right company site.',
"Publish once, control visibility per channel, and keep every company's data, payments, and reporting fully isolated.",
renterKicker: 'Customer experience',
renterTitle: 'Give renters a fast, transparent booking journey that ends on your branded checkout.',
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: [
['Unified publishing', 'Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages.'],
['Qualified discovery', 'Featured offers, location-aware browsing, and company reputation signals help renters narrow options fast.'],
['Direct revenue path', 'Bookings move into the companys own payment flow, so customer ownership and cash collection stay with the business.'],
['Unified command', 'Vehicle inventory, dynamic pricing, and promotional offers flow from one admin hub into marketplace discovery and your own branded storefront.'],
['Smart discovery', 'Location-aware search, reputation scoring, and AI-curated listings help renters find the right vehicle in seconds.'],
['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! })),
metrics: [
['01', 'Shared marketplace visibility'],
['02', 'Direct company checkout'],
['03', 'Company-owned payments'],
['01', 'Multi-tenant fleet operations'],
['02', 'White-label booking experience'],
['03', 'Direct-to-owner payments'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'What the product covers',
featureLabel: 'What FleetOS covers',
features: [
'Fleet management with multi-photo uploads',
'Marketplace offers and redirect booking flow',
'Branded public booking site per company',
'Customer CRM, analytics, and billing controls',
'Fleet inventory with multi-photo uploads and condition tracking',
'Dynamic pricing engine with seasonal and promotional rules',
'White-label booking site per fleet operator',
'Customer CRM, real-time analytics, and automated billing',
],
howitworksKicker: 'GETTING STARTED',
howitworksTitle: 'How It Works',
howitworksSteps: [
{
number: '1',
title: 'Create Account',
description: 'Sign up for your company workspace and choose your pricing plan for the 90-day free trial.',
title: 'Create Your Workspace',
description: 'Sign up and launch your fleet workspace with a 90-day free trial — no credit card required.',
},
{
number: '2',
title: 'Add Your Fleet',
description: 'Upload vehicle photos, set prices, and create offers visible on the marketplace.',
title: 'Onboard Your Fleet',
description: 'Upload vehicles, configure pricing, and publish offers that appear on the marketplace instantly.',
},
{
number: '3',
title: 'Start Selling',
description: 'Renters discover your fleet, and bookings flow directly to your branded checkout.',
title: 'Go Live',
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: [
['1', 'Create your company workspace', 'Pick a plan, launch your 90-day trial, and verify the owner account.'],
['2', 'Publish vehicles and offers', 'Upload photos once and control what appears on the marketplace and branded site.'],
['3', 'Accept bookings on your own site', 'Renters discover your fleet on RentalDriveGo, then pay you directly on your branded booking flow.'],
['1', 'Create your fleet workspace', 'Choose a plan, start your 90-day trial, and verify your operator account.'],
['2', 'Publish vehicles and offers', 'Upload vehicle data once and control exactly what appears on every channel.'],
['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! })),
stepLabel: 'Step',
testimonialsKicker: 'CUSTOMER STORIES',
testimonialsTitle: 'Trusted by rental companies worldwide',
testimonialsTitle: 'Trusted by fleet operators worldwide',
testimonials: [
{
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',
role: 'Fleet Manager',
role: 'Fleet Director',
company: 'Premium Auto Rentals',
},
{
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',
role: 'Operations Director',
company: 'Desert Wheels',
},
{
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',
role: 'Owner',
role: 'CEO',
company: 'Provence Car Hire',
},
],
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:
'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',
createWorkspace: 'Create workspace',
},
fr: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'Une vitrine marketplace plus claire et plus percutante.',
heroKicker: 'FleetOS',
heroTitle: "Le système d'exploitation pour la gestion de flotte moderne.",
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.",
startTrial: 'Commencer lessai gratuit',
"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 l'essai gratuit",
exploreVehicles: 'Explorer les véhicules',
surfaceLabel: 'Vitrine marketplace',
surfaceTitle: 'Pensée pour deux audiences à la fois.',
surfaceLabel: 'Tableau de bord unifié',
surfaceTitle: 'Une plateforme. Toutes les opérations.',
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.',
liveLabel: 'Réseau actif',
trustedFleets: 'Flottes fiables',
brandedFlows: 'Parcours de marque',
multiTenant: 'Opérations multi-tenant',
companyKicker: 'Flux opérateur',
companyTitle: 'Pilotez linventaire, les offres, la facturation et léquipe depuis un seul centre de contrôle.',
'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: 'Plateforme active',
trustedFleets: 'Contrôle de flotte en temps réel',
brandedFlows: 'Moteur de réservation en marque blanche',
multiTenant: 'Architecture multi-entreprise',
companyKicker: 'Centre de commande opérateur',
companyTitle: "Gérez l'inventaire, les tarifs, les contrats et l'équipe depuis une interface unique.",
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',
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:
'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: [
['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.'],
['Découverte qualifiée', 'Offres mises en avant, navigation par localisation et signaux de réputation aident les clients à filtrer rapidement.'],
['Revenus en direct', 'Les réservations basculent vers le paiement propre à lentreprise afin de préserver la relation client et lencaissement.'],
["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 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 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! })),
metrics: [
['01', 'Visibilité marketplace partagée'],
['02', 'Paiement direct entreprise'],
['03', 'Paiements gérés par lentreprise'],
['01', 'Opérations de flotte multi-tenant'],
['02', 'Expérience de réservation en marque blanche'],
['03', 'Paiements directs au propriétaire'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'Ce que couvre le produit',
featureLabel: 'Ce que FleetOS couvre',
features: [
'Gestion de flotte avec téléversement de plusieurs photos',
'Offres marketplace et redirection vers la réservation',
'Site public de réservation par entreprise',
'CRM client, analytics et contrôle de facturation',
"Inventaire de flotte avec photos multiples et suivi d'état",
'Moteur de tarification dynamique avec règles saisonnières et promotionnelles',
'Site de réservation en marque blanche par opérateur',
'CRM client, analytics en temps réel et facturation automatisée',
],
howitworksKicker: 'MISE EN ROUTE',
howitworksTitle: 'Comment ça marche',
howitworksSteps: [
{
number: '1',
title: 'Créer un compte',
description: 'Inscrivez-vous à votre espace entreprise et choisissez votre forfait pour l\'essai gratuit de 90 jours.',
title: 'Créez votre espace',
description: 'Inscrivez-vous et lancez votre espace de flotte avec un essai gratuit de 90 jours — sans carte bancaire.',
},
{
number: '2',
title: 'Ajouter votre flotte',
description: 'Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace.',
title: 'Intégrez votre flotte',
description: 'Ajoutez vos véhicules, configurez les tarifs et publiez des offres visibles instantanément sur la marketplace.',
},
{
number: '3',
title: 'Commencer à vendre',
description: 'Les clients découvrent votre flotte et les réservations arrivent directement à votre paiement de marque.',
title: 'Passez en direct',
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: [
['1', 'Créez votre espace entreprise', 'Choisissez un plan, lancez votre essai de 90 jours et vérifiez le compte propriétaire.'],
['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.'],
['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.'],
['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', '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 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! })),
stepLabel: 'Étape',
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: [
{
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',
role: 'Responsable de Flotte',
role: 'Directrice de Flotte',
company: 'Location Premium',
},
{
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',
role: 'Directeur des Opérations',
company: 'Sahara Cars',
},
{
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',
role: 'Propriétaire',
role: 'PDG',
company: 'Provence Auto',
},
],
readyKicker: 'Prêt à démarrer',
readyTitle: 'La page daccueil marketplace doit présenter le produit en quelques secondes.',
readyKicker: 'Prêt à vous lancer',
readyTitle: "Votre flotte mérite un système d'exploitation, pas juste un site d'annonces.",
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',
createWorkspace: 'Créer lespace',
createWorkspace: "Créer l'espace",
},
ar: {
sections: defaultMarketplaceHomepageSections,
heroKicker: 'RentalDriveGo',
heroTitle: 'واجهة سوق أكثر وضوحاً وتأثيراً.',
heroKicker: 'FleetOS',
heroTitle: 'نظام التشغيل لإدارة الأساطيل الحديثة.',
heroBody:
'شركات التأجير تدير عملياتها بشكل خاص، والمستأجرون يتصفحون سوقاً مشتركاً، وكل حجز ينتهي في صفحة دفع تحمل هوية الشركة ذاتها.',
'قم بإدارة كامل نشاط التأجير الخاص بك من منصة واحدة — المخزون، الحجوزات، المدفوعات وتجربة العملاء، كل ذلك تحت علامتك التجارية الخاصة.',
startTrial: 'ابدأ التجربة المجانية',
exploreVehicles: 'استكشف السيارات',
surfaceLabel: 'واجهة السوق',
surfaceTitle: صممة لجمهورين في الوقت نفسه.',
exploreVehicles: 'استكشف المركبات',
surfaceLabel: 'لوحة تحكم موحدة',
surfaceTitle: نصة واحدة. كل العمليات.',
surfaceBody:
'المشغلون يريدون التحكم، والمستأجرون يريدون الثقة. يجب أن تُظهر المنصة الأمرين معاً من دون أن تبدو كقالب عادي.',
liveLabel: 'شبكة نشطة',
trustedFleets: 'أساطيل موثوقة',
brandedFlows: سارات حجز مخصصة',
multiTenant: 'عمليات متعددة الشركات',
companyKicker: 'سير عمل المشغل',
companyTitle: 'تحكم في المخزون والعروض والفوترة والفريق من طبقة تشغيل واحدة.',
'مشغلو الأساطيل يحتاجون إلى القوة، والمستأجرون يحتاجون إلى البساطة. FleetOS يقدم كليهما بدون تنازلات — لا قوالب جاهزة ولا سير عمل عام.',
liveLabel: 'منصة نشطة',
trustedFleets: 'تحكم فوري بالأسطول',
brandedFlows: حرك حجز بالعلامة البيضاء',
multiTenant: 'بنية متعددة الشركات',
companyKicker: 'مركز قيادة المشغل',
companyTitle: 'أدر المخزون والأسعار والعقود والفريق من لوحة تحكم واحدة.',
companyBody:
'انشر المخزون مرة واحدة، وحدد ما يظهر للعامة، واحتفظ بالعقود والمدفوعات والتقارير معزولة لكل شركة.',
'انشر مرة واحدة، وتحكم في الظهور حسب القناة، واحتفظ ببيانات كل شركة ومدفوعاتها وتقاريرها معزولة تماماً.',
renterKicker: 'تجربة المستأجر',
renterTitle: 'دع المستأجرين يقارنون بسرعة ثم وجّههم إلى موقع الشركة المناسب.',
renterTitle: 'امنح المستأجرين رحلة حجز سريعة وشفافة تنتهي على صفحة الدفع الخاصة بك.',
renterBody:
'هذه المنصة محرك لاكتشاف الخيارات، وليست مُجمِّعاً بلا مسار واضح. ابحث هنا، احجز هناك، وادفع مباشرة للشركة.',
'FleetOS يشغّل الاكتشاف والتحويل — وليس حديقة مغلقة. تصفح هنا، احجز هناك، وادفع مباشرة لمالك الأسطول.',
pillars: [
['نشر موحد', 'صور السيارات والأسعار والعروض تنتقل من سير إدارة واحد إلى السوق وصفحات الحجز ذات الهوية الخاصة.'],
['اكتشاف مؤهل', 'تساعد العروض المميزة، والتصفح حسب الموقع، وإشارات السمعة المستأجرين على تحديد خياراتهم بسرعة.'],
['مسار إيراد مباشر', 'تنتقل الحجوزات إلى صفحة الدفع الخاصة بالشركة حتى تبقى علاقة العميل والتحصيل المالي بيد الشركة نفسها.'],
['قيادة موحدة', 'المخزون، التسعير الديناميكي والعروض الترويجية تتدفق من مركز إدارة واحد إلى marketplace السوق وواجهة متجرك الخاصة.'],
['اكتشاف ذكي', 'بحث حسب الموقع، تقييم السمعة وقوائم منسقة بالذكاء الاصطناعي تساعد المستأجرين في العثور على المركبة المناسبة بثوانٍ.'],
['إيرادات مباشرة', 'كل حجز يمر عبر بوابة الدفع الخاصة بك — أنت تملك علاقة العميل والتدفق النقدي.'],
].map(([title, body]) => ({ title: title!, body: body! })),
metrics: [
['01', 'ظهور مشترك في السوق'],
['02', 'دفع مباشر للشركة'],
['03', 'مدفوعات مملوكة للشركة'],
['01', 'عمليات أسطول متعددة الشركات'],
['02', 'تجربة حجز بالعلامة البيضاء'],
['03', 'مدفوعات مباشرة للمالك'],
].map(([value, label]) => ({ value: value!, label: label! })),
featureLabel: 'ما الذي يقدمه المنتج',
featureLabel: 'ما الذي يغطيه FleetOS',
features: [
'إدارة الأسطول مع رفع عدة صور',
'عروض السوق والتحويل إلى مسار الحجز',
'موقع حجز عام مخصص لكل شركة',
'إدارة العملاء والتحليلات والفوترة',
'مخزون الأسطول مع رفع صور متعددة وتتبع الحالة',
'محرك تسعير ديناميكي مع قواعد موسمية وترويجية',
'موقع حجز بالعلامة البيضاء لكل مشغل أسطول',
'CRM عملاء، تحليلات فورية وفوترة آلية',
],
howitworksKicker: 'البدء',
howitworksTitle: 'كيف يعمل',
howitworksSteps: [
{
number: '1',
title: 'إنشاء حساب',
description: 'قم بالتسجيل للحصول على مساحة عمل شركتك واختر خطتك للتجربة المجانية لمدة 90 يوماً.',
title: 'أنشئ مساحتك',
description: 'سجّل وأطلق مساحة أسطولك مع تجربة مجانية لمدة 90 يوماً — بدون بطاقة ائتمان.',
},
{
number: '2',
title: 'أضف أسطولك',
description: 'قم برفع صور السيارات، وحدد الأسعار، وأنشئ عروضاً مرئية في السوق.',
description: 'أضف المركبات، واضبط الأسعار، وانشر العروض التي تظهر فوراً في marketplace السوق.',
},
{
number: '3',
title: 'ابدأ البيع',
description: 'يكتشف المستأجرون أسطولك وتأتي الحجوزات مباشرة إلى دفعتك المخصصة.',
title: 'انطلق مباشرة',
description: 'يكتشف العملاء أسطولك، ويحجزون عبر موقعك التجاري، وتصل المدفوعات مباشرة إلى حسابك.',
},
],
stepsTitle: 'كيف تبدأ الشركات',
stepsTitle: 'كيف تنطلق الأساطيل على FleetOS',
steps: [
['1', 'أنشئ مساحة عمل شركتك', 'اختر الخطة وابدأ تجربتك لمدة 14 يوماً ثم فعّل حساب المالك.'],
['2', 'انشر السيارات والعروض', 'ارفع الصور مرة واحدة وتحكم فيما يظهر في السوق وفي الموقع المخصص.'],
['3', 'استقبل الحجوزات على موقعك', 'يكتشف المستأجرون أسطولك على RentalDriveGo ثم يدفعون مباشرة عبر مسار الحجز الخاص بك.'],
['1', 'أنشئ مساحة عمل أسطولك', 'اختر الخطة وابدأ تجربتك لمدة 90 يوماً وتحقق من حساب المشغل الخاص بك.'],
['2', 'انشر المركبات والعروض', 'أضف بيانات المركبات مرة واحدة وتحكم بدقة فيما يظهر على كل قناة.'],
['3', 'استقبل الحجوزات بشروطك', 'يكتشف العملاء أسطولك على FleetOS، ثم يكملون الحجز على صفحة الدفع الخاصة بك.'],
].map(([step, title, body]) => ({ step: step!, title: title!, body: body! })),
stepLabel: 'الخطوة',
testimonialsKicker: 'قصص العملاء',
testimonialsTitle: 'موثوقة من قبل شركات التأجير في جميع أنحاء العالم',
testimonialsTitle: 'موثوق من قبل مشغلي الأساطيل حول العالم',
testimonials: [
{
quote:
'غيّرت RentalDriveGo طريقة إدارة أسطولنا والوصول إلى العملاء. الظهور في السوق قد ضاعف حجوزاتنا.',
'غيّر FleetOS طريقة إدارة أسطولنا المكون من أكثر من 200 مركبة. لوحة التحكم الموحدة قلصت وقت الإدارة إلى النصف وتضاعفت حجوزاتنا في ثلاثة أشهر.',
author: 'فاطمة الزهراء',
role: 'مدير الأسطول',
role: 'مديرة الأسطول',
company: 'تأجير السيارات الممتازة',
},
{
quote:
'تمنحنا المنصة ثنائية الجانب التحكم في عملياتنا بينما يحصل المستأجرون على تجربة سلسة. التوازن المثالي.',
'محرك الحجز بالعلامة البيضاء يغير قواعد اللعبة. عملاؤنا لا يغادرون تجربة علامتنا التجارية أبداً، مع الاستفادة من ظهور marketplace السوق.',
author: 'محمد علي',
role: 'مدير العمليات',
company: 'صحراء ويلز',
},
{
quote:
'أقدرنا على سهولة إعداد مسار الحجز المخصص بنا. يحب عملاؤنا تجربة الدفع المباشر.',
'قيمنا ست منصات قبل FleetOS. البنية متعددة الشركات ونموذج الدفع المباشر جعلاه الخيار الواضح لنمونا المتسارع.',
author: 'ليلى حسن',
role: 'المالك',
company: 'تأجير السيارات بالبحر الأبيض',
role: 'المديرة التنفيذية',
company: 'تأجير سيارات البحر الأبيض',
},
],
readyKicker: 'جاهز للانطلاق',
readyTitle: 'يجب أن تشرح الصفحة الرئيسية قيمة المنصة خلال ثوانٍ.',
readyTitle: 'أسطولك يستحق نظام تشغيل، وليس مجرد موقع إعلانات.',
readyBody:
'استخدم السوق لجذب الطلب، ثم انقل المستأجرين إلى تجربة تحمل هوية شركتك، بحيث تبقى الأسعار والمدفوعات والثقة مرتبطة بنشاطك.',
'يمنحك FleetOS الأدوات اللازمة لإدارة كامل نشاط التأجير — من المخزون إلى الفوترة — مع إبقاء علامتك التجارية في المقدمة عند كل نقطة اتصال.',
viewPricing: 'عرض الأسعار',
createWorkspace: 'إنشاء المساحة',
},