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
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:
@@ -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)}`
|
||||
|
||||
const emailSubjects: Record<string, string> = {
|
||||
en: 'Verify your email — FleetOS',
|
||||
fr: 'Vérifiez votre email — FleetOS',
|
||||
ar: 'تحقق من بريدك الإلكتروني — FleetOS',
|
||||
}
|
||||
const emailBodies: Record<string, string> = {
|
||||
en: `<p>Welcome to FleetOS!</p><p>Click the link below to verify your email and activate your account:</p><p><a href="${verifyUrl}">Verify Email</a></p><p>If you did not create this account, you can safely ignore this email.</p>`,
|
||||
fr: `<p>Bienvenue sur FleetOS !</p><p>Cliquez sur le lien ci-dessous pour vérifier votre email et activer votre compte :</p><p><a href="${verifyUrl}">Vérifier l'email</a></p><p>Si vous n'avez pas créé ce compte, ignorez cet email.</p>`,
|
||||
ar: `<p>مرحباً بك في FleetOS!</p><p>انقر على الرابط أدناه للتحقق من بريدك الإلكتروني وتفعيل حسابك:</p><p><a href="${verifyUrl}">تحقق من البريد الإلكتروني</a></p><p>إذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.</p>`,
|
||||
}
|
||||
const emailTexts: Record<string, string> = {
|
||||
en: `Welcome to FleetOS!\n\nVerify your email by visiting:\n${verifyUrl}\n\nIf you did not create this account, you can safely ignore this email.`,
|
||||
fr: `Bienvenue sur FleetOS !\n\nVérifiez votre email en visitant :\n${verifyUrl}\n\nSi vous n'avez pas créé ce compte, ignorez cet email.`,
|
||||
ar: `مرحباً بك في FleetOS!\n\nتحقق من بريدك الإلكتروني بزيارة:\n${verifyUrl}\n\nإذا لم تقم بإنشاء هذا الحساب، يمكنك تجاهل هذا البريد الإلكتروني.`,
|
||||
}
|
||||
|
||||
const lang = (body.preferredLanguage === 'ar' || body.preferredLanguage === 'fr') ? body.preferredLanguage : 'en'
|
||||
|
||||
await sendTransactionalEmail({
|
||||
to: body.email,
|
||||
subject: emailSubjects[lang],
|
||||
html: emailBodies[lang],
|
||||
text: emailTexts[lang],
|
||||
}).catch((err) => {
|
||||
console.error('[AccountStart] Verification email delivery failed:', err?.message)
|
||||
})
|
||||
|
||||
return { token, ...presentEmployeeSession(result as any, token) }
|
||||
return {
|
||||
message: 'Account created. Please check your email to verify your address before signing in.',
|
||||
email: body.email,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,3 +70,27 @@ export function resetPassword(id: string, passwordHash: string) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function setEmailVerificationToken(id: string, token: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: { emailVerificationToken: token },
|
||||
})
|
||||
}
|
||||
|
||||
export function findEmployeeByVerificationToken(token: string) {
|
||||
return prisma.employee.findFirst({
|
||||
where: { emailVerificationToken: token },
|
||||
include: { company: true },
|
||||
})
|
||||
}
|
||||
|
||||
export function verifyEmployeeEmail(id: string) {
|
||||
return prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
emailVerificationToken: null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,4 +61,18 @@ router.post('/reset-password', async (req, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
© {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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} RentalDriveGo. {rightsLabel}
|
||||
© {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>
|
||||
|
||||
|
||||
+6
@@ -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");
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 'إنشاء المساحة',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user