fix bug 16 and 17
Build & Push / Pipeline Tests (push) Successful in 1m53s
Test / Type Check (all packages) (push) Successful in 56s
Build & Push / Build & Push Docker Image (push) Failing after 4m53s
Test / API Unit Tests (push) Successful in 1m15s
Test / Homepage Unit Tests (push) Successful in 46s
Test / Carplace Unit Tests (push) Successful in 41s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 42s
Test / API Integration Tests (push) Successful in 1m6s

This commit is contained in:
root
2026-08-04 00:52:58 -04:00
parent 626da23c2e
commit 149806a773
72 changed files with 834 additions and 174 deletions
BIN
View File
Binary file not shown.
+3 -1
View File
@@ -14,10 +14,12 @@ import { AdminSessionProvider, type AdminSessionUser } from './AdminSessionConte
function buildUnifiedLoginUrl(nextPath: string) { function buildUnifiedLoginUrl(nextPath: string) {
const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000') const websiteUrl = resolveBrowserAppUrl(process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000')
const storedTheme = window.localStorage.getItem('rentaldrivego-theme') ?? window.localStorage.getItem('admin-theme')
const theme = storedTheme === 'light' ? 'light' : 'dark'
const params = new URLSearchParams({ const params = new URLSearchParams({
next: `/admin${nextPath || '/dashboard'}`, next: `/admin${nextPath || '/dashboard'}`,
}) })
return `${websiteUrl}/en/light/admin-sign-in?${params.toString()}` return `${websiteUrl}/en/${theme}/admin-sign-in?${params.toString()}`
} }
const navLinks = [ const navLinks = [
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ADMIN_API_BASE } from '@/lib/api' import { ADMIN_API_BASE } from '@/lib/api'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type CompanyOption = { type CompanyOption = {
@@ -82,7 +82,7 @@ type FormState = {
} }
const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT'] const ROLES: EmployeeRole[] = ['OWNER', 'MANAGER', 'AGENT']
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'] const ITEM_TYPES: MenuItemType[] = ['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']
const INPUT = const INPUT =
@@ -117,7 +117,7 @@ function emptyForm(): FormState {
isRequired: false, isRequired: false,
isActive: true, isActive: true,
roles: ['OWNER', 'MANAGER', 'AGENT'], roles: ['OWNER', 'MANAGER', 'AGENT'],
plans: ['STARTER', 'GROWTH', 'PRO'], plans: ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'],
companyIds: [], companyIds: [],
} }
} }
@@ -64,19 +64,21 @@ type PlanFeatureForm = {
// ─── Constants ───────────────────────────────────────────────────────────── // ─── Constants ─────────────────────────────────────────────────────────────
const PLANS = ['STARTER', 'GROWTH', 'PRO'] const PLANS = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PERIODS = ['MONTHLY', 'ANNUAL'] const PERIODS = ['MONTHLY', 'ANNUAL']
const PLAN_COLORS: Record<string, string> = { const PLAN_COLORS: Record<string, string> = {
STARTER: 'text-zinc-400', STARTER: 'text-zinc-400',
GROWTH: 'text-sky-400', GROWTH: 'text-sky-400',
PRO: 'text-violet-400', PRO: 'text-violet-400',
ENTERPRISE: 'text-emerald-400',
} }
const PLAN_BADGE: Record<string, string> = { const PLAN_BADGE: Record<string, string> = {
STARTER: 'bg-zinc-800 text-zinc-300', STARTER: 'bg-zinc-800 text-zinc-300',
GROWTH: 'bg-sky-900/40 text-sky-300', GROWTH: 'bg-sky-900/40 text-sky-300',
PRO: 'bg-violet-900/40 text-violet-300', PRO: 'bg-violet-900/40 text-violet-300',
ENTERPRISE: 'bg-emerald-900/40 text-emerald-300',
} }
// ─── Helpers ─────────────────────────────────────────────────────────────── // ─── Helpers ───────────────────────────────────────────────────────────────
+2 -2
View File
@@ -9,12 +9,12 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en" className="light" suppressHydrationWarning> <html lang="en" className="dark" suppressHydrationWarning>
<head> <head>
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();", "(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):(localStorage.getItem('rentaldrivego-theme')||localStorage.getItem('admin-theme'));if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.remove('light','dark');document.documentElement.classList.add(theme);document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}} }}
/> />
</head> </head>
+5 -2
View File
@@ -1,13 +1,16 @@
import { headers } from 'next/headers' import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
import { resolveServerAppUrl } from '@/lib/appUrls' import { resolveServerAppUrl } from '@/lib/appUrls'
export default async function AdminLoginPage() { export default async function AdminLoginPage() {
const requestHeaders = await headers() const requestHeaders = await headers()
const cookieStore = await cookies()
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
const theme = rawTheme === 'light' ? 'light' : 'dark'
const websiteUrl = resolveServerAppUrl( const websiteUrl = resolveServerAppUrl(
process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000', process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:3000',
requestHeaders.get('host'), requestHeaders.get('host'),
requestHeaders.get('x-forwarded-proto'), requestHeaders.get('x-forwarded-proto'),
) )
redirect(`${websiteUrl}/en/light/admin-sign-in?next=/admin/dashboard`) redirect(`${websiteUrl}/en/${theme}/admin-sign-in?next=/admin/dashboard`)
} }
+6 -6
View File
@@ -110,10 +110,11 @@ const Context = createContext<AdminI18nContext | null>(null)
export function AdminI18nProvider({ children }: { children: React.ReactNode }) { export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
const [language, setLanguage] = useState<AdminLanguage>('en') const [language, setLanguage] = useState<AdminLanguage>('en')
const [theme, setTheme] = useState<AdminTheme>('light') const [theme, setTheme] = useState<AdminTheme>('dark')
// Skip the very first write so we don't overwrite a stored preference with // Skip the very first write so we don't overwrite a stored preference with
// the default 'en' value before the hydration read-effect has applied it. // the default 'en' value before the hydration read-effect has applied it.
const skipFirstLangWrite = useRef(true) const skipFirstLangWrite = useRef(true)
const skipFirstThemeWrite = useRef(true)
useEffect(() => { useEffect(() => {
const stored = window.localStorage.getItem('admin-language') const stored = window.localStorage.getItem('admin-language')
@@ -124,11 +125,6 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
if (storedTheme === 'light' || storedTheme === 'dark') { if (storedTheme === 'light' || storedTheme === 'dark') {
setTheme(storedTheme) setTheme(storedTheme)
return
}
if (!window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('light')
} }
}, []) }, [])
@@ -147,6 +143,10 @@ export function AdminI18nProvider({ children }: { children: React.ReactNode }) {
document.documentElement.classList.add(theme) document.documentElement.classList.add(theme)
document.documentElement.style.colorScheme = theme document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme document.body.dataset.theme = theme
if (skipFirstThemeWrite.current) {
skipFirstThemeWrite.current = false
return
}
document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax` document.cookie = `${SHARED_THEME_KEY}=${encodeURIComponent(theme)}; path=/; max-age=31536000; SameSite=Lax`
window.localStorage.setItem(SHARED_THEME_KEY, theme) window.localStorage.setItem(SHARED_THEME_KEY, theme)
window.localStorage.setItem('admin-theme', theme) window.localStorage.setItem('admin-theme', theme)
+2 -2
View File
@@ -492,7 +492,7 @@ export function listPlanFeatures() {
}) })
} }
export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder?: number }) { export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder?: number }) {
return prisma.planFeature.create({ return prisma.planFeature.create({
data: { data: {
plan: data.plan, plan: data.plan,
@@ -502,7 +502,7 @@ export function createPlanFeature(data: { plan: 'STARTER' | 'GROWTH' | 'PRO'; la
}) })
} }
export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO'; label: string; sortOrder: number }>) { export function updatePlanFeature(id: string, data: Partial<{ plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; label: string; sortOrder: number }>) {
return prisma.planFeature.update({ return prisma.planFeature.update({
where: { id }, where: { id },
data, data,
+5 -5
View File
@@ -150,7 +150,7 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(), }).optional(),
}).optional(), }).optional(),
subscription: z.object({ subscription: z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']).optional(),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']).optional(),
status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(), status: z.enum(['TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELLED', 'UNPAID']).optional(),
currency: z.literal('MAD').optional(), currency: z.literal('MAD').optional(),
@@ -316,14 +316,14 @@ export const homepageUpdateSchema = z.object({
export const pricingUpdateSchema = z.object({ export const pricingUpdateSchema = z.object({
entries: z.array(z.object({ entries: z.array(z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
amount: z.number().int().positive(), amount: z.number().int().positive(),
})).min(1), })).min(1),
}) })
const planFeatureSchema = z.object({ const planFeatureSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
label: z.string().min(1).max(120), label: z.string().min(1).max(120),
sortOrder: z.number().int().min(0).default(0), sortOrder: z.number().int().min(0).default(0),
}) })
@@ -336,7 +336,7 @@ export const promotionCreateSchema = z.object({
description: z.string().max(500).optional(), description: z.string().max(500).optional(),
discountType: z.enum(['PERCENTAGE', 'FIXED']), discountType: z.enum(['PERCENTAGE', 'FIXED']),
discountValue: z.number().int().positive(), discountValue: z.number().int().positive(),
plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO'])), plans: z.array(z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])),
periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])), periods: z.array(z.enum(['MONTHLY', 'ANNUAL'])),
maxUses: z.number().int().positive().nullable().optional(), maxUses: z.number().int().positive().nullable().optional(),
validFrom: z.string().datetime(), validFrom: z.string().datetime(),
@@ -350,7 +350,7 @@ export const planFeatureIdParamSchema = z.object({ featureId: z.string().min(1)
export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) }) export const promotionIdParamSchema = z.object({ promotionId: z.string().min(1) })
const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT']) const employeeRoleSchema = z.enum(['OWNER', 'MANAGER', 'AGENT'])
const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO']) const planSchema = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER']) const menuItemTypeSchema = z.enum(['INTERNAL_PAGE', 'EXTERNAL_LINK', 'PARENT_MENU', 'SECTION_LABEL', 'DIVIDER'])
export const menuItemSchema = z.object({ export const menuItemSchema = z.object({
@@ -11,6 +11,7 @@ export const accountStartSchema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().min(8).max(128), password: z.string().min(8).max(128),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
subscriptionPlan: z.enum(['STARTER', 'GROWTH', 'PRO']).optional(),
}) })
export const companyProfileSchema = z.object({ export const companyProfileSchema = z.object({
@@ -57,6 +58,6 @@ export const paymentSetupSchema = z.object({
}) })
export const planSchema = z.object({ export const planSchema = z.object({
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
}) })
@@ -63,7 +63,7 @@ export async function startAccount(body: AccountStartInput) {
await tx.subscription.create({ await tx.subscription.create({
data: { data: {
companyId: company.id, companyId: company.id,
plan: 'STARTER', plan: body.subscriptionPlan ?? 'STARTER',
billingPeriod: 'MONTHLY', billingPeriod: 'MONTHLY',
currency: 'MAD', currency: 'MAD',
status: 'TRIALING', status: 'TRIALING',
@@ -32,7 +32,7 @@ type CompanySignupInput = {
responsibleEmail: string responsibleEmail: string
currency: 'MAD' currency: 'MAD'
registrationNumber: string registrationNumber: string
plan: 'STARTER' | 'GROWTH' | 'PRO' plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
billingPeriod: 'MONTHLY' | 'ANNUAL' billingPeriod: 'MONTHLY' | 'ANNUAL'
preferredLanguage: 'en' | 'fr' | 'ar' preferredLanguage: 'en' | 'fr' | 'ar'
firstName: string firstName: string
@@ -31,7 +31,7 @@ export const companySignupSchema = z.object({
responsiblePhone: z.string().min(1).max(80), responsiblePhone: z.string().min(1).max(80),
responsibleEmail: z.string().email(), responsibleEmail: z.string().email(),
preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'), preferredLanguage: z.enum(['en', 'fr', 'ar']).default('en'),
plan: z.enum(['STARTER', 'GROWTH', 'PRO']), plan: z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']),
billingPeriod: z.enum(['MONTHLY', 'ANNUAL']), billingPeriod: z.enum(['MONTHLY', 'ANNUAL']),
currency: z.literal('MAD'), currency: z.literal('MAD'),
paymentProvider: z.enum(['AMANPAY', 'PAYPAL']), paymentProvider: z.enum(['AMANPAY', 'PAYPAL']),
+18 -2
View File
@@ -1,3 +1,4 @@
import { accountStartSchema } from './auth.account.schemas'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { companySignupSchema } from './auth.company.schemas' import { companySignupSchema } from './auth.company.schemas'
import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas' import { employeeForgotPasswordSchema, employeeLanguageSchema, employeeLoginSchema, employeeResetPasswordSchema } from './auth.employee.schemas'
@@ -36,15 +37,30 @@ describe('auth schemas', () => {
paymentProvider: 'AMANPAY', paymentProvider: 'AMANPAY',
} as const } as const
it('defaults company signup language and rejects unsupported commercial choices', () => { it('defaults company signup language and validates commercial choices', () => {
const parsed = companySignupSchema.parse(validCompanySignup) const parsed = companySignupSchema.parse(validCompanySignup)
expect(parsed.preferredLanguage).toBe('en') expect(parsed.preferredLanguage).toBe('en')
expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(false) expect(companySignupSchema.safeParse({ ...validCompanySignup, plan: 'ENTERPRISE' }).success).toBe(true)
expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false) expect(companySignupSchema.safeParse({ ...validCompanySignup, currency: 'EUR' }).success).toBe(false)
expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false) expect(companySignupSchema.safeParse({ ...validCompanySignup, paymentProvider: 'STRIPE' }).success).toBe(false)
}) })
it('accepts optional subscription plan for minimal account start', () => {
const parsed = accountStartSchema.parse({
firstName: 'Aya',
lastName: 'Benali',
companyName: 'Atlas Cars',
email: 'owner@example.test',
password: 'safe-password',
preferredLanguage: 'fr',
subscriptionPlan: 'PRO',
})
expect(parsed.subscriptionPlan).toBe('PRO')
expect(accountStartSchema.safeParse({ ...parsed, subscriptionPlan: 'TEAM' }).success).toBe(false)
})
it('normalizes employee auth fields and rejects weak reset payloads', () => { it('normalizes employee auth fields and rejects weak reset payloads', () => {
expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({ expect(employeeLoginSchema.parse({ email: 'Agent@Example.COM', password: 'password' })).toEqual({
email: 'agent@example.com', email: 'agent@example.com',
@@ -30,7 +30,7 @@ export type SettingsSectionKey =
| 'accounting' | 'accounting'
type Locale = 'en' | 'fr' | 'ar' type Locale = 'en' | 'fr' | 'ar'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type SubscriptionStatus = type SubscriptionStatus =
| 'TRIALING' | 'TRIALING'
| 'ACTIVE' | 'ACTIVE'
@@ -44,9 +44,9 @@ type SubscriptionStatus =
type MenuState = 'ENABLED' | 'LOCKED' type MenuState = 'ENABLED' | 'LOCKED'
const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const ALL_PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO'] const GROWTH_PLUS: Plan[] = ['GROWTH', 'PRO', 'ENTERPRISE']
const PRO_ONLY: Plan[] = ['PRO'] const PRO_ONLY: Plan[] = ['PRO', 'ENTERPRISE']
const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = { const SECTION_COPY: Record<Locale, Record<SettingsSectionKey, { label: string; description: string }>> = {
en: { en: {
+1 -1
View File
@@ -3,7 +3,7 @@ import { prisma } from '../../lib/prisma'
import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy' import { getAccessLevel, hasFullAccess, type AccessLevel } from '../subscriptions/subscription.policy'
type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT' type EmployeeRole = 'OWNER' | 'MANAGER' | 'AGENT'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER' type MenuItemType = 'INTERNAL_PAGE' | 'EXTERNAL_LINK' | 'PARENT_MENU' | 'SECTION_LABEL' | 'DIVIDER'
type AdminActor = { type AdminActor = {
@@ -1,6 +1,6 @@
import { z } from 'zod' import { z } from 'zod'
const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO']) const planEnum = z.enum(['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE'])
const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL']) const billingPeriodEnum = z.enum(['MONTHLY', 'ANNUAL'])
const providerEnum = z.enum(['STRIPE']) const providerEnum = z.enum(['STRIPE'])
const currencyEnum = z.enum(['MAD', 'EUR', 'USD']) const currencyEnum = z.enum(['MAD', 'EUR', 'USD'])
@@ -259,7 +259,7 @@ export async function handleStripeWebhook(event: any, rawBody: string | Buffer =
// ─── Checkout ───────────────────────────────────────────────── // ─── Checkout ─────────────────────────────────────────────────
export async function checkout(companyId: string, body: { export async function checkout(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL' plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'STRIPE' currency: 'MAD'; provider: 'STRIPE'
successUrl: string; failureUrl: string successUrl: string; failureUrl: string
}) { }) {
@@ -367,7 +367,7 @@ export async function resume(companyId: string) {
// ─── Reactivation ──────────────────────────────────────────── // ─── Reactivation ────────────────────────────────────────────
export async function reactivate(companyId: string, body: { export async function reactivate(companyId: string, body: {
plan: 'STARTER' | 'GROWTH' | 'PRO'; billingPeriod: 'MONTHLY' | 'ANNUAL' plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'; billingPeriod: 'MONTHLY' | 'ANNUAL'
currency: 'MAD'; provider: 'STRIPE' currency: 'MAD'; provider: 'STRIPE'
successUrl: string; failureUrl: string successUrl: string; failureUrl: string
}) { }) {
@@ -1,5 +1,25 @@
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
export async function getCompanySubscription(companyId: string) {
return prisma.subscription.findUnique({ where: { companyId } })
}
export async function findPlanFeatures(plan: string) {
return prisma.planFeature.findMany({
where: { plan: plan as any },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
})
}
export async function countActiveFleetVehicles(companyId: string) {
return prisma.vehicle.count({
where: {
companyId,
status: { not: 'OUT_OF_SERVICE' },
},
})
}
export async function findMany(where: any, skip: number, take: number) { export async function findMany(where: any, skip: number, take: number) {
return Promise.all([ return Promise.all([
prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }), prisma.vehicle.findMany({ where, skip, take, orderBy: { createdAt: 'desc' } }),
@@ -4,6 +4,9 @@ vi.mock('../../lib/prisma', () => ({ prisma: {} }))
vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('https://cdn.example.test/photo.jpg') })) vi.mock('../../lib/storage', () => ({ uploadImage: vi.fn().mockResolvedValue('https://cdn.example.test/photo.jpg') }))
vi.mock('./vehicle.repo', () => ({ vi.mock('./vehicle.repo', () => ({
create: vi.fn(), create: vi.fn(),
getCompanySubscription: vi.fn(),
findPlanFeatures: vi.fn(),
countActiveFleetVehicles: vi.fn(),
findFirst: vi.fn(), findFirst: vi.fn(),
updateById: vi.fn(), updateById: vi.fn(),
findById: vi.fn(), findById: vi.fn(),
@@ -35,6 +38,11 @@ const vehicle = {
} }
beforeEach(() => vi.clearAllMocks()) beforeEach(() => vi.clearAllMocks())
beforeEach(() => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
})
describe('vehicle.service edge behavior', () => { describe('vehicle.service edge behavior', () => {
it('deduplicates and trims location settings when creating vehicles', async () => { it('deduplicates and trims location settings when creating vehicles', async () => {
@@ -1,3 +1,4 @@
import { PLAN_FEATURES } from '@rentaldrivego/types'
import { uploadImage } from '../../lib/storage' import { uploadImage } from '../../lib/storage'
import { AppError, NotFoundError, ValidationError } from '../../http/errors' import { AppError, NotFoundError, ValidationError } from '../../http/errors'
import { presentVehicle, presentVehicleList } from './vehicle.presenter' import { presentVehicle, presentVehicleList } from './vehicle.presenter'
@@ -349,6 +350,13 @@ const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED'
const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
const ACTIVE_FLEET_STATUSES = new Set(VEHICLE_STATUSES.filter((status) => status !== 'OUT_OF_SERVICE'))
const FALLBACK_VEHICLE_LIMITS: Record<string, number | null> = {
STARTER: 25,
GROWTH: 75,
PRO: 150,
ENTERPRISE: null,
}
function listTextVariants(value: string) { function listTextVariants(value: string) {
const lower = value.toLowerCase() const lower = value.toLowerCase()
@@ -357,6 +365,47 @@ function listTextVariants(value: string) {
return Array.from(new Set([value, lower, upper, title])) return Array.from(new Set([value, lower, upper, title]))
} }
function parseVehicleLimit(labels: string[]) {
for (const label of labels) {
if (!/\bvehicles?\b/i.test(label)) continue
if (/\bunlimited\b/i.test(label)) return null
const openEndedMatch = label.match(/\b(\d+)\s*\+/)
if (openEndedMatch) return null
const match = label.match(/\b(\d+)\b/)
if (match) return Number(match[1])
}
return undefined
}
async function getVehicleLimitForCompany(companyId: string) {
const subscription = await repo.getCompanySubscription(companyId)
const plan = subscription?.plan
if (!plan) {
throw new ValidationError('A subscription plan is required before adding vehicles')
}
const persistedFeatures = await repo.findPlanFeatures(plan)
const persistedLimit = parseVehicleLimit(persistedFeatures.map((feature: any) => feature.label))
if (persistedLimit !== undefined) return persistedLimit
const fallbackLimit = parseVehicleLimit(PLAN_FEATURES[plan] ?? [])
if (fallbackLimit !== undefined) return fallbackLimit
return FALLBACK_VEHICLE_LIMITS[plan] ?? null
}
async function assertCanAddActiveFleetVehicle(companyId: string) {
const limit = await getVehicleLimitForCompany(companyId)
if (limit == null) return
const activeCount = await repo.countActiveFleetVehicles(companyId)
if (activeCount >= limit) {
throw new ValidationError(`Your subscription plan allows up to ${limit} active vehicles. Upgrade your plan or retire a vehicle before adding another one.`)
}
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) { export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1 const page = query.page ?? 1
const pageSize = query.pageSize ?? 20 const pageSize = query.pageSize ?? 20
@@ -413,9 +462,15 @@ export async function getVehicle(id: string, companyId: string) {
} }
export async function createVehicle(data: any, companyId: string) { export async function createVehicle(data: any, companyId: string) {
const patch = applyLocationSettings(data)
const status = patch.status ?? 'AVAILABLE'
if (ACTIVE_FLEET_STATUSES.has(status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
return presentVehicle(await repo.create({ return presentVehicle(await repo.create({
dailyRate: 0, dailyRate: 0,
...applyLocationSettings(data), ...patch,
companyId, companyId,
})) }))
} }
@@ -424,11 +479,14 @@ const PUBLISHED_STATUSES = new Set(['AVAILABLE', 'RESERVED', 'READY', 'RENTED'])
export async function updateVehicle(id: string, companyId: string, data: any) { export async function updateVehicle(id: string, companyId: string, data: any) {
const patch = applyLocationSettings(data) const patch = applyLocationSettings(data)
if (patch.status) { if (typeof patch.status === 'string') {
patch.isPublished = PUBLISHED_STATUSES.has(patch.status) patch.isPublished = PUBLISHED_STATUSES.has(patch.status)
} }
const existing = await repo.findFirst(id, companyId) const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found') if (!existing) throw new NotFoundError('Vehicle not found')
if (existing.status === 'OUT_OF_SERVICE' && typeof patch.status === 'string' && ACTIVE_FLEET_STATUSES.has(patch.status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
const updated = await repo.updateById(id, patch) const updated = await repo.updateById(id, patch)
if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') { if (patch.dailyRate !== undefined && typeof patch.dailyRate === 'number') {
await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate) await syncPricingBaseRateFromVehicle(id, existing.dailyRate, patch.dailyRate)
@@ -439,6 +497,9 @@ export async function updateVehicle(id: string, companyId: string, data: any) {
export async function setStatus(id: string, companyId: string, status: string) { export async function setStatus(id: string, companyId: string, status: string) {
const existing = await repo.findFirst(id, companyId) const existing = await repo.findFirst(id, companyId)
if (!existing) throw new NotFoundError('Vehicle not found') if (!existing) throw new NotFoundError('Vehicle not found')
if (existing.status === 'OUT_OF_SERVICE' && ACTIVE_FLEET_STATUSES.has(status as any)) {
await assertCanAddActiveFleetVehicle(companyId)
}
const isPublished = PUBLISHED_STATUSES.has(status) const isPublished = PUBLISHED_STATUSES.has(status)
return presentVehicle(await repo.updateById(id, { status, isPublished })) return presentVehicle(await repo.updateById(id, { status, isPublished }))
} }
@@ -25,6 +25,9 @@ const mockVehicle = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'STARTER' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 25 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(0)
}) })
describe('vehicle.service', () => { describe('vehicle.service', () => {
@@ -91,6 +94,7 @@ describe('vehicle.service', () => {
it('creates a vehicle with companyId', async () => { it('creates a vehicle with companyId', async () => {
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any) vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1') const result = await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123', dailyRate: 500 }, 'comp_1')
expect(repo.countActiveFleetVehicles).toHaveBeenCalledWith('comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' })) expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1' }))
expect(result).toEqual(mockVehicle) expect(result).toEqual(mockVehicle)
}) })
@@ -100,6 +104,57 @@ describe('vehicle.service', () => {
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1') await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 })) expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ companyId: 'comp_1', dailyRate: 0 }))
}) })
it('rejects active vehicle creation when the subscription vehicle limit is reached', async () => {
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 25 active vehicles')
expect(repo.create).not.toHaveBeenCalled()
})
it('uses the numeric vehicle limit from persisted plan features', async () => {
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 3 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(3)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 3 active vehicles')
})
it('enforces the PRO 150 vehicle limit', async () => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'PRO' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: 'Up to 150 vehicles' }] as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(150)
await expect(service.createVehicle({
make: 'Toyota',
model: 'Camry',
year: 2022,
licensePlate: 'ABC-123',
}, 'comp_1')).rejects.toThrow('allows up to 150 active vehicles')
expect(repo.create).not.toHaveBeenCalled()
})
it('allows open-ended enterprise vehicle plans', async () => {
vi.mocked(repo.getCompanySubscription).mockResolvedValue({ plan: 'ENTERPRISE' } as any)
vi.mocked(repo.findPlanFeatures).mockResolvedValue([{ label: '150+ vehicles' }] as any)
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)
await service.createVehicle({ make: 'Toyota', model: 'Camry', year: 2022, licensePlate: 'ABC-123' }, 'comp_1')
expect(repo.countActiveFleetVehicles).not.toHaveBeenCalled()
expect(repo.create).toHaveBeenCalled()
})
}) })
describe('updateVehicle', () => { describe('updateVehicle', () => {
@@ -121,6 +176,15 @@ describe('vehicle.service', () => {
await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' }) await service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })
expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true })) expect(repo.updateById).toHaveBeenCalledWith('veh_1', expect.objectContaining({ isPublished: true }))
}) })
it('rejects reactivating an out-of-service vehicle when the fleet limit is reached', async () => {
vi.mocked(repo.findFirst).mockResolvedValue({ ...mockVehicle, status: 'OUT_OF_SERVICE' } as any)
vi.mocked(repo.countActiveFleetVehicles).mockResolvedValue(25)
await expect(service.updateVehicle('veh_1', 'comp_1', { status: 'AVAILABLE' })).rejects.toThrow('allows up to 25 active vehicles')
expect(repo.updateById).not.toHaveBeenCalled()
})
}) })
describe('uploadPhotos', () => { describe('uploadPhotos', () => {
@@ -48,6 +48,7 @@ const PLAN_LABEL: Record<string, string> = {
STARTER: 'Starter', STARTER: 'Starter',
GROWTH: 'Growth', GROWTH: 'Growth',
PRO: 'Pro', PRO: 'Pro',
ENTERPRISE: 'Enterprise',
} }
const PERIOD_LABEL: Record<string, string> = { const PERIOD_LABEL: Record<string, string> = {
@@ -134,6 +134,7 @@ describe('auth API boundaries', () => {
email: 'owner@example.com', email: 'owner@example.com',
password: 'super-secret', password: 'super-secret',
preferredLanguage: 'fr', preferredLanguage: 'fr',
subscriptionPlan: 'GROWTH',
}) })
expect(res.status).toBe(201) expect(res.status).toBe(201)
@@ -150,6 +151,7 @@ describe('auth API boundaries', () => {
email: 'owner@example.com', email: 'owner@example.com',
password: 'super-secret', password: 'super-secret',
preferredLanguage: 'fr', preferredLanguage: 'fr',
subscriptionPlan: 'GROWTH',
}) })
}) })
@@ -62,6 +62,7 @@ describe('subscriptions public e2e smoke', () => {
expect(plans.body.data).toHaveProperty('STARTER') expect(plans.body.data).toHaveProperty('STARTER')
expect(plans.body.data).toHaveProperty('GROWTH') expect(plans.body.data).toHaveProperty('GROWTH')
expect(plans.body.data).toHaveProperty('PRO') expect(plans.body.data).toHaveProperty('PRO')
expect(plans.body.data).toHaveProperty('ENTERPRISE')
const features = await request(app).get('/api/v1/subscriptions/features') const features = await request(app).get('/api/v1/subscriptions/features')
expect(features.status).toBe(200) expect(features.status).toBe(200)
@@ -93,6 +93,8 @@ describe('Subscriptions API', () => {
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(res.body.data).toHaveProperty('STARTER') expect(res.body.data).toHaveProperty('STARTER')
expect(res.body.data).toHaveProperty('GROWTH') expect(res.body.data).toHaveProperty('GROWTH')
expect(res.body.data).toHaveProperty('PRO')
expect(res.body.data).toHaveProperty('ENTERPRISE')
}) })
it('returns provider availability', async () => { it('returns provider availability', async () => {
+2 -2
View File
@@ -31,7 +31,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const language = await getCarplaceLanguage() const language = await getCarplaceLanguage()
const cookieStore = await cookies() const cookieStore = await cookies()
const rawTheme = cookieStore.get('rentaldrivego-theme')?.value const rawTheme = cookieStore.get('rentaldrivego-theme')?.value
const theme = rawTheme === 'dark' ? 'dark' : 'light' const theme = rawTheme === 'light' ? 'light' : 'dark'
return ( return (
<html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning> <html lang={language} dir={language === 'ar' ? 'rtl' : 'ltr'} suppressHydrationWarning>
@@ -40,7 +40,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<script <script
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: __html:
"(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();", "(function(){try{var m=document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);var theme=m?decodeURIComponent(m[1]):localStorage.getItem('rentaldrivego-theme');if(theme!=='light'&&theme!=='dark'){theme='dark'}document.documentElement.classList.toggle('dark',theme==='dark');document.documentElement.style.colorScheme=theme;document.body&&document.body.setAttribute('data-theme',theme)}catch(e){}})();",
}} }}
/> />
</head> </head>
@@ -146,7 +146,7 @@ export function useCarplacePreferences() {
export default function CarplaceShell({ export default function CarplaceShell({
children, children,
initialLanguage = 'en', initialLanguage = 'en',
initialTheme = 'light', initialTheme = 'dark',
}: { }: {
children: React.ReactNode children: React.ReactNode
initialLanguage?: CarplaceLanguage initialLanguage?: CarplaceLanguage
@@ -198,10 +198,11 @@ export default function CarplaceShell({
}, [hydrated, language, pathname, router]) }, [hydrated, language, pathname, router])
useEffect(() => { useEffect(() => {
if (!hydrated) return
document.documentElement.classList.toggle('dark', theme === 'dark') document.documentElement.classList.toggle('dark', theme === 'dark')
document.documentElement.style.colorScheme = theme document.documentElement.style.colorScheme = theme
document.body.dataset.theme = theme document.body.dataset.theme = theme
if (hydrated) writeScopedPreference(SHARED_THEME_KEY, theme) writeScopedPreference(SHARED_THEME_KEY, theme)
}, [hydrated, theme]) }, [hydrated, theme])
const value = useMemo( const value = useMemo(
@@ -974,7 +974,7 @@ export default function ContractDetailPage() {
</div> </div>
</div> </div>
<div className="space-y-8 print:space-y-0"> <div className="contract-print-layout space-y-8 print:space-y-0">
<section className="mx-auto max-w-[1060px] rounded-lg border-2 border-neutral-950 bg-white p-4 text-[13.5px] leading-[1.25] text-neutral-950 shadow-2xl print:h-[calc(100vh-10mm)] print:max-w-none print:rounded-none print:border print:p-1.5 print:text-[8px] print:shadow-none print:break-after-page"> <section className="mx-auto max-w-[1060px] rounded-lg border-2 border-neutral-950 bg-white p-4 text-[13.5px] leading-[1.25] text-neutral-950 shadow-2xl print:h-[calc(100vh-10mm)] print:max-w-none print:rounded-none print:border print:p-1.5 print:text-[8px] print:shadow-none print:break-after-page">
<header className="grid w-full grid-cols-[31fr_69fr] items-start gap-3 print:gap-1.5"> <header className="grid w-full grid-cols-[31fr_69fr] items-start gap-3 print:gap-1.5">
<div className="flex h-[86px] flex-col items-start justify-start px-2 text-left print:h-[52px] print:px-1"> <div className="flex h-[86px] flex-col items-start justify-start px-2 text-left print:h-[52px] print:px-1">
@@ -54,7 +54,7 @@ interface Entitlement {
} }
interface Entitlements { interface Entitlements {
plan: 'STARTER' | 'GROWTH' | 'PRO' plan: 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
subscriptionStatus: string subscriptionStatus: string
accessLevel: 'full' | 'limited' | 'read_only' | 'none' accessLevel: 'full' | 'limited' | 'read_only' | 'none'
currentPeriodEnd: string | null currentPeriodEnd: string | null
@@ -7,7 +7,7 @@ import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
import { useDashboardI18n } from '@/components/I18nProvider' import { useDashboardI18n } from '@/components/I18nProvider'
import { buildHomepageSignInPath } from '@/lib/dashboardPaths' import { buildHomepageSignInPath } from '@/lib/dashboardPaths'
type Plan = 'STARTER' | 'GROWTH' | 'PRO' type Plan = 'STARTER' | 'GROWTH' | 'PRO' | 'ENTERPRISE'
type BillingPeriod = 'MONTHLY' | 'ANNUAL' type BillingPeriod = 'MONTHLY' | 'ANNUAL'
interface Subscription { interface Subscription {
@@ -65,7 +65,13 @@ const INVOICE_STATUS: Record<string, string> = {
REFUNDED: 'bg-slate-100 text-slate-600', REFUNDED: 'bg-slate-100 text-slate-600',
} }
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO'] const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO', 'ENTERPRISE']
const PLAN_LABELS: Record<Plan, string> = {
STARTER: 'Launch',
GROWTH: 'Growth',
PRO: 'Pro',
ENTERPRISE: 'Enterprise',
}
export default function SubscriptionPage() { export default function SubscriptionPage() {
const router = useRouter() const router = useRouter()
const { language } = useDashboardI18n() const { language } = useDashboardI18n()
@@ -130,9 +136,10 @@ export default function SubscriptionPage() {
statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>, statusLabels: { TRIALING: 'Trialing', ACTIVE: 'Active', PAST_DUE: 'Past due', CANCELLED: 'Cancelled', CANCELED: 'Canceled', UNPAID: 'Unpaid', EXPIRED: 'Expired', SUSPENDED: 'Suspended' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>, invoiceStatusLabels: { PAID: 'Paid', PENDING: 'Pending', FAILED: 'Failed', REFUNDED: 'Refunded' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['Up to 10 vehicles', '1 user seat', 'Basic analytics', 'Carplace listing'], STARTER: ['Up to 25 vehicles', '1 user seat', 'Basic analytics', 'Carplace listing'],
GROWTH: ['Up to 50 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'], GROWTH: ['Up to 75 vehicles', '5 user seats', 'Full analytics', 'Priority listing', 'Custom branding'],
PRO: ['Unlimited vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'], PRO: ['Up to 150 vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
ENTERPRISE: ['150+ vehicles', 'Unlimited seats', 'Advanced reports', 'API access', 'Dedicated support'],
} as Record<Plan, string[]>, } as Record<Plan, string[]>,
}, },
fr: { fr: {
@@ -178,9 +185,10 @@ export default function SubscriptionPage() {
statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>, statusLabels: { TRIALING: 'Essai', ACTIVE: 'Actif', PAST_DUE: 'En retard', CANCELLED: 'Annulé', CANCELED: 'Annulé', UNPAID: 'Impayé', EXPIRED: 'Expiré', SUSPENDED: 'Suspendu' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>, invoiceStatusLabels: { PAID: 'Payé', PENDING: 'En attente', FAILED: 'Échec', REFUNDED: 'Remboursé' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['Jusqu’à 10 véhicules', '1 utilisateur', 'Analyses de base', 'Présence sur Carplace'], STARTER: ['Jusqu’à 25 véhicules', '1 utilisateur', 'Analyses de base', 'Présence sur Carplace'],
GROWTH: ['Jusqu’à 50 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'], GROWTH: ['Jusqu’à 75 véhicules', '5 utilisateurs', 'Analyses complètes', 'Mise en avant prioritaire', 'Personnalisation'],
PRO: ['Véhicules illimités', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'], PRO: ['Jusqu’à 150 véhicules', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
ENTERPRISE: ['150+ véhicules', 'Utilisateurs illimités', 'Rapports avancés', 'Accès API', 'Support dédié'],
} as Record<Plan, string[]>, } as Record<Plan, string[]>,
}, },
ar: { ar: {
@@ -226,9 +234,10 @@ export default function SubscriptionPage() {
statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>, statusLabels: { TRIALING: 'تجريبي', ACTIVE: 'نشط', PAST_DUE: 'متأخر', CANCELLED: 'ملغى', CANCELED: 'ملغى', UNPAID: 'غير مدفوع', EXPIRED: 'منتهي', SUSPENDED: 'معلّق' } as Record<string, string>,
invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>, invoiceStatusLabels: { PAID: 'مدفوع', PENDING: 'قيد الانتظار', FAILED: 'فشل', REFUNDED: 'مسترد' } as Record<string, string>,
planFeatures: { planFeatures: {
STARTER: ['حتى 10 مركبات', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'], STARTER: ['حتى 25 مركبة', 'مستخدم واحد', 'تحليلات أساسية', 'إدراج في السوق'],
GROWTH: ['حتى 50 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'], GROWTH: ['حتى 75 مركبة', '5 مستخدمين', 'تحليلات كاملة', 'إدراج ذو أولوية', 'تخصيص العلامة'],
PRO: ['مركبات غير محدودة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'], PRO: ['حتى 150 مركبة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
ENTERPRISE: ['150+ مركبة', 'مقاعد غير محدودة', 'تقارير متقدمة', 'وصول API', 'دعم مخصص'],
} as Record<Plan, string[]>, } as Record<Plan, string[]>,
}, },
}[language] }[language]
@@ -430,7 +439,7 @@ export default function SubscriptionPage() {
<div> <div>
<p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p> <p className="text-xs font-medium text-slate-500 uppercase tracking-wide">{copy.currentPlan}</p>
<div className="mt-1 flex items-center gap-3"> <div className="mt-1 flex items-center gap-3">
<h3 className="text-2xl font-bold text-slate-900">{subscription.plan}</h3> <h3 className="text-2xl font-bold text-slate-900">{PLAN_LABELS[subscription.plan]}</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[subscription.status] ?? 'bg-slate-100 text-slate-600'}`}>
{copy.statusLabels[subscription.status] ?? subscription.status} {copy.statusLabels[subscription.status] ?? subscription.status}
</span> </span>
@@ -496,7 +505,7 @@ export default function SubscriptionPage() {
</div> </div>
{/* Plan cards */} {/* Plan cards */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{PLANS.map((plan) => { {PLANS.map((plan) => {
const price = planPrices[plan]?.[billingPeriod]?.[currency] const price = planPrices[plan]?.[billingPeriod]?.[currency]
const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE' const isActive = subscription?.plan === plan && subscription?.status === 'ACTIVE'
@@ -512,7 +521,7 @@ export default function SubscriptionPage() {
} ${isActive ? 'ring-2 ring-green-200' : ''}`} } ${isActive ? 'ring-2 ring-green-200' : ''}`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-semibold text-slate-900">{plan}</p> <p className="font-semibold text-slate-900">{PLAN_LABELS[plan]}</p>
{isActive && <span className="badge-green">{copy.active}</span>} {isActive && <span className="badge-green">{copy.active}</span>}
</div> </div>
<p className="mt-2 text-2xl font-black text-slate-900"> <p className="mt-2 text-2xl font-black text-slate-900">
+7
View File
@@ -376,6 +376,13 @@ html.dark body {
background-color: rgb(23 37 84 / 0.9); background-color: rgb(23 37 84 / 0.9);
} }
.dark .contract-print-layout,
.dark .contract-print-layout .bg-white {
background-color: #ffffff;
color: #0a0a0a;
color-scheme: light;
}
.dark .border-slate-200, .dark .border-slate-200,
.dark .border-stone-200 { .dark .border-stone-200 {
border-color: rgb(30 64 175 / 0.50); border-color: rgb(30 64 175 / 0.50);
@@ -86,7 +86,7 @@ describe("dashboard public auth pages", () => {
const text = collectText(page).join(" "); const text = collectText(page).join(" ");
const signInLink = findElement( const signInLink = findElement(
page, page,
(element) => element.props.href === "/en/light/sign-in", (element) => element.props.href === "/en/dark/sign-in",
); );
expect(text).toContain("Invitation accepted"); expect(text).toContain("Invitation accepted");
@@ -10,6 +10,19 @@ import { buildHomepageSignInPath } from "@/lib/dashboardPaths";
import { websiteUrl } from "@/lib/urls"; import { websiteUrl } from "@/lib/urls";
const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png"; const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png";
const SUBSCRIPTION_PLANS = ["STARTER", "GROWTH", "PRO"] as const;
type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
const PLAN_LABELS: Record<SubscriptionPlan, string> = {
STARTER: "Launch",
GROWTH: "Growth",
PRO: "Pro",
};
function isSubscriptionPlan(value: string | null): value is SubscriptionPlan {
return SUBSCRIPTION_PLANS.some((plan) => plan === value);
}
function notifyParent(message: Record<string, unknown>) { function notifyParent(message: Record<string, unknown>) {
if (typeof window === "undefined" || window.parent === window) return; if (typeof window === "undefined" || window.parent === window) return;
@@ -41,6 +54,8 @@ export default function SignUpForm({
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const requestedLanguage = searchParams.get("lang"); const requestedLanguage = searchParams.get("lang");
const requestedTheme = searchParams.get("theme"); const requestedTheme = searchParams.get("theme");
const requestedPlan = searchParams.get("plan");
const selectedPlan = isSubscriptionPlan(requestedPlan) ? requestedPlan : null;
const initializedFromQuery = useRef(false); const initializedFromQuery = useRef(false);
const initialLanguage = const initialLanguage =
requestedLanguage === "en" || requestedLanguage === "en" ||
@@ -151,6 +166,7 @@ export default function SignUpForm({
passwordHint: "Minimum 8 characters", passwordHint: "Minimum 8 characters",
languageLabel: "Preferred language", languageLabel: "Preferred language",
create: "Create account", create: "Create account",
subscribeToPlan: "Subscribe to {plan}",
creating: "Creating\u2026", creating: "Creating\u2026",
alreadyHave: "Already have an account?", alreadyHave: "Already have an account?",
signIn: "Sign in", signIn: "Sign in",
@@ -179,6 +195,7 @@ export default function SignUpForm({
passwordHint: "Minimum 8 caract\u00e8res", passwordHint: "Minimum 8 caract\u00e8res",
languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e", languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e",
create: "Cr\u00e9er un compte", create: "Cr\u00e9er un compte",
subscribeToPlan: "Sabonner à {plan}",
creating: "Cr\u00e9ation\u2026", creating: "Cr\u00e9ation\u2026",
alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?", alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?",
signIn: "Se connecter", signIn: "Se connecter",
@@ -214,6 +231,8 @@ export default function SignUpForm({
"\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629", "\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629",
create: create:
"\u0623\u0646\u0634\u0626 \u062d\u0633\u0627\u0628\u064b\u0627", "\u0623\u0646\u0634\u0626 \u062d\u0633\u0627\u0628\u064b\u0627",
subscribeToPlan:
"\u0627\u0634\u062a\u0631\u0643 \u0641\u064a {plan}",
creating: creating:
"\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026", "\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026",
alreadyHave: alreadyHave:
@@ -240,6 +259,9 @@ export default function SignUpForm({
language: preferredLanguage, language: preferredLanguage,
theme, theme,
}); });
const submitLabel = selectedPlan
? dict.subscribeToPlan.replace("{plan}", PLAN_LABELS[selectedPlan])
: dict.create;
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -261,6 +283,7 @@ export default function SignUpForm({
email, email,
password, password,
preferredLanguage, preferredLanguage,
...(selectedPlan ? { subscriptionPlan: selectedPlan } : {}),
}), }),
}); });
@@ -498,7 +521,7 @@ export default function SignUpForm({
disabled={loading} disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400" className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
> >
{loading ? dict.creating : dict.create} {loading ? dict.creating : submitLabel}
</button> </button>
</form> </form>
@@ -135,8 +135,8 @@ describe('DashboardAccessGuard route helpers', () => {
}) })
it('builds sign-in redirects with public dashboard return paths', () => { it('builds sign-in redirects with public dashboard return paths', () => {
expect(buildSignInRedirect('/reservations')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Freservations') expect(buildSignInRedirect('/reservations')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Freservations')
expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Ffleet') expect(buildSignInRedirect('/dashboard/fleet')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
}) })
it('treats subscription as an owner-only recovery route independent of menu registration', () => { it('treats subscription as an owner-only recovery route independent of menu registration', () => {
@@ -21,8 +21,8 @@ describe('dashboard path normalization', () => {
}) })
it('builds the canonical homepage sign-in URL with a dashboard return path', () => { it('builds the canonical homepage sign-in URL with a dashboard return path', () => {
expect(buildHomepageSignInPath('/reservations')).toBe('/en/light/sign-in?redirect=%2Fdashboard%2Freservations') expect(buildHomepageSignInPath('/reservations')).toBe('/en/dark/sign-in?redirect=%2Fdashboard%2Freservations')
expect(buildHomepageSignInPath('/dashboard/fleet', { locale: 'fr', theme: 'dark' })).toBe('/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet') expect(buildHomepageSignInPath('/dashboard/fleet', { locale: 'fr', theme: 'dark' })).toBe('/fr/dark/sign-in?redirect=%2Fdashboard%2Ffleet')
expect(buildHomepageSignInPath()).toBe('/en/light/sign-in') expect(buildHomepageSignInPath()).toBe('/en/dark/sign-in')
}) })
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
const DASHBOARD_BASE_PATH = '/dashboard' const DASHBOARD_BASE_PATH = '/dashboard'
const DEFAULT_SIGN_IN_LOCALE = 'en' const DEFAULT_SIGN_IN_LOCALE = 'en'
const DEFAULT_SIGN_IN_THEME = 'light' const DEFAULT_SIGN_IN_THEME = 'dark'
export function toDashboardAppPath(path?: string | null): string { export function toDashboardAppPath(path?: string | null): string {
const value = (path ?? '').trim() const value = (path ?? '').trim()
+4 -4
View File
@@ -77,7 +77,7 @@ describe('dashboard middleware', () => {
const response = middleware(request('http://dashboard:3001/dashboard/team') as never) const response = middleware(request('http://dashboard:3001/dashboard/team') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/light/sign-in?redirect=%2Fdashboard%2Fteam' }) expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Fteam' })
}) })
it('redirects unprefixed internal app paths with a public dashboard return path', async () => { it('redirects unprefixed internal app paths with a public dashboard return path', async () => {
@@ -85,7 +85,7 @@ describe('dashboard middleware', () => {
const response = middleware(request('http://dashboard:3001/reservations') as never) const response = middleware(request('http://dashboard:3001/reservations') as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/light/sign-in?redirect=%2Fdashboard%2Freservations' }) expect(response).toEqual({ kind: 'redirect', url: 'https://rentaldrivego.example/en/dark/sign-in?redirect=%2Fdashboard%2Freservations' })
}) })
it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => { it('ignores spoofed forwarded host/proto when building the dashboard sign-in redirect', async () => {
@@ -98,7 +98,7 @@ describe('dashboard middleware', () => {
}, },
}) as never) }) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/light/sign-in?redirect=%2Fdashboard%2Fbilling' }) expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/dark/sign-in?redirect=%2Fdashboard%2Fbilling' })
}) })
it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => { it('ignores internal forwarded hosts when building the dashboard sign-in redirect', async () => {
@@ -111,7 +111,7 @@ describe('dashboard middleware', () => {
}, },
}) as never) }) as never)
expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/light/sign-in?redirect=%2Fdashboard%2Ffleet' }) expect(response).toEqual({ kind: 'redirect', url: 'https://market.example.com/en/dark/sign-in?redirect=%2Fdashboard%2Ffleet' })
}) })
it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => { it('redirects legacy dashboard sign-in requests to the localized homepage sign-in route', async () => {
+1 -1
View File
@@ -5,7 +5,7 @@ const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL ?? 'http://localhost:300
const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard` const DASHBOARD_PUBLIC_URL = process.env.NEXT_PUBLIC_DASHBOARD_URL ?? `${WEBSITE_URL.replace(/\/$/, '')}/dashboard`
const DASHBOARD_BASE_PATH = '/dashboard' const DASHBOARD_BASE_PATH = '/dashboard'
const DEFAULT_SIGN_IN_LOCALE = 'en' const DEFAULT_SIGN_IN_LOCALE = 'en'
const DEFAULT_SIGN_IN_THEME = 'light' const DEFAULT_SIGN_IN_THEME = 'dark'
function toDashboardAppPath(pathname: string): string { function toDashboardAppPath(pathname: string): string {
let normalized = pathname || '/' let normalized = pathname || '/'
@@ -70,6 +70,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
<PricingSection <PricingSection
content={content.pricing} content={content.pricing}
locale={locale} locale={locale}
themePreference={mode}
homePath={homePath} homePath={homePath}
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled} demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
/> />
@@ -1,5 +1,5 @@
import { defaultMode, isLocale } from '@/lib/localization/config' import { defaultMode, isLocale } from '@/lib/localization/config'
import { isThemePreference, themeCookie } from '@/lib/theme/config' import { isThemePreference, sharedThemeCookie, themeCookie } from '@/lib/theme/config'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -14,6 +14,11 @@ export default async function LegacyComponentLabPage({
if (!isLocale(locale)) notFound() if (!isLocale(locale)) notFound()
const cookieStore = await cookies() const cookieStore = await cookies()
const cookieMode = cookieStore.get(themeCookie)?.value const cookieMode = cookieStore.get(themeCookie)?.value
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode const sharedCookieMode = cookieStore.get(sharedThemeCookie)?.value
const mode = isThemePreference(cookieMode)
? cookieMode
: isThemePreference(sharedCookieMode)
? sharedCookieMode
: defaultMode
redirect(`/${locale}/${mode}/component-lab`) redirect(`/${locale}/${mode}/component-lab`)
} }
@@ -4,7 +4,7 @@ import {
localizedModePath, localizedModePath,
type Locale, type Locale,
} from '@/lib/localization/config' } from '@/lib/localization/config'
import { isThemePreference, themeCookie } from '@/lib/theme/config' import { isThemePreference, sharedThemeCookie, themeCookie } from '@/lib/theme/config'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -17,6 +17,11 @@ export default async function LegacyForgotPasswordPage({
if (!isLocale(localeValue)) notFound() if (!isLocale(localeValue)) notFound()
const cookieStore = await cookies() const cookieStore = await cookies()
const cookieMode = cookieStore.get(themeCookie)?.value const cookieMode = cookieStore.get(themeCookie)?.value
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode const sharedCookieMode = cookieStore.get(sharedThemeCookie)?.value
const mode = isThemePreference(cookieMode)
? cookieMode
: isThemePreference(sharedCookieMode)
? sharedCookieMode
: defaultMode
redirect(localizedModePath('forgot-password', localeValue as Locale, mode)) redirect(localizedModePath('forgot-password', localeValue as Locale, mode))
} }
@@ -14,6 +14,7 @@ import { getMessages } from '@/lib/localization/messages';
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script'; import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
import { import {
isThemePreference, isThemePreference,
sharedThemeCookie,
serverResolvedTheme, serverResolvedTheme,
themeCookie, themeCookie,
type ThemePreference, type ThemePreference,
@@ -50,10 +51,13 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
const cookieStore = await cookies(); const cookieStore = await cookies();
const headerPreference = requestHeaders.get('x-theme-preference'); const headerPreference = requestHeaders.get('x-theme-preference');
const cookiePreference = cookieStore.get(themeCookie)?.value; const cookiePreference = cookieStore.get(themeCookie)?.value;
const sharedCookiePreference = cookieStore.get(sharedThemeCookie)?.value;
const preference: ThemePreference = isThemePreference(headerPreference) const preference: ThemePreference = isThemePreference(headerPreference)
? headerPreference ? headerPreference
: isThemePreference(cookiePreference) : isThemePreference(cookiePreference)
? cookiePreference ? cookiePreference
: isThemePreference(sharedCookiePreference)
? sharedCookiePreference
: 'dark'; : 'dark';
const resolvedTheme = serverResolvedTheme(preference); const resolvedTheme = serverResolvedTheme(preference);
+4 -2
View File
@@ -4,7 +4,7 @@ import {
localizedModePath, localizedModePath,
type Locale, type Locale,
} from '@/lib/localization/config'; } from '@/lib/localization/config';
import { isThemePreference, themeCookie } from '@/lib/theme/config'; import { isThemePreference, sharedThemeCookie, themeCookie } from '@/lib/theme/config';
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { notFound, redirect } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
@@ -15,7 +15,9 @@ interface LocaleRedirectPageProps {
async function preferredMode() { async function preferredMode() {
const cookieStore = await cookies(); const cookieStore = await cookies();
const cookieMode = cookieStore.get(themeCookie)?.value; const cookieMode = cookieStore.get(themeCookie)?.value;
return isThemePreference(cookieMode) ? cookieMode : defaultMode; const sharedCookieMode = cookieStore.get(sharedThemeCookie)?.value;
if (isThemePreference(cookieMode)) return cookieMode;
return isThemePreference(sharedCookieMode) ? sharedCookieMode : defaultMode;
} }
export default async function LocaleRedirectPage({ params }: LocaleRedirectPageProps) { export default async function LocaleRedirectPage({ params }: LocaleRedirectPageProps) {
@@ -4,7 +4,7 @@ import {
localizedModePath, localizedModePath,
type Locale, type Locale,
} from '@/lib/localization/config' } from '@/lib/localization/config'
import { isThemePreference, themeCookie } from '@/lib/theme/config' import { isThemePreference, sharedThemeCookie, themeCookie } from '@/lib/theme/config'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
@@ -17,6 +17,11 @@ export default async function LegacySignInPage({
if (!isLocale(localeValue)) notFound() if (!isLocale(localeValue)) notFound()
const cookieStore = await cookies() const cookieStore = await cookies()
const cookieMode = cookieStore.get(themeCookie)?.value const cookieMode = cookieStore.get(themeCookie)?.value
const mode = isThemePreference(cookieMode) ? cookieMode : defaultMode const sharedCookieMode = cookieStore.get(sharedThemeCookie)?.value
const mode = isThemePreference(cookieMode)
? cookieMode
: isThemePreference(sharedCookieMode)
? sharedCookieMode
: defaultMode
redirect(localizedModePath('sign-in', localeValue as Locale, mode)) redirect(localizedModePath('sign-in', localeValue as Locale, mode))
} }
@@ -1,3 +1,5 @@
'use client';
import { StatusBadge } from '@/components/foundation/StatusBadge'; import { StatusBadge } from '@/components/foundation/StatusBadge';
import { accountCreateUrl } from '@/lib/account-urls'; import { accountCreateUrl } from '@/lib/account-urls';
import { localizedModePath, type Locale } from '@/lib/localization/config'; import { localizedModePath, type Locale } from '@/lib/localization/config';
@@ -5,6 +7,7 @@ import type { ShellMessages } from '@/lib/localization/messages';
import type { ThemePreference } from '@/lib/theme/config'; import type { ThemePreference } from '@/lib/theme/config';
import Image from 'next/image'; import Image from 'next/image';
import styles from './SiteFooter.module.css'; import styles from './SiteFooter.module.css';
import { useThemePreference } from './useThemePreference';
function signInUrl(locale: Locale, mode: ThemePreference): string { function signInUrl(locale: Locale, mode: ThemePreference): string {
return localizedModePath('sign-in', locale, mode); return localizedModePath('sign-in', locale, mode);
@@ -61,6 +64,7 @@ function PendingItem({ label, pending }: { label: string; pending: string }) {
} }
export function SiteFooter({ locale, mode, messages }: SiteFooterProps) { export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
const currentMode = useThemePreference(mode);
const footer = messages.footer; const footer = messages.footer;
const year = new Date().getUTCFullYear(); const year = new Date().getUTCFullYear();
@@ -117,7 +121,7 @@ export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
<PendingItem label={footer.links.contact} pending={footer.pending} /> <PendingItem label={footer.links.contact} pending={footer.pending} />
</li> </li>
<li> <li>
<a href={accountCreateUrl(locale, mode)}>{footer.links.demo}</a> <a href={accountCreateUrl(locale, currentMode)}>{footer.links.demo}</a>
</li> </li>
</ul> </ul>
</section> </section>
@@ -126,7 +130,7 @@ export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
<h2 id="footer-account">{footer.groups.account}</h2> <h2 id="footer-account">{footer.groups.account}</h2>
<ul> <ul>
<li> <li>
<a href={signInUrl(locale, mode)}> <a href={signInUrl(locale, currentMode)}>
{footer.links.login} {footer.links.login}
</a> </a>
</li> </li>
@@ -1,11 +1,13 @@
'use client'; 'use client';
import { applyTheme, currentThemePreference } from '@/lib/theme/client'; import { applyTheme, currentThemePreference, persistTheme } from '@/lib/theme/client';
import { themeStorageKey } from '@/lib/theme/config'; import { themeStorageKey } from '@/lib/theme/config';
import { useEffect } from 'react'; import { useEffect } from 'react';
export function ThemeController() { export function ThemeController() {
useEffect(() => { useEffect(() => {
persistTheme(currentThemePreference());
const media = window.matchMedia('(prefers-color-scheme: dark)'); const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = () => { const handleSystemChange = () => {
if (currentThemePreference() === 'system') applyTheme('system'); if (currentThemePreference() === 'system') applyTheme('system');
@@ -18,6 +18,10 @@
display: grid; display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr); grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr);
gap: var(--space-5); gap: var(--space-5);
position: relative;
left: 50%;
inline-size: min(100vw - (var(--layout-gutter) * 2), 104rem);
transform: translateX(-50%);
padding: var(--space-5); padding: var(--space-5);
border: 1px solid var(--border-standard); border: 1px solid var(--border-standard);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -107,8 +111,12 @@
.planGrid { .planGrid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch; align-items: stretch;
position: relative;
left: 50%;
inline-size: min(100vw - (var(--layout-gutter) * 2), 104rem);
transform: translateX(-50%);
gap: var(--space-5); gap: var(--space-5);
} }
@@ -180,15 +188,15 @@
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: baseline; align-items: baseline;
gap: var(--space-2); gap: var(--space-1);
color: var(--text-primary); color: var(--text-primary);
} }
.price { .price {
font-size: clamp(2rem, 4vw, 3rem); font-size: clamp(1.65rem, 2.4vw, 2.35rem);
font-weight: 850; font-weight: 850;
line-height: 1; line-height: 1;
letter-spacing: -0.035em; letter-spacing: 0;
} }
.pendingPrice, .pendingPrice,
@@ -330,10 +338,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.planCard:last-child {
grid-column: 1 / -1;
}
.compareBlock { .compareBlock {
justify-items: start; justify-items: start;
text-align: start; text-align: start;
@@ -356,10 +360,6 @@
transform: none; transform: none;
} }
.planCard:last-child {
grid-column: auto;
}
.audience, .audience,
.priceBlock { .priceBlock {
min-block-size: auto; min-block-size: auto;
@@ -1,6 +1,7 @@
'use client'; 'use client';
import { ActionLink } from '@/components/actions/ActionLink'; import { ActionLink } from '@/components/actions/ActionLink';
import { useThemePreference } from '@/components/app-shell/useThemePreference';
import { Icon } from '@/components/foundation/Icon'; import { Icon } from '@/components/foundation/Icon';
import { DemoTrigger } from '@/components/integrations/DemoTrigger'; import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives'; import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
@@ -14,18 +15,27 @@ import {
} from '@/content/pricing-config'; } from '@/content/pricing-config';
import { trackAnalytics } from '@/lib/analytics/client'; import { trackAnalytics } from '@/lib/analytics/client';
import type { AnalyticsContext } from '@/lib/analytics/events'; import type { AnalyticsContext } from '@/lib/analytics/events';
import { accountCreateUrl, type AccountSubscriptionPlan } from '@/lib/account-urls';
import { classNames } from '@/lib/components/classNames'; import { classNames } from '@/lib/components/classNames';
import { getDirection, type Locale } from '@/lib/localization/config'; import { getDirection, type Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import styles from './PricingSection.module.css'; import styles from './PricingSection.module.css';
interface PricingSectionProps { interface PricingSectionProps {
content: HomepageContent['pricing']; content: HomepageContent['pricing'];
locale: Locale; locale: Locale;
themePreference: ThemePreference;
homePath: string; homePath: string;
demoSubmissionEnabled: boolean; demoSubmissionEnabled: boolean;
} }
const accountSubscriptionPlanByPricingPlan: Record<Exclude<PricingPlanId, 'enterprise'>, AccountSubscriptionPlan> = {
launch: 'STARTER',
growth: 'GROWTH',
pro: 'PRO',
};
function analyticsContext( function analyticsContext(
locale: Locale, locale: Locale,
fleetBand: FleetBandId, fleetBand: FleetBandId,
@@ -44,10 +54,10 @@ function analyticsContext(
}; };
} }
function monthlyEquivalent(monthlyPrice: number, billingPeriod: BillingPeriod): number { function calculateDisplayedPrice(monthlyPrice: number, billingPeriod: BillingPeriod): number {
if (billingPeriod === 'monthly') return monthlyPrice; if (billingPeriod === 'monthly') return monthlyPrice;
const discountPercent = pricingCommercialConfig.annualDiscountPercent ?? 0; const discountPercent = pricingCommercialConfig.annualDiscountPercent ?? 0;
return monthlyPrice * (1 - discountPercent / 100); return monthlyPrice * 12 * (1 - discountPercent / 100);
} }
function formatPrice(value: number, locale: Locale): string { function formatPrice(value: number, locale: Locale): string {
@@ -69,9 +79,11 @@ function formatDiscount(locale: Locale): string {
export function PricingSection({ export function PricingSection({
content, content,
locale, locale,
themePreference,
homePath, homePath,
demoSubmissionEnabled, demoSubmissionEnabled,
}: PricingSectionProps) { }: PricingSectionProps) {
const currentThemePreference = useThemePreference(themePreference);
const [fleetBand, setFleetBand] = useState<FleetBandId>('small'); const [fleetBand, setFleetBand] = useState<FleetBandId>('small');
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('monthly'); const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('monthly');
const recommendedPlan = pricingCommercialConfig.recommendedPlanByFleetBand[fleetBand]; const recommendedPlan = pricingCommercialConfig.recommendedPlanByFleetBand[fleetBand];
@@ -84,7 +96,7 @@ export function PricingSection({
const publicPrice = pricingCommercialConfig.publicPricesApproved ? configuredPrice : null; const publicPrice = pricingCommercialConfig.publicPricesApproved ? configuredPrice : null;
return [ return [
plan.id, plan.id,
publicPrice === null ? null : monthlyEquivalent(publicPrice, billingPeriod), publicPrice === null ? null : calculateDisplayedPrice(publicPrice, billingPeriod),
]; ];
}), }),
) as Record<PricingPlanId, number | null>, ) as Record<PricingPlanId, number | null>,
@@ -198,7 +210,7 @@ export function PricingSection({
<bdi className={styles.price} dir="ltr"> <bdi className={styles.price} dir="ltr">
{formatPrice(price, locale)} {formatPrice(price, locale)}
</bdi> </bdi>
<span>{content.perMonth}</span> <span>{billingPeriod === 'monthly' ? content.perMonth : content.perYear}</span>
</div> </div>
{billingPeriod === 'annual' ? ( {billingPeriod === 'annual' ? (
<p className={styles.priceMeta}>{content.annualEquivalent}</p> <p className={styles.priceMeta}>{content.annualEquivalent}</p>
@@ -216,22 +228,42 @@ export function PricingSection({
))} ))}
</ul> </ul>
<DemoTrigger {plan.id === 'enterprise' ? (
label={plan.cta} <DemoTrigger
source="pricing" label={plan.cta}
size="large" source="pricing"
fullWidth size="large"
className={styles.planAction} fullWidth
disabledReason={ className={styles.planAction}
demoSubmissionEnabled ? undefined : content.actionDisabledReason disabledReason={
} demoSubmissionEnabled ? undefined : content.actionDisabledReason
onActivate={() => }
trackAnalytics( onActivate={() =>
'pricing_plan_cta_clicked', trackAnalytics(
analyticsContext(locale, fleetBand, billingPeriod, plan.id), 'pricing_plan_cta_clicked',
) analyticsContext(locale, fleetBand, billingPeriod, plan.id),
} )
/> }
/>
) : (
<ActionLink
href={accountCreateUrl(
locale,
currentThemePreference,
accountSubscriptionPlanByPricingPlan[plan.id],
)}
variant="button-conversion"
className={styles.planAction}
onClick={() =>
trackAnalytics(
'pricing_plan_cta_clicked',
analyticsContext(locale, fleetBand, billingPeriod, plan.id),
)
}
>
{plan.cta}
</ActionLink>
)}
</article> </article>
); );
})} })}
@@ -30,6 +30,7 @@ interface DemoDialogHostProps {
type FormStatus = 'idle' | 'submitting' | 'success' | 'error'; type FormStatus = 'idle' | 'submitting' | 'success' | 'error';
type FieldErrors = Record<string, string>; type FieldErrors = Record<string, string>;
type FleetOption = { value: (typeof fleetSizeValues)[number]; label: string };
function messageForCode(code: string, messages: DemoDialogHostProps['messages']): string { function messageForCode(code: string, messages: DemoDialogHostProps['messages']): string {
switch (code) { switch (code) {
@@ -99,6 +100,14 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
const idempotencyKeyRef = useRef(''); const idempotencyKeyRef = useRef('');
const startedAtRef = useRef(0); const startedAtRef = useRef(0);
const closeTrackedRef = useRef(false); const closeTrackedRef = useRef(false);
const pricingFleetOnly = source === 'pricing';
const dialogTitle = pricingFleetOnly ? messages.quoteTitle : messages.title;
const dialogDescription = pricingFleetOnly ? messages.quoteBody : messages.body;
const noticeLabel = pricingFleetOnly ? messages.quoteModeLabel : messages.localModeLabel;
const noticeBody = pricingFleetOnly ? messages.quotePrivacy : messages.privacy;
const submitLabel = pricingFleetOnly ? messages.quoteSubmit : messages.submit;
const successTitle = pricingFleetOnly ? messages.quoteSuccessTitle : messages.successTitle;
const successBody = pricingFleetOnly ? messages.quoteSuccessBody : messages.successBody;
const summaryErrors = useMemo<FormErrorItem[]>( const summaryErrors = useMemo<FormErrorItem[]>(
() => () =>
@@ -108,6 +117,18 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
})), })),
[fieldErrors, messages], [fieldErrors, messages],
); );
const fleetOptions = useMemo<FleetOption[]>(() => {
if (pricingFleetOnly) {
return [{ value: '250+', label: messages.fleetOptions.at(-1) ?? '150 or more vehicles' }];
}
return fleetSizeValues
.map((value, index) => {
const label = messages.fleetOptions[index + 1];
return label ? { value, label } : null;
})
.filter((option): option is FleetOption => option !== null);
}, [messages.fleetOptions, pricingFleetOnly]);
useEffect(() => { useEffect(() => {
const handleOpen = (event: Event) => { const handleOpen = (event: Event) => {
@@ -156,8 +177,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
setOpen(true); setOpen(true);
} }
}} }}
title={messages.title} title={dialogTitle}
description={messages.body} description={dialogDescription}
closeLabel={messages.close} closeLabel={messages.close}
initialFocusRef={firstFieldRef} initialFocusRef={firstFieldRef}
returnFocusRef={returnFocusRef} returnFocusRef={returnFocusRef}
@@ -166,8 +187,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
{status === 'success' ? ( {status === 'success' ? (
<section className={styles.success} aria-live="polite"> <section className={styles.success} aria-live="polite">
<StatusBadge tone="success">{messages.localModeLabel}</StatusBadge> <StatusBadge tone="success">{messages.localModeLabel}</StatusBadge>
<h3>{messages.successTitle}</h3> <h3>{successTitle}</h3>
<p>{messages.successBody}</p> <p>{successBody}</p>
<div className={styles.actions}> <div className={styles.actions}>
<Button intent="primary" onClick={reset}> <Button intent="primary" onClick={reset}>
{messages.submitAnother} {messages.submitAnother}
@@ -270,8 +291,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
}} }}
> >
<div className={styles.notice}> <div className={styles.notice}>
<StatusBadge tone="info">{messages.localModeLabel}</StatusBadge> <StatusBadge tone="info">{noticeLabel}</StatusBadge>
<p>{messages.privacy}</p> <p>{noticeBody}</p>
</div> </div>
{formError ? ( {formError ? (
@@ -355,17 +376,18 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
} }
> >
<Select <Select
key={pricingFleetOnly ? 'pricing-fleet-size' : 'default-fleet-size'}
id="fleetSize" id="fleetSize"
name="fleetSize" name="fleetSize"
required required
defaultValue="" defaultValue={pricingFleetOnly ? '250+' : ''}
invalid={Boolean(fieldErrors.fleetSize)} invalid={Boolean(fieldErrors.fleetSize)}
describedBy={fieldErrors.fleetSize ? 'fleetSize-error' : undefined} describedBy={fieldErrors.fleetSize ? 'fleetSize-error' : undefined}
> >
<option value="">{messages.fleetOptions[0]}</option> {pricingFleetOnly ? null : <option value="">{messages.fleetOptions[0]}</option>}
{fleetSizeValues.map((value, index) => ( {fleetOptions.map((option) => (
<option key={value} value={value}> <option key={option.value} value={option.value}>
{messages.fleetOptions[index + 1]} {option.label}
</option> </option>
))} ))}
</Select> </Select>
@@ -428,7 +450,7 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
? messages.submitting ? messages.submitting
: status === 'error' : status === 'error'
? messages.retry ? messages.retry
: messages.submit} : submitLabel}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -139,6 +139,7 @@ export interface HomepageContent {
recommended: string; recommended: string;
startingAt: string; startingAt: string;
perMonth: string; perMonth: string;
perYear: string;
annualEquivalent: string; annualEquivalent: string;
pricePending: string; pricePending: string;
customPrice: string; customPrice: string;
@@ -404,6 +405,7 @@ export function buildHomepageContent(
recommended: homepage.pricing.recommended, recommended: homepage.pricing.recommended,
startingAt: homepage.pricing.startingAt, startingAt: homepage.pricing.startingAt,
perMonth: homepage.pricing.perMonth, perMonth: homepage.pricing.perMonth,
perYear: homepage.pricing.perYear,
annualEquivalent: homepage.pricing.annualEquivalent, annualEquivalent: homepage.pricing.annualEquivalent,
pricePending: homepage.pricing.pricePending, pricePending: homepage.pricing.pricePending,
customPrice: homepage.pricing.customPrice, customPrice: homepage.pricing.customPrice,
@@ -166,7 +166,8 @@
"recommended": "موصى بها", "recommended": "موصى بها",
"startingAt": "يبدأ من", "startingAt": "يبدأ من",
"perMonth": "/ شهريًا", "perMonth": "/ شهريًا",
"annualEquivalent": "ما يعادل شهريًا مع فوترة سنوية", "perYear": "/ سنويًا",
"annualEquivalent": "تُدفع سنويًا بعد خصم الدفع الكامل",
"pricePending": "السعر الابتدائي بانتظار الاعتماد", "pricePending": "السعر الابتدائي بانتظار الاعتماد",
"customPrice": "تسعير مخصص", "customPrice": "تسعير مخصص",
"plans": [ "plans": [
@@ -199,12 +200,28 @@
], ],
"cta": "احجز عرضًا لباقة Growth" "cta": "احجز عرضًا لباقة Growth"
}, },
{
"id": "pro",
"name": "Pro",
"audience": "للأساطيل القائمة التي تحتاج إلى تحكم متقدم عبر عمليات أكبر.",
"features": [
"كل ما تتضمنه Growth",
"حتى 150 مركبة نشطة",
"تقارير متقدمة",
"أدوار وصلاحيات الفريق",
"مسارات عمل ذات أولوية",
"وصول API",
"دعم مخصص"
],
"cta": "احجز عرضًا لباقة Pro"
},
{ {
"id": "enterprise", "id": "enterprise",
"name": "مؤسسات", "name": "مؤسسات",
"audience": "للأساطيل الكبيرة والامتيازات والعمليات ذات احتياجات التكامل المعقدة.", "audience": "للأساطيل الكبيرة والامتيازات والعمليات ذات احتياجات التكامل المعقدة.",
"features": [ "features": [
"كل ما تتضمنه Growth", "كل ما تتضمنه Pro",
"150+ مركبة نشطة",
"تكاملات مخصصة ووصول إلى API", "تكاملات مخصصة ووصول إلى API",
"أدوار وصلاحيات متقدمة", "أدوار وصلاحيات متقدمة",
"تخطيط ترحيل البيانات", "تخطيط ترحيل البيانات",
@@ -316,7 +333,9 @@
}, },
"form": { "form": {
"title": "أنشئ حسابًا للمنتج", "title": "أنشئ حسابًا للمنتج",
"quoteTitle": "اطلب عرض سعر Enterprise",
"body": "أخبرنا كيف يعمل نشاط تأجير السيارات لديك. الحقول المطلوبة مميزة.", "body": "أخبرنا كيف يعمل نشاط تأجير السيارات لديك. الحقول المطلوبة مميزة.",
"quoteBody": "شارك معلومات شركتك حتى نتمكن من إعداد سعر Enterprise مخصص.",
"name": "الاسم الكامل", "name": "الاسم الكامل",
"email": "البريد الإلكتروني للعمل", "email": "البريد الإلكتروني للعمل",
"company": "الشركة", "company": "الشركة",
@@ -325,7 +344,9 @@
"language": "اللغة المفضلة", "language": "اللغة المفضلة",
"message": "ما الموضوع الذي ينبغي أن نركز عليه؟", "message": "ما الموضوع الذي ينبغي أن نركز عليه؟",
"submit": "إنشاء حسابي", "submit": "إنشاء حسابي",
"quoteSubmit": "اطلب عرض سعر",
"privacy": "يتحقق وضع غير الإنتاج من سير العمل ثم يتخلص من الطلب. لا يُرسل أي عميل محتمل إلى نظام مبيعات. يظل نص الخصوصية والموافقة النهائي محظورًا إلى حين الاعتماد القانوني.", "privacy": "يتحقق وضع غير الإنتاج من سير العمل ثم يتخلص من الطلب. لا يُرسل أي عميل محتمل إلى نظام مبيعات. يظل نص الخصوصية والموافقة النهائي محظورًا إلى حين الاعتماد القانوني.",
"quotePrivacy": "شارك معلومات الشركة فقط. لا تُدرج معلومات شخصية حساسة أو معلومات دفع.",
"close": "إغلاق", "close": "إغلاق",
"required": "مطلوب", "required": "مطلوب",
"emailError": "أدخل عنوان بريد إلكتروني صالحًا للعمل.", "emailError": "أدخل عنوان بريد إلكتروني صالحًا للعمل.",
@@ -339,7 +360,9 @@
], ],
"langOptions": ["English", "Français", "العربية"], "langOptions": ["English", "Français", "العربية"],
"successTitle": "تم تسجيل إنشاء الحساب", "successTitle": "تم تسجيل إنشاء الحساب",
"quoteSuccessTitle": "تم تسجيل طلب عرض السعر",
"successBody": "اكتمل مسار الحجز في هذا النموذج الأولي. لم يُرسل أي طلب إلى نظام مبيعات فعلي.", "successBody": "اكتمل مسار الحجز في هذا النموذج الأولي. لم يُرسل أي طلب إلى نظام مبيعات فعلي.",
"quoteSuccessBody": "تم تسجيل طلب عرض سعر Enterprise الخاص بك.",
"submitAnother": "إرسال طلب آخر", "submitAnother": "إرسال طلب آخر",
"submissionError": "تعذر على النموذج الأولي إكمال الطلب. ما زالت بياناتك متاحة. حاول مرة أخرى.", "submissionError": "تعذر على النموذج الأولي إكمال الطلب. ما زالت بياناتك متاحة. حاول مرة أخرى.",
"errorSummary": "راجع الحقول المميزة ثم حاول مرة أخرى.", "errorSummary": "راجع الحقول المميزة ثم حاول مرة أخرى.",
@@ -357,6 +380,7 @@
"rateLimitError": "تم استلام عدد كبير جدًا من الطلبات. حاول مرة أخرى لاحقًا.", "rateLimitError": "تم استلام عدد كبير جدًا من الطلبات. حاول مرة أخرى لاحقًا.",
"genericError": "تعذر إكمال الطلب. لا تزال بياناتك متاحة.", "genericError": "تعذر إكمال الطلب. لا تزال بياناتك متاحة.",
"localModeLabel": "موصل محلي آمن", "localModeLabel": "موصل محلي آمن",
"quoteModeLabel": "عرض سعر Enterprise",
"marketDefault": "المغرب" "marketDefault": "المغرب"
}, },
"preview": { "preview": {
@@ -178,7 +178,8 @@
"recommended": "Recommended", "recommended": "Recommended",
"startingAt": "Starting at", "startingAt": "Starting at",
"perMonth": "/ month", "perMonth": "/ month",
"annualEquivalent": "monthly equivalent, billed annually", "perYear": "/ year",
"annualEquivalent": "billed annually after full-payment discount",
"pricePending": "Starting price pending approval", "pricePending": "Starting price pending approval",
"customPrice": "Custom pricing", "customPrice": "Custom pricing",
"plans": [ "plans": [
@@ -211,12 +212,28 @@
], ],
"cta": "Book a Growth demo" "cta": "Book a Growth demo"
}, },
{
"id": "pro",
"name": "Pro",
"audience": "For established fleets that need advanced controls across larger operations.",
"features": [
"Everything in Growth",
"Up to 150 active vehicles",
"Advanced reports",
"Team roles and permissions",
"Priority workflows",
"API access",
"Dedicated support"
],
"cta": "Book a Pro demo"
},
{ {
"id": "enterprise", "id": "enterprise",
"name": "Enterprise", "name": "Enterprise",
"audience": "For large fleets, franchises, and operations with complex integration needs.", "audience": "For large fleets, franchises, and operations with complex integration needs.",
"features": [ "features": [
"Everything in Growth", "Everything in Pro",
"150+ active vehicles",
"Custom integrations and API access", "Custom integrations and API access",
"Advanced roles and permissions", "Advanced roles and permissions",
"Data migration planning", "Data migration planning",
@@ -328,7 +345,9 @@
}, },
"form": { "form": {
"title": "Create account", "title": "Create account",
"quoteTitle": "Request Enterprise quote",
"body": "Tell us how your rental operation works. Required fields are marked.", "body": "Tell us how your rental operation works. Required fields are marked.",
"quoteBody": "Share your company details so we can prepare customized Enterprise pricing.",
"name": "Full name", "name": "Full name",
"email": "Work email", "email": "Work email",
"company": "Company", "company": "Company",
@@ -338,7 +357,9 @@
"language": "Preferred language", "language": "Preferred language",
"message": "What should we focus on?", "message": "What should we focus on?",
"submit": "Create my account", "submit": "Create my account",
"quoteSubmit": "Request quote",
"privacy": "Non-production mode validates the workflow and discards the request. No lead is sent to a sales system. Final privacy and consent text remains blocked pending legal approval.", "privacy": "Non-production mode validates the workflow and discards the request. No lead is sent to a sales system. Final privacy and consent text remains blocked pending legal approval.",
"quotePrivacy": "Share company information only. Do not include sensitive personal or payment information.",
"close": "Close", "close": "Close",
"required": "Required", "required": "Required",
"emailError": "Enter a valid work email address.", "emailError": "Enter a valid work email address.",
@@ -352,7 +373,9 @@
], ],
"langOptions": ["English", "Français", "العربية"], "langOptions": ["English", "Français", "العربية"],
"successTitle": "Account creation recorded", "successTitle": "Account creation recorded",
"quoteSuccessTitle": "Quote request recorded",
"successBody": "This prototype has completed the booking flow. No request was sent to a real sales system.", "successBody": "This prototype has completed the booking flow. No request was sent to a real sales system.",
"quoteSuccessBody": "Your Enterprise quote request has been recorded.",
"submitAnother": "Submit another request", "submitAnother": "Submit another request",
"submissionError": "The prototype could not complete the request. Your entries are still available. Try again.", "submissionError": "The prototype could not complete the request. Your entries are still available. Try again.",
"errorSummary": "Review the highlighted fields and try again.", "errorSummary": "Review the highlighted fields and try again.",
@@ -369,7 +392,8 @@
"duplicateError": "This request has already been processed. No second lead was created.", "duplicateError": "This request has already been processed. No second lead was created.",
"rateLimitError": "Too many requests were received. Try again later.", "rateLimitError": "Too many requests were received. Try again later.",
"genericError": "The request could not be completed. Your entries are still available.", "genericError": "The request could not be completed. Your entries are still available.",
"localModeLabel": "Safe local adapter" "localModeLabel": "Safe local adapter",
"quoteModeLabel": "Enterprise quote"
}, },
"preview": { "preview": {
"today": "Today", "today": "Today",
@@ -197,7 +197,8 @@
"recommended": "Recommandé", "recommended": "Recommandé",
"startingAt": "À partir de", "startingAt": "À partir de",
"perMonth": "/ mois", "perMonth": "/ mois",
"annualEquivalent": "équivalent mensuel, facturé annuellement", "perYear": "/ an",
"annualEquivalent": "facturé annuellement après remise pour paiement intégral",
"pricePending": "Prix de départ en attente de validation", "pricePending": "Prix de départ en attente de validation",
"customPrice": "Tarification personnalisée", "customPrice": "Tarification personnalisée",
"plans": [ "plans": [
@@ -230,12 +231,28 @@
], ],
"cta": "Réserver une démo Growth" "cta": "Réserver une démo Growth"
}, },
{
"id": "pro",
"name": "Pro",
"audience": "Pour les parcs établis qui ont besoin de contrôles avancés sur une exploitation plus large.",
"features": [
"Tout ce qui est inclus dans Growth",
"Jusqu’à 150 véhicules actifs",
"Rapports avancés",
"Rôles et autorisations d’équipe",
"Flux prioritaires",
"Accès API",
"Support dédié"
],
"cta": "Réserver une démo Pro"
},
{ {
"id": "enterprise", "id": "enterprise",
"name": "Entreprise", "name": "Entreprise",
"audience": "Pour les grands parcs, franchises et exploitations ayant des besoins dintégration complexes.", "audience": "Pour les grands parcs, franchises et exploitations ayant des besoins dintégration complexes.",
"features": [ "features": [
"Tout ce qui est inclus dans Growth", "Tout ce qui est inclus dans Pro",
"150+ véhicules actifs",
"Intégrations personnalisées et accès API", "Intégrations personnalisées et accès API",
"Rôles et autorisations avancés", "Rôles et autorisations avancés",
"Planification de la migration des données", "Planification de la migration des données",
@@ -347,7 +364,9 @@
}, },
"form": { "form": {
"title": "Créer un compte du produit", "title": "Créer un compte du produit",
"quoteTitle": "Demander un devis Enterprise",
"body": "Décrivez-nous le fonctionnement de votre activité de location. Les champs obligatoires sont signalés.", "body": "Décrivez-nous le fonctionnement de votre activité de location. Les champs obligatoires sont signalés.",
"quoteBody": "Partagez les informations de votre entreprise afin que nous préparions un tarif Enterprise personnalisé.",
"name": "Nom complet", "name": "Nom complet",
"email": "Adresse e-mail professionnelle", "email": "Adresse e-mail professionnelle",
"company": "Entreprise", "company": "Entreprise",
@@ -356,7 +375,9 @@
"language": "Langue souhaitée", "language": "Langue souhaitée",
"message": "Sur quoi devons-nous nous concentrer ?", "message": "Sur quoi devons-nous nous concentrer ?",
"submit": "Créer mon compte", "submit": "Créer mon compte",
"quoteSubmit": "Demander un devis",
"privacy": "Le mode hors production valide le parcours puis supprime la demande. Aucun prospect nest envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans lattente de lapprobation juridique.", "privacy": "Le mode hors production valide le parcours puis supprime la demande. Aucun prospect nest envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans lattente de lapprobation juridique.",
"quotePrivacy": "Partagez uniquement les informations de lentreprise. Nincluez aucune donnée personnelle sensible ni information de paiement.",
"close": "Fermer", "close": "Fermer",
"required": "Obligatoire", "required": "Obligatoire",
"emailError": "Saisissez une adresse e-mail professionnelle valide.", "emailError": "Saisissez une adresse e-mail professionnelle valide.",
@@ -370,7 +391,9 @@
], ],
"langOptions": ["English", "Français", "العربية"], "langOptions": ["English", "Français", "العربية"],
"successTitle": "Création de compte enregistrée", "successTitle": "Création de compte enregistrée",
"quoteSuccessTitle": "Demande de devis enregistrée",
"successBody": "Ce prototype a terminé le parcours de réservation. Aucune demande na été envoyée à un véritable système commercial.", "successBody": "Ce prototype a terminé le parcours de réservation. Aucune demande na été envoyée à un véritable système commercial.",
"quoteSuccessBody": "Votre demande de devis Enterprise a été enregistrée.",
"submitAnother": "Envoyer une autre demande", "submitAnother": "Envoyer une autre demande",
"submissionError": "Le prototype na pas pu terminer la demande. Vos informations sont conservées. Réessayez.", "submissionError": "Le prototype na pas pu terminer la demande. Vos informations sont conservées. Réessayez.",
"errorSummary": "Vérifiez les champs signalés, puis réessayez.", "errorSummary": "Vérifiez les champs signalés, puis réessayez.",
@@ -388,6 +411,7 @@
"rateLimitError": "Trop de demandes ont été reçues. Réessayez plus tard.", "rateLimitError": "Trop de demandes ont été reçues. Réessayez plus tard.",
"genericError": "La demande na pas pu être terminée. Vos informations sont toujours disponibles.", "genericError": "La demande na pas pu être terminée. Vos informations sont toujours disponibles.",
"localModeLabel": "Adaptateur local sécurisé", "localModeLabel": "Adaptateur local sécurisé",
"quoteModeLabel": "Devis Enterprise",
"marketDefault": "Maroc" "marketDefault": "Maroc"
}, },
"preview": { "preview": {
+23 -18
View File
@@ -1,7 +1,7 @@
export const fleetBandIds = ['small', 'growing', 'scale', 'enterprise'] as const; export const fleetBandIds = ['small', 'growing', 'scale', 'enterprise'] as const;
export type FleetBandId = (typeof fleetBandIds)[number]; export type FleetBandId = (typeof fleetBandIds)[number];
export const pricingPlanIds = ['launch', 'growth', 'enterprise'] as const; export const pricingPlanIds = ['launch', 'growth', 'pro', 'enterprise'] as const;
export type PricingPlanId = (typeof pricingPlanIds)[number]; export type PricingPlanId = (typeof pricingPlanIds)[number];
export type BillingPeriod = 'monthly' | 'annual'; export type BillingPeriod = 'monthly' | 'annual';
@@ -16,37 +16,42 @@ export interface PricingCommercialConfig {
/** /**
* This is the only file that should contain commercial pricing numbers. * This is the only file that should contain commercial pricing numbers.
* Keep publicPricesApproved=false until finance and product approve the amounts, * Public prices apply only to non-custom plans. Enterprise remains custom.
* included limits, onboarding policy, taxes, and annual billing terms.
*/ */
export const pricingCommercialConfig: PricingCommercialConfig = { export const pricingCommercialConfig: PricingCommercialConfig = {
currency: 'MAD', currency: 'MAD',
annualDiscountPercent: null, annualDiscountPercent: 20,
publicPricesApproved: false, publicPricesApproved: true,
monthlyPrices: { monthlyPrices: {
launch: { launch: {
small: 50, small: 149,
growing: 50, growing: 149,
scale: 50, scale: 149,
enterprise: 50, enterprise: 149,
}, },
growth: { growth: {
small: 200, small: 299,
growing: 200, growing: 299,
scale: 200, scale: 299,
enterprise: 200, enterprise: 299,
},
pro: {
small: 399,
growing: 399,
scale: 399,
enterprise: 399,
}, },
enterprise: { enterprise: {
small: 500, small: null,
growing: 500, growing: null,
scale: 500, scale: null,
enterprise: 500, enterprise: null,
}, },
}, },
recommendedPlanByFleetBand: { recommendedPlanByFleetBand: {
small: 'launch', small: 'launch',
growing: 'growth', growing: 'growth',
scale: 'growth', scale: 'pro',
enterprise: 'enterprise', enterprise: 'enterprise',
}, },
}; };
+8 -1
View File
@@ -1,8 +1,15 @@
import type { Locale } from '@/lib/localization/config'; import type { Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config'; import type { ThemePreference } from '@/lib/theme/config';
export function accountCreateUrl(locale: Locale, theme?: ThemePreference): string { export type AccountSubscriptionPlan = 'STARTER' | 'GROWTH' | 'PRO';
export function accountCreateUrl(
locale: Locale,
theme?: ThemePreference,
subscriptionPlan?: AccountSubscriptionPlan,
): string {
const params = new URLSearchParams({ lang: locale }); const params = new URLSearchParams({ lang: locale });
if (theme === 'light' || theme === 'dark') params.set('theme', theme); if (theme === 'light' || theme === 'dark') params.set('theme', theme);
if (subscriptionPlan) params.set('plan', subscriptionPlan);
return `/dashboard/sign-up?${params.toString()}`; return `/dashboard/sign-up?${params.toString()}`;
} }
+1 -1
View File
@@ -45,7 +45,7 @@ export const analyticsContextSchema = z
reason: safeText.optional(), reason: safeText.optional(),
fleetBand: z.enum(['small', 'growing', 'scale', 'enterprise']).optional(), fleetBand: z.enum(['small', 'growing', 'scale', 'enterprise']).optional(),
billingPeriod: z.enum(['monthly', 'annual']).optional(), billingPeriod: z.enum(['monthly', 'annual']).optional(),
plan: z.enum(['launch', 'growth', 'enterprise']).optional(), plan: z.enum(['launch', 'growth', 'pro', 'enterprise']).optional(),
}) })
.strict(); .strict();
@@ -1,10 +1,15 @@
export const themeBootstrapScript = `(function () { export const themeBootstrapScript = `(function () {
var allowed = { light: true, dark: true, system: true }; var allowed = { light: true, dark: true, system: true };
var allowedResolved = { light: true, dark: true };
var cookieMatch = document.cookie.match(/(?:^|; )hpc-theme=([^;]+)/); var cookieMatch = document.cookie.match(/(?:^|; )hpc-theme=([^;]+)/);
var cookieValue = cookieMatch ? decodeURIComponent(cookieMatch[1]) : null; var cookieValue = cookieMatch ? decodeURIComponent(cookieMatch[1]) : null;
var sharedCookieMatch = document.cookie.match(/(?:^|; )rentaldrivego-theme=([^;]+)/);
var sharedCookieValue = sharedCookieMatch ? decodeURIComponent(sharedCookieMatch[1]) : null;
var stored = null; var stored = null;
var sharedStored = null;
try { stored = localStorage.getItem("hpc.theme.preference"); } catch (_) {} try { stored = localStorage.getItem("hpc.theme.preference"); } catch (_) {}
var preference = allowed[cookieValue] ? cookieValue : (allowed[stored] ? stored : "dark"); try { sharedStored = localStorage.getItem("rentaldrivego-theme"); } catch (_) {}
var preference = allowed[cookieValue] ? cookieValue : (allowed[stored] ? stored : (allowedResolved[sharedCookieValue] ? sharedCookieValue : (allowedResolved[sharedStored] ? sharedStored : "dark")));
var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; var dark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
var resolved = preference === "system" ? (dark ? "dark" : "light") : preference; var resolved = preference === "system" ? (dark ? "dark" : "light") : preference;
var root = document.documentElement; var root = document.documentElement;
+9 -7
View File
@@ -1,6 +1,8 @@
import { import {
isThemePreference, isThemePreference,
resolveTheme, resolveTheme,
sharedThemeCookie,
sharedThemeStorageKey,
themeCookie, themeCookie,
themeCookieMaxAgeSeconds, themeCookieMaxAgeSeconds,
themeStorageKey, themeStorageKey,
@@ -18,20 +20,20 @@ export function applyTheme(preference: ThemePreference): void {
} }
export function persistTheme(preference: ThemePreference): void { export function persistTheme(preference: ThemePreference): void {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const resolved = resolveTheme(preference, systemDark);
try { try {
window.localStorage.setItem(themeStorageKey, preference); window.localStorage.setItem(themeStorageKey, preference);
window.localStorage.setItem(sharedThemeStorageKey, resolved);
} catch { } catch {
// Storage can be unavailable; the first-party cookie remains canonical. // Storage can be unavailable; the first-party cookie remains canonical.
} }
const secure = window.location.protocol === 'https:' ? '; Secure' : ''; const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = const cookieAttributes = ['Path=/', `Max-Age=${themeCookieMaxAgeSeconds}`, 'SameSite=Lax'].join('; ') + secure;
[ document.cookie = `${themeCookie}=${encodeURIComponent(preference)}; ${cookieAttributes}`;
`${themeCookie}=${encodeURIComponent(preference)}`, document.cookie = `${sharedThemeCookie}=${encodeURIComponent(resolved)}; ${cookieAttributes}`;
'Path=/',
`Max-Age=${themeCookieMaxAgeSeconds}`,
'SameSite=Lax',
].join('; ') + secure;
applyTheme(preference); applyTheme(preference);
window.dispatchEvent( window.dispatchEvent(
new CustomEvent<ThemePreference>(themePreferenceEvent, { detail: preference }), new CustomEvent<ThemePreference>(themePreferenceEvent, { detail: preference }),
+2
View File
@@ -1,5 +1,7 @@
export const themeCookie = 'hpc-theme'; export const themeCookie = 'hpc-theme';
export const themeStorageKey = 'hpc.theme.preference'; export const themeStorageKey = 'hpc.theme.preference';
export const sharedThemeCookie = 'rentaldrivego-theme';
export const sharedThemeStorageKey = 'rentaldrivego-theme';
export const themePreferences = ['light', 'dark', 'system'] as const; export const themePreferences = ['light', 'dark', 'system'] as const;
export type ThemePreference = (typeof themePreferences)[number]; export type ThemePreference = (typeof themePreferences)[number];
export type ResolvedTheme = 'light' | 'dark'; export type ResolvedTheme = 'light' | 'dark';
+11 -1
View File
@@ -13,6 +13,7 @@ import {
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp'; import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
import { import {
isThemePreference, isThemePreference,
sharedThemeCookie,
themeCookie, themeCookie,
themeCookieMaxAgeSeconds, themeCookieMaxAgeSeconds,
} from '@/lib/theme/config'; } from '@/lib/theme/config';
@@ -55,7 +56,9 @@ function resolveRequestLocale(request: NextRequest) {
function resolveRequestMode(request: NextRequest) { function resolveRequestMode(request: NextRequest) {
const cookieMode = request.cookies.get(themeCookie)?.value; const cookieMode = request.cookies.get(themeCookie)?.value;
return isThemePreference(cookieMode) ? cookieMode : defaultMode; const sharedCookieMode = request.cookies.get(sharedThemeCookie)?.value;
if (isThemePreference(cookieMode)) return cookieMode;
return isThemePreference(sharedCookieMode) ? sharedCookieMode : defaultMode;
} }
function setLocaleCookie(response: NextResponse, locale: string): void { function setLocaleCookie(response: NextResponse, locale: string): void {
@@ -76,6 +79,13 @@ function setModeCookie(response: NextResponse, mode: string): void {
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
httpOnly: false, httpOnly: false,
}); });
response.cookies.set(sharedThemeCookie, mode === 'light' ? 'light' : 'dark', {
path: '/',
sameSite: 'lax',
maxAge: themeCookieMaxAgeSeconds,
secure: process.env.NODE_ENV === 'production',
httpOnly: false,
});
} }
export function proxy(request: NextRequest): NextResponse { export function proxy(request: NextRequest): NextResponse {
@@ -62,6 +62,11 @@ test('pricing recommends a plan from the selected fleet band', async ({ page })
'data-recommended', 'data-recommended',
'true', 'true',
); );
await page.getByLabel('76150 vehicles').check();
await expect(page.locator('#pricing [data-plan="pro"]')).toHaveAttribute(
'data-recommended',
'true',
);
await page.getByLabel('Annual', { exact: true }).check(); await page.getByLabel('Annual', { exact: true }).check();
await expect(page.getByLabel('Annual', { exact: true })).toBeChecked(); await expect(page.getByLabel('Annual', { exact: true })).toBeChecked();
}); });
@@ -22,7 +22,7 @@ describe('homepage content model', () => {
expect(content.modules.items).toHaveLength(6); expect(content.modules.items).toHaveLength(6);
expect(content.faq.items).toHaveLength(6); expect(content.faq.items).toHaveLength(6);
expect(content.pricing.fleetBands).toHaveLength(4); expect(content.pricing.fleetBands).toHaveLength(4);
expect(content.pricing.plans).toHaveLength(3); expect(content.pricing.plans).toHaveLength(4);
expect(content.hero.preview.rows).toHaveLength(2); expect(content.hero.preview.rows).toHaveLength(2);
}); });
@@ -40,6 +40,9 @@ describe('homepage content model', () => {
it('includes explicit theme handoff in account creation URLs', () => { it('includes explicit theme handoff in account creation URLs', () => {
expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light'); expect(accountCreateUrl('en', 'light')).toBe('/dashboard/sign-up?lang=en&theme=light');
expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark'); expect(accountCreateUrl('en', 'dark')).toBe('/dashboard/sign-up?lang=en&theme=dark');
expect(accountCreateUrl('en', 'dark', 'GROWTH')).toBe(
'/dashboard/sign-up?lang=en&theme=dark&plan=GROWTH',
);
}); });
it('keeps preview identifiers isolated from translated prose', () => { it('keeps preview identifiers isolated from translated prose', () => {
@@ -0,0 +1,62 @@
import { DemoDialogHost } from '@/components/integrations/DemoDialogHost';
import { demoOpenEventName } from '@/components/integrations/DemoTrigger';
import enHomepage from '@/content/locales/en/homepage.json';
import { act, render, screen } from '@testing-library/react';
import { beforeAll, describe, expect, it } from 'vitest';
function openDemoForm(source: 'hero' | 'pricing') {
const trigger = document.createElement('button');
document.body.appendChild(trigger);
act(() => {
window.dispatchEvent(
new CustomEvent(demoOpenEventName, {
detail: { source, trigger },
}),
);
});
}
describe('DemoDialogHost', () => {
beforeAll(() => {
HTMLDialogElement.prototype.showModal ??= function showModal() {
this.setAttribute('open', '');
};
HTMLDialogElement.prototype.close ??= function close() {
this.removeAttribute('open');
};
});
it('keeps the full fleet selector for regular demo requests', () => {
render(<DemoDialogHost locale="en" messages={enHomepage.form} enabled />);
openDemoForm('hero');
const fleet = screen.getByLabelText(/Approximate fleet size/) as HTMLSelectElement;
expect(Array.from(fleet.options).map((option) => option.textContent)).toEqual([
'Select an option',
'125 vehicles',
'2675 vehicles',
'76150 vehicles',
'150 or more vehicles',
]);
});
it('limits the pricing request fleet selector to 150 or more vehicles', () => {
render(<DemoDialogHost locale="en" messages={enHomepage.form} enabled />);
openDemoForm('pricing');
const fleet = screen.getByLabelText(/Approximate fleet size/) as HTMLSelectElement;
expect(Array.from(fleet.options).map((option) => option.textContent)).toEqual([
'150 or more vehicles',
]);
expect(fleet.value).toBe('250+');
expect(screen.getByRole('button', { name: 'Request quote' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Create my account' })).not.toBeInTheDocument();
expect(screen.getByRole('dialog', { name: 'Request Enterprise quote' })).toBeVisible();
expect(screen.getByText('Enterprise quote')).toBeInTheDocument();
expect(screen.getByText(/Share company information only/)).toBeInTheDocument();
expect(screen.queryByText('Safe local adapter')).not.toBeInTheDocument();
expect(screen.queryByText(/Non-production mode validates/)).not.toBeInTheDocument();
});
});
@@ -15,7 +15,13 @@ describe('PricingSection', () => {
const user = userEvent.setup(); const user = userEvent.setup();
const content = buildHomepageContent(enHomepage, enShell).pricing; const content = buildHomepageContent(enHomepage, enShell).pricing;
const { container } = render( const { container } = render(
<PricingSection content={content} locale="en" homePath="/en/system" demoSubmissionEnabled />, <PricingSection
content={content}
locale="en"
themePreference="light"
homePath="/en/system"
demoSubmissionEnabled
/>,
); );
expect(container.querySelector('[data-plan="launch"]')).toHaveAttribute( expect(container.querySelector('[data-plan="launch"]')).toHaveAttribute(
@@ -30,15 +36,71 @@ describe('PricingSection', () => {
'true', 'true',
); );
expect(container.querySelector('[data-plan="launch"]')).not.toHaveAttribute('data-recommended'); expect(container.querySelector('[data-plan="launch"]')).not.toHaveAttribute('data-recommended');
await user.click(screen.getByLabelText('76150 vehicles'));
expect(container.querySelector('[data-plan="pro"]')).toHaveAttribute(
'data-recommended',
'true',
);
expect(container.querySelector('[data-plan="growth"]')).not.toHaveAttribute('data-recommended');
}); });
it('keeps unapproved public prices out of the rendered section', () => { it('renders approved public prices except for Enterprise custom pricing', async () => {
const user = userEvent.setup();
const content = buildHomepageContent(enHomepage, enShell).pricing; const content = buildHomepageContent(enHomepage, enShell).pricing;
render( render(
<PricingSection content={content} locale="en" homePath="/en/system" demoSubmissionEnabled />, <PricingSection
content={content}
locale="en"
themePreference="light"
homePath="/en/system"
demoSubmissionEnabled
/>,
); );
expect(screen.getAllByText('Starting price pending approval')).toHaveLength(2); expect(screen.queryByText('Starting price pending approval')).not.toBeInTheDocument();
expect(screen.getByText(/149/)).toBeInTheDocument();
expect(screen.getByText(/299/)).toBeInTheDocument();
expect(screen.getByText(/399/)).toBeInTheDocument();
expect(screen.getByText('Custom pricing')).toBeInTheDocument(); expect(screen.getByText('Custom pricing')).toBeInTheDocument();
await user.click(screen.getByLabelText(/Annual/));
expect(screen.getByText(/Save 20%/)).toBeInTheDocument();
expect(screen.getByText(/1,430/)).toBeInTheDocument();
expect(screen.getAllByText('/ year')).toHaveLength(3);
expect(screen.getAllByText('billed annually after full-payment discount')).toHaveLength(3);
});
it('links self-serve plan CTAs to account creation and opens a pricing form for Enterprise', () => {
const content = buildHomepageContent(enHomepage, enShell).pricing;
render(
<PricingSection
content={content}
locale="en"
themePreference="dark"
homePath="/en/system"
demoSubmissionEnabled
/>,
);
expect(screen.getByRole('link', { name: 'Estimate Launch pricing' })).toHaveAttribute(
'href',
'/dashboard/sign-up?lang=en&theme=dark&plan=STARTER',
);
expect(screen.getByRole('link', { name: 'Book a Growth demo' })).toHaveAttribute(
'href',
'/dashboard/sign-up?lang=en&theme=dark&plan=GROWTH',
);
expect(screen.getByRole('link', { name: 'Book a Pro demo' })).toHaveAttribute(
'href',
'/dashboard/sign-up?lang=en&theme=dark&plan=PRO',
);
expect(screen.getByRole('button', { name: 'Talk to sales' })).toHaveAttribute(
'data-demo-source',
'pricing',
);
expect(screen.queryByRole('link', { name: 'Talk to sales' })).not.toBeInTheDocument();
}); });
}); });
+7 -3
View File
@@ -6,9 +6,13 @@ import { describe, expect, it } from 'vitest';
describe('homepage pricing configuration', () => { describe('homepage pricing configuration', () => {
it('keeps commercial values in one guarded configuration', () => { it('keeps commercial values in one guarded configuration', () => {
expect(pricingCommercialConfig.publicPricesApproved).toBe(false); expect(pricingCommercialConfig.publicPricesApproved).toBe(true);
expect(pricingCommercialConfig.currency).toBe('MAD'); expect(pricingCommercialConfig.currency).toBe('MAD');
expect(pricingCommercialConfig.annualDiscountPercent).toBeNull(); expect(pricingCommercialConfig.annualDiscountPercent).toBe(20);
expect(pricingCommercialConfig.monthlyPrices.launch.small).toBe(149);
expect(pricingCommercialConfig.monthlyPrices.growth.growing).toBe(299);
expect(pricingCommercialConfig.monthlyPrices.pro.scale).toBe(399);
expect(pricingCommercialConfig.monthlyPrices.enterprise.enterprise).toBeNull();
}); });
it('maps every fleet band and plan from localized content', () => { it('maps every fleet band and plan from localized content', () => {
@@ -20,6 +24,6 @@ describe('homepage pricing configuration', () => {
'scale', 'scale',
'enterprise', 'enterprise',
]); ]);
expect(pricing.plans.map((plan) => plan.id)).toEqual(['launch', 'growth', 'enterprise']); expect(pricing.plans.map((plan) => plan.id)).toEqual(['launch', 'growth', 'pro', 'enterprise']);
}); });
}); });
+46 -2
View File
@@ -1,6 +1,28 @@
import { currentThemePreference } from '@/lib/theme/client'; import { currentThemePreference, persistTheme } from '@/lib/theme/client';
import { isThemePreference, resolveTheme, serverResolvedTheme } from '@/lib/theme/config'; import { isThemePreference, resolveTheme, serverResolvedTheme } from '@/lib/theme/config';
import { describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let storage: Map<string, string>;
beforeEach(() => {
storage = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
clear: () => storage.clear(),
getItem: (key: string) => storage.get(key) ?? null,
removeItem: (key: string) => storage.delete(key),
setItem: (key: string, value: string) => storage.set(key, value),
},
});
});
afterEach(() => {
storage.clear();
document.cookie = 'hpc-theme=; Path=/; Max-Age=0';
document.cookie = 'rentaldrivego-theme=; Path=/; Max-Age=0';
vi.restoreAllMocks();
});
describe('theme foundations', () => { describe('theme foundations', () => {
it('accepts only approved preferences', () => { it('accepts only approved preferences', () => {
@@ -21,4 +43,26 @@ describe('theme foundations', () => {
delete document.documentElement.dataset.themePreference; delete document.documentElement.dataset.themePreference;
expect(currentThemePreference()).toBe('dark'); expect(currentThemePreference()).toBe('dark');
}); });
it('persists the shared dashboard theme when homepage theme changes', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false } as MediaQueryList);
persistTheme('dark');
expect(window.localStorage.getItem('hpc.theme.preference')).toBe('dark');
expect(window.localStorage.getItem('rentaldrivego-theme')).toBe('dark');
expect(document.cookie).toContain('hpc-theme=dark');
expect(document.cookie).toContain('rentaldrivego-theme=dark');
});
it('stores the resolved shared theme for system preference', () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false } as MediaQueryList);
persistTheme('system');
expect(window.localStorage.getItem('hpc.theme.preference')).toBe('system');
expect(window.localStorage.getItem('rentaldrivego-theme')).toBe('light');
expect(document.cookie).toContain('hpc-theme=system');
expect(document.cookie).toContain('rentaldrivego-theme=light');
});
}); });
@@ -0,0 +1 @@
ALTER TYPE "Plan" ADD VALUE IF NOT EXISTS 'ENTERPRISE';
@@ -0,0 +1,6 @@
-- Superseded by 20260803000000_add_enterprise_plan_enum and
-- 20260803001000_update_vehicle_plan_limits.
-- Kept as a no-op because this migration directory was created before the
-- enum/data migration was split, and Prisma requires every migration directory
-- to contain a migration.sql file.
SELECT 1;
@@ -0,0 +1,50 @@
UPDATE "plan_features"
SET "label" = 'Up to 25 vehicles', "updatedAt" = CURRENT_TIMESTAMP
WHERE "plan" = 'STARTER' AND "label" = 'Up to 10 vehicles';
UPDATE "plan_features"
SET "label" = 'Up to 75 vehicles', "updatedAt" = CURRENT_TIMESTAMP
WHERE "plan" = 'GROWTH' AND "label" = 'Up to 50 vehicles';
UPDATE "plan_features"
SET "label" = 'Up to 150 vehicles', "updatedAt" = CURRENT_TIMESTAMP
WHERE "plan" = 'PRO' AND "label" = 'Unlimited vehicles';
INSERT INTO "pricing_configs" ("id", "plan", "billingPeriod", "amount", "updatedAt")
VALUES
('prc_enterprise_monthly', 'ENTERPRISE', 'MONTHLY', 59900, NOW()),
('prc_enterprise_annual', 'ENTERPRISE', 'ANNUAL', 575040, NOW())
ON CONFLICT ("plan", "billingPeriod") DO UPDATE
SET
"amount" = EXCLUDED."amount",
"updatedAt" = EXCLUDED."updatedAt";
INSERT INTO "plan_features" ("id", "plan", "label", "sortOrder")
VALUES
('plf_enterprise_1', 'ENTERPRISE', '150+ vehicles', 10),
('plf_enterprise_2', 'ENTERPRISE', 'Unlimited user accounts', 20),
('plf_enterprise_3', 'ENTERPRISE', 'Advanced reports', 30),
('plf_enterprise_4', 'ENTERPRISE', 'API access', 40),
('plf_enterprise_5', 'ENTERPRISE', 'Dedicated support', 50)
ON CONFLICT ("id") DO UPDATE
SET
"label" = EXCLUDED."label",
"sortOrder" = EXCLUDED."sortOrder",
"updatedAt" = CURRENT_TIMESTAMP;
INSERT INTO "subscription_menu_items" ("id", "plan", "menuItemId", "displayOrder", "isActive", "createdAt", "updatedAt")
SELECT
'smi_enterprise_' || "menuItemId",
'ENTERPRISE'::"Plan",
"menuItemId",
"displayOrder",
"isActive",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM "subscription_menu_items"
WHERE "plan" = 'PRO'
ON CONFLICT ("plan", "menuItemId") DO UPDATE
SET
"displayOrder" = EXCLUDED."displayOrder",
"isActive" = EXCLUDED."isActive",
"updatedAt" = CURRENT_TIMESTAMP;
+1
View File
@@ -25,6 +25,7 @@ enum Plan {
STARTER STARTER
GROWTH GROWTH
PRO PRO
ENTERPRISE
} }
enum BillingPeriod { enum BillingPeriod {
+15 -3
View File
@@ -33,25 +33,36 @@ export const PLAN_PRICES: Record<string, Record<string, Record<string, number>>>
MONTHLY: { MAD: 39900 }, MONTHLY: { MAD: 39900 },
ANNUAL: { MAD: 383040 }, ANNUAL: { MAD: 383040 },
}, },
ENTERPRISE: {
MONTHLY: { MAD: 59900 },
ANNUAL: { MAD: 575040 },
},
} }
export const PLAN_FEATURES: Record<string, string[]> = { export const PLAN_FEATURES: Record<string, string[]> = {
STARTER: [ STARTER: [
'Up to 10 vehicles', 'Up to 25 vehicles',
'1 user account', '1 user account',
'Basic analytics', 'Basic analytics',
'Carplace listing', 'Carplace listing',
'Notification management', 'Notification management',
], ],
GROWTH: [ GROWTH: [
'Up to 50 vehicles', 'Up to 75 vehicles',
'5 user accounts', '5 user accounts',
'Full analytics', 'Full analytics',
'Priority Carplace placement', 'Priority Carplace placement',
'Custom branding', 'Custom branding',
], ],
PRO: [ PRO: [
'Unlimited vehicles', 'Up to 150 vehicles',
'Unlimited user accounts',
'Advanced reports',
'API access',
'Dedicated support',
],
ENTERPRISE: [
'150+ vehicles',
'Unlimited user accounts', 'Unlimited user accounts',
'Advanced reports', 'Advanced reports',
'API access', 'API access',
@@ -70,6 +81,7 @@ export const PLAN_CAPABILITIES: Record<string, SubscriptionCapability[]> = {
STARTER: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT], STARTER: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
GROWTH: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT], GROWTH: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
PRO: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT], PRO: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
ENTERPRISE: [SUBSCRIPTION_CAPABILITIES.NOTIFICATION_MANAGEMENT],
} }
export type Locale = 'en' | 'fr' | 'ar' export type Locale = 'en' | 'fr' | 'ar'