From c5f614b3e4c86d8c4efadc5de7585fba34f96d25 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 25 Jun 2026 20:25:16 -0400 Subject: [PATCH] feat: add employee email verification on signup - 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 --- .../src/modules/auth/auth.account.routes.ts | 8 +- .../src/modules/auth/auth.account.service.ts | 60 +++- .../src/modules/auth/auth.employee.repo.ts | 24 ++ .../src/modules/auth/auth.employee.routes.ts | 14 + .../src/modules/auth/auth.employee.service.ts | 54 ++++ .../src/app/(dashboard)/settings/page.tsx | 2 +- .../ForgotPasswordPageClient.tsx | 7 +- apps/dashboard/src/app/layout.tsx | 2 +- apps/dashboard/src/app/onboarding/page.tsx | 4 +- .../ResetPasswordPageClient.tsx | 50 +++- .../[[...sign-in]]/SignInPageClient.tsx | 31 +- .../app/sign-up/[[...sign-up]]/SignUpForm.tsx | 100 ++++++- apps/dashboard/src/app/verify-email/page.tsx | 130 +++++++++ .../src/components/layout/PublicFooter.tsx | 2 +- .../src/components/layout/PublicHeader.tsx | 4 +- .../src/components/layout/Sidebar.tsx | 2 +- apps/marketplace/src/app/layout.tsx | 2 +- .../src/components/MarketplaceFooter.tsx | 2 +- .../src/components/MarketplaceHeader.tsx | 4 +- .../migration.sql | 6 + packages/database/prisma/schema.prisma | 2 + packages/types/src/marketplace-homepage.ts | 274 +++++++++--------- 22 files changed, 594 insertions(+), 190 deletions(-) create mode 100644 apps/dashboard/src/app/verify-email/page.tsx create mode 100644 packages/database/prisma/migrations/20260625015104_add_employee_email_verification/migration.sql diff --git a/apps/api/src/modules/auth/auth.account.routes.ts b/apps/api/src/modules/auth/auth.account.routes.ts index f2ca443..ef389fb 100644 --- a/apps/api/src/modules/auth/auth.account.routes.ts +++ b/apps/api/src/modules/auth/auth.account.routes.ts @@ -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) } }) diff --git a/apps/api/src/modules/auth/auth.account.service.ts b/apps/api/src/modules/auth/auth.account.service.ts index bc846f2..9e554ae 100644 --- a/apps/api/src/modules/auth/auth.account.service.ts +++ b/apps/api/src/modules/auth/auth.account.service.ts @@ -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 +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)}` + + const emailSubjects: Record = { + en: 'Verify your email — FleetOS', + fr: 'Vérifiez votre email — FleetOS', + ar: 'تحقق من بريدك الإلكتروني — FleetOS', + } + const emailBodies: Record = { + en: `

Welcome to FleetOS!

Click the link below to verify your email and activate your account:

Verify Email

If you did not create this account, you can safely ignore this email.

`, + fr: `

Bienvenue sur FleetOS !

Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :

Vérifier l'email

Si vous n'avez pas créé ce compte, ignorez cet email.

`, + ar: `

مرحباً بك في FleetOS!

انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:

تحقق من البريد الإلكتروني

إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.

`, + } + const emailTexts: Record = { + 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, + } } diff --git a/apps/api/src/modules/auth/auth.employee.repo.ts b/apps/api/src/modules/auth/auth.employee.repo.ts index c456286..25549c5 100644 --- a/apps/api/src/modules/auth/auth.employee.repo.ts +++ b/apps/api/src/modules/auth/auth.employee.repo.ts @@ -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, + }, + }) +} diff --git a/apps/api/src/modules/auth/auth.employee.routes.ts b/apps/api/src/modules/auth/auth.employee.routes.ts index 662e574..a5e0ca5 100644 --- a/apps/api/src/modules/auth/auth.employee.routes.ts +++ b/apps/api/src/modules/auth/auth.employee.routes.ts @@ -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 diff --git a/apps/api/src/modules/auth/auth.employee.service.ts b/apps/api/src/modules/auth/auth.employee.service.ts index 9ed46a4..140e680 100644 --- a/apps/api/src/modules/auth/auth.employee.service.ts +++ b/apps/api/src/modules/auth/auth.employee.service.ts @@ -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 = { + en: 'Verify your email — FleetOS', + fr: 'Vérifiez votre email — FleetOS', + ar: 'تحقق من بريدك الإلكتروني — FleetOS', + } + + await sendTransactionalEmail({ + to: employee.email, + subject: emailSubjects[lang], + html: `

Click the link below to verify your email and activate your account:

Verify Email

`, + 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.' } +} diff --git a/apps/dashboard/src/app/(dashboard)/settings/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/page.tsx index 8b7f95e..f2366bd 100644 --- a/apps/dashboard/src/app/(dashboard)/settings/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/settings/page.tsx @@ -657,7 +657,7 @@ export default function SettingsPage() {
-
{brand.subdomain}.RentalDriveGo.com
+
{brand.subdomain}.FleetOS.com
diff --git a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx index 0385075..eac473d 100644 --- a/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx +++ b/apps/dashboard/src/app/forgot-password/ForgotPasswordPageClient.tsx @@ -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 RentalDriveGo setEmail(e.target.value)} - placeholder="owner@company.com" + placeholder={dict.emailPlaceholder} className="input-field" />
diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 119237e..a7c0cb2 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -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', diff --git a/apps/dashboard/src/app/onboarding/page.tsx b/apps/dashboard/src/app/onboarding/page.tsx index 3fa0cbb..aae89b4 100644 --- a/apps/dashboard/src/app/onboarding/page.tsx +++ b/apps/dashboard/src/app/onboarding/page.tsx @@ -96,7 +96,7 @@ export default function OnboardingPage() {
- RentalDriveGo + FleetOS

Set up your account

Step {step} of 3

@@ -251,7 +251,7 @@ export default function OnboardingPage() { checked={payments.isListedOnMarketplace} onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })} /> - List my company on the RentalDriveGo marketplace + List my company on the FleetOS marketplace
diff --git a/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx index c72fdd0..f41bf18 100644 --- a/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx +++ b/apps/dashboard/src/app/reset-password/ResetPasswordPageClient.tsx @@ -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(null) @@ -144,27 +146,65 @@ function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) { )}
+
setPassword(e.target.value)} placeholder="••••••••" - className="input-field" + className="input-field pr-10" /> + +
+
setConfirm(e.target.value)} placeholder="••••••••" - className="input-field" + className="input-field pr-10" /> + +
@@ -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" />
diff --git a/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx b/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx index dd12aed..f23e9a1 100644 --- a/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx +++ b/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx @@ -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(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 ( + +
+ +
+ + ) + } + return (
@@ -96,7 +147,7 @@ export default function SignUpForm() { RentalDriveGo 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() { +
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" /> -

Minimum 8 characters

+ +
+

{dict.passwordHint}

diff --git a/apps/dashboard/src/app/verify-email/page.tsx b/apps/dashboard/src/app/verify-email/page.tsx new file mode 100644 index 0000000..61b6caf --- /dev/null +++ b/apps/dashboard/src/app/verify-email/page.tsx @@ -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 ( + +
+
+ + ) +} diff --git a/apps/dashboard/src/components/layout/PublicFooter.tsx b/apps/dashboard/src/components/layout/PublicFooter.tsx index 203edab..626c5b8 100644 --- a/apps/dashboard/src/components/layout/PublicFooter.tsx +++ b/apps/dashboard/src/components/layout/PublicFooter.tsx @@ -165,7 +165,7 @@ export default function PublicFooter() {

- © {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel} + © {new Date().getFullYear()} FleetOS. {footerContent.rightsLabel}

diff --git a/apps/dashboard/src/components/layout/PublicHeader.tsx b/apps/dashboard/src/components/layout/PublicHeader.tsx index f95c80b..a9f0bcc 100644 --- a/apps/dashboard/src/components/layout/PublicHeader.tsx +++ b/apps/dashboard/src/components/layout/PublicHeader.tsx @@ -109,13 +109,13 @@ export default function PublicHeader() { RentalDriveGo - RentalDriveGo + FleetOS
diff --git a/apps/dashboard/src/components/layout/Sidebar.tsx b/apps/dashboard/src/components/layout/Sidebar.tsx index 57f3abd..d5be3cf 100644 --- a/apps/dashboard/src/components/layout/Sidebar.tsx +++ b/apps/dashboard/src/components/layout/Sidebar.tsx @@ -401,7 +401,7 @@ export default function Sidebar() { )}
- {brand?.displayName ?? 'RentalDriveGo'} + {brand?.displayName ?? 'FleetOS'} diff --git a/apps/marketplace/src/app/layout.tsx b/apps/marketplace/src/app/layout.tsx index b138745..56f0218 100644 --- a/apps/marketplace/src/app/layout.tsx +++ b/apps/marketplace/src/app/layout.tsx @@ -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', diff --git a/apps/marketplace/src/components/MarketplaceFooter.tsx b/apps/marketplace/src/components/MarketplaceFooter.tsx index b6e1090..e52bcf0 100644 --- a/apps/marketplace/src/components/MarketplaceFooter.tsx +++ b/apps/marketplace/src/components/MarketplaceFooter.tsx @@ -101,7 +101,7 @@ export default function MarketplaceFooter({

- © {new Date().getFullYear()} RentalDriveGo. {rightsLabel} + © {new Date().getFullYear()} FleetOS. {rightsLabel}

diff --git a/apps/marketplace/src/components/MarketplaceHeader.tsx b/apps/marketplace/src/components/MarketplaceHeader.tsx index 907235e..74547e3 100644 --- a/apps/marketplace/src/components/MarketplaceHeader.tsx +++ b/apps/marketplace/src/components/MarketplaceHeader.tsx @@ -107,13 +107,13 @@ export default function MarketplaceHeader({ RentalDriveGo - RentalDriveGo + FleetOS diff --git a/packages/database/prisma/migrations/20260625015104_add_employee_email_verification/migration.sql b/packages/database/prisma/migrations/20260625015104_add_employee_email_verification/migration.sql new file mode 100644 index 0000000..60a03b8 --- /dev/null +++ b/packages/database/prisma/migrations/20260625015104_add_employee_email_verification/migration.sql @@ -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"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 7866309..3959ba5 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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) diff --git a/packages/types/src/marketplace-homepage.ts b/packages/types/src/marketplace-homepage.ts index f75730f..e7744c9 100644 --- a/packages/types/src/marketplace-homepage.ts +++ b/packages/types/src/marketplace-homepage.ts @@ -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 company’s 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 company’s 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 l’essai 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 l’inventaire, 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 d’entreprise.', + 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 d’agré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 d’administration 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 à l’entreprise afin de préserver la relation client et l’encaissement.'], + ["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 l’entreprise'], + ['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 d’accueil 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 l’espace', + 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: 'إنشاء المساحة', },