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
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:
BIN
Binary file not shown.
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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: "S’abonner à {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,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()
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,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 d’intégration complexes.",
|
"audience": "Pour les grands parcs, franchises et exploitations ayant des besoins d’inté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 n’est envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans l’attente de l’approbation juridique.",
|
"privacy": "Le mode hors production valide le parcours puis supprime la demande. Aucun prospect n’est envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans l’attente de l’approbation juridique.",
|
||||||
|
"quotePrivacy": "Partagez uniquement les informations de l’entreprise. N’incluez 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 n’a été envoyée à un véritable système commercial.",
|
"successBody": "Ce prototype a terminé le parcours de réservation. Aucune demande n’a é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 n’a pas pu terminer la demande. Vos informations sont conservées. Réessayez.",
|
"submissionError": "Le prototype n’a 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 n’a pas pu être terminée. Vos informations sont toujours disponibles.",
|
"genericError": "La demande n’a 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": {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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('76–150 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',
|
||||||
|
'1–25 vehicles',
|
||||||
|
'26–75 vehicles',
|
||||||
|
'76–150 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('76–150 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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TYPE "Plan" ADD VALUE IF NOT EXISTS 'ENTERPRISE';
|
||||||
+6
@@ -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;
|
||||||
+50
@@ -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;
|
||||||
@@ -25,6 +25,7 @@ enum Plan {
|
|||||||
STARTER
|
STARTER
|
||||||
GROWTH
|
GROWTH
|
||||||
PRO
|
PRO
|
||||||
|
ENTERPRISE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BillingPeriod {
|
enum BillingPeriod {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user