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

This commit is contained in:
root
2026-08-04 00:52:58 -04:00
parent 626da23c2e
commit 149806a773
72 changed files with 834 additions and 174 deletions
@@ -70,6 +70,7 @@ export default async function LocaleHomePage({ params }: LocalePageProps) {
<PricingSection
content={content.pricing}
locale={locale}
themePreference={mode}
homePath={homePath}
demoSubmissionEnabled={integrationConfig.demoSubmissionEnabled}
/>
@@ -1,5 +1,5 @@
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 { notFound, redirect } from 'next/navigation'
@@ -14,6 +14,11 @@ export default async function LegacyComponentLabPage({
if (!isLocale(locale)) notFound()
const cookieStore = await cookies()
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`)
}
@@ -4,7 +4,7 @@ import {
localizedModePath,
type Locale,
} 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 { notFound, redirect } from 'next/navigation'
@@ -17,6 +17,11 @@ export default async function LegacyForgotPasswordPage({
if (!isLocale(localeValue)) notFound()
const cookieStore = await cookies()
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))
}
@@ -14,6 +14,7 @@ import { getMessages } from '@/lib/localization/messages';
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
import {
isThemePreference,
sharedThemeCookie,
serverResolvedTheme,
themeCookie,
type ThemePreference,
@@ -50,10 +51,13 @@ export default async function LocaleLayout({ children, params }: LocaleLayoutPro
const cookieStore = await cookies();
const headerPreference = requestHeaders.get('x-theme-preference');
const cookiePreference = cookieStore.get(themeCookie)?.value;
const sharedCookiePreference = cookieStore.get(sharedThemeCookie)?.value;
const preference: ThemePreference = isThemePreference(headerPreference)
? headerPreference
: isThemePreference(cookiePreference)
? cookiePreference
: isThemePreference(sharedCookiePreference)
? sharedCookiePreference
: 'dark';
const resolvedTheme = serverResolvedTheme(preference);
+4 -2
View File
@@ -4,7 +4,7 @@ import {
localizedModePath,
type Locale,
} 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 { notFound, redirect } from 'next/navigation';
@@ -15,7 +15,9 @@ interface LocaleRedirectPageProps {
async function preferredMode() {
const cookieStore = await cookies();
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) {
@@ -4,7 +4,7 @@ import {
localizedModePath,
type Locale,
} 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 { notFound, redirect } from 'next/navigation'
@@ -17,6 +17,11 @@ export default async function LegacySignInPage({
if (!isLocale(localeValue)) notFound()
const cookieStore = await cookies()
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))
}
@@ -1,3 +1,5 @@
'use client';
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { accountCreateUrl } from '@/lib/account-urls';
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 Image from 'next/image';
import styles from './SiteFooter.module.css';
import { useThemePreference } from './useThemePreference';
function signInUrl(locale: Locale, mode: ThemePreference): string {
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) {
const currentMode = useThemePreference(mode);
const footer = messages.footer;
const year = new Date().getUTCFullYear();
@@ -117,7 +121,7 @@ export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
<PendingItem label={footer.links.contact} pending={footer.pending} />
</li>
<li>
<a href={accountCreateUrl(locale, mode)}>{footer.links.demo}</a>
<a href={accountCreateUrl(locale, currentMode)}>{footer.links.demo}</a>
</li>
</ul>
</section>
@@ -126,7 +130,7 @@ export function SiteFooter({ locale, mode, messages }: SiteFooterProps) {
<h2 id="footer-account">{footer.groups.account}</h2>
<ul>
<li>
<a href={signInUrl(locale, mode)}>
<a href={signInUrl(locale, currentMode)}>
{footer.links.login}
</a>
</li>
@@ -1,11 +1,13 @@
'use client';
import { applyTheme, currentThemePreference } from '@/lib/theme/client';
import { applyTheme, currentThemePreference, persistTheme } from '@/lib/theme/client';
import { themeStorageKey } from '@/lib/theme/config';
import { useEffect } from 'react';
export function ThemeController() {
useEffect(() => {
persistTheme(currentThemePreference());
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = () => {
if (currentThemePreference() === 'system') applyTheme('system');
@@ -18,6 +18,10 @@
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(16rem, 0.5fr);
gap: var(--space-5);
position: relative;
left: 50%;
inline-size: min(100vw - (var(--layout-gutter) * 2), 104rem);
transform: translateX(-50%);
padding: var(--space-5);
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
@@ -107,8 +111,12 @@
.planGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(4, minmax(0, 1fr));
align-items: stretch;
position: relative;
left: 50%;
inline-size: min(100vw - (var(--layout-gutter) * 2), 104rem);
transform: translateX(-50%);
gap: var(--space-5);
}
@@ -180,15 +188,15 @@
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
gap: var(--space-1);
color: var(--text-primary);
}
.price {
font-size: clamp(2rem, 4vw, 3rem);
font-size: clamp(1.65rem, 2.4vw, 2.35rem);
font-weight: 850;
line-height: 1;
letter-spacing: -0.035em;
letter-spacing: 0;
}
.pendingPrice,
@@ -330,10 +338,6 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.planCard:last-child {
grid-column: 1 / -1;
}
.compareBlock {
justify-items: start;
text-align: start;
@@ -356,10 +360,6 @@
transform: none;
}
.planCard:last-child {
grid-column: auto;
}
.audience,
.priceBlock {
min-block-size: auto;
@@ -1,6 +1,7 @@
'use client';
import { ActionLink } from '@/components/actions/ActionLink';
import { useThemePreference } from '@/components/app-shell/useThemePreference';
import { Icon } from '@/components/foundation/Icon';
import { DemoTrigger } from '@/components/integrations/DemoTrigger';
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
@@ -14,18 +15,27 @@ import {
} from '@/content/pricing-config';
import { trackAnalytics } from '@/lib/analytics/client';
import type { AnalyticsContext } from '@/lib/analytics/events';
import { accountCreateUrl, type AccountSubscriptionPlan } from '@/lib/account-urls';
import { classNames } from '@/lib/components/classNames';
import { getDirection, type Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config';
import { useMemo, useState } from 'react';
import styles from './PricingSection.module.css';
interface PricingSectionProps {
content: HomepageContent['pricing'];
locale: Locale;
themePreference: ThemePreference;
homePath: string;
demoSubmissionEnabled: boolean;
}
const accountSubscriptionPlanByPricingPlan: Record<Exclude<PricingPlanId, 'enterprise'>, AccountSubscriptionPlan> = {
launch: 'STARTER',
growth: 'GROWTH',
pro: 'PRO',
};
function analyticsContext(
locale: Locale,
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;
const discountPercent = pricingCommercialConfig.annualDiscountPercent ?? 0;
return monthlyPrice * (1 - discountPercent / 100);
return monthlyPrice * 12 * (1 - discountPercent / 100);
}
function formatPrice(value: number, locale: Locale): string {
@@ -69,9 +79,11 @@ function formatDiscount(locale: Locale): string {
export function PricingSection({
content,
locale,
themePreference,
homePath,
demoSubmissionEnabled,
}: PricingSectionProps) {
const currentThemePreference = useThemePreference(themePreference);
const [fleetBand, setFleetBand] = useState<FleetBandId>('small');
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('monthly');
const recommendedPlan = pricingCommercialConfig.recommendedPlanByFleetBand[fleetBand];
@@ -84,7 +96,7 @@ export function PricingSection({
const publicPrice = pricingCommercialConfig.publicPricesApproved ? configuredPrice : null;
return [
plan.id,
publicPrice === null ? null : monthlyEquivalent(publicPrice, billingPeriod),
publicPrice === null ? null : calculateDisplayedPrice(publicPrice, billingPeriod),
];
}),
) as Record<PricingPlanId, number | null>,
@@ -198,7 +210,7 @@ export function PricingSection({
<bdi className={styles.price} dir="ltr">
{formatPrice(price, locale)}
</bdi>
<span>{content.perMonth}</span>
<span>{billingPeriod === 'monthly' ? content.perMonth : content.perYear}</span>
</div>
{billingPeriod === 'annual' ? (
<p className={styles.priceMeta}>{content.annualEquivalent}</p>
@@ -216,22 +228,42 @@ export function PricingSection({
))}
</ul>
<DemoTrigger
label={plan.cta}
source="pricing"
size="large"
fullWidth
className={styles.planAction}
disabledReason={
demoSubmissionEnabled ? undefined : content.actionDisabledReason
}
onActivate={() =>
trackAnalytics(
'pricing_plan_cta_clicked',
analyticsContext(locale, fleetBand, billingPeriod, plan.id),
)
}
/>
{plan.id === 'enterprise' ? (
<DemoTrigger
label={plan.cta}
source="pricing"
size="large"
fullWidth
className={styles.planAction}
disabledReason={
demoSubmissionEnabled ? undefined : content.actionDisabledReason
}
onActivate={() =>
trackAnalytics(
'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>
);
})}
@@ -30,6 +30,7 @@ interface DemoDialogHostProps {
type FormStatus = 'idle' | 'submitting' | 'success' | 'error';
type FieldErrors = Record<string, string>;
type FleetOption = { value: (typeof fleetSizeValues)[number]; label: string };
function messageForCode(code: string, messages: DemoDialogHostProps['messages']): string {
switch (code) {
@@ -99,6 +100,14 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
const idempotencyKeyRef = useRef('');
const startedAtRef = useRef(0);
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[]>(
() =>
@@ -108,6 +117,18 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
})),
[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(() => {
const handleOpen = (event: Event) => {
@@ -156,8 +177,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
setOpen(true);
}
}}
title={messages.title}
description={messages.body}
title={dialogTitle}
description={dialogDescription}
closeLabel={messages.close}
initialFocusRef={firstFieldRef}
returnFocusRef={returnFocusRef}
@@ -166,8 +187,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
{status === 'success' ? (
<section className={styles.success} aria-live="polite">
<StatusBadge tone="success">{messages.localModeLabel}</StatusBadge>
<h3>{messages.successTitle}</h3>
<p>{messages.successBody}</p>
<h3>{successTitle}</h3>
<p>{successBody}</p>
<div className={styles.actions}>
<Button intent="primary" onClick={reset}>
{messages.submitAnother}
@@ -270,8 +291,8 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
}}
>
<div className={styles.notice}>
<StatusBadge tone="info">{messages.localModeLabel}</StatusBadge>
<p>{messages.privacy}</p>
<StatusBadge tone="info">{noticeLabel}</StatusBadge>
<p>{noticeBody}</p>
</div>
{formError ? (
@@ -355,17 +376,18 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
}
>
<Select
key={pricingFleetOnly ? 'pricing-fleet-size' : 'default-fleet-size'}
id="fleetSize"
name="fleetSize"
required
defaultValue=""
defaultValue={pricingFleetOnly ? '250+' : ''}
invalid={Boolean(fieldErrors.fleetSize)}
describedBy={fieldErrors.fleetSize ? 'fleetSize-error' : undefined}
>
<option value="">{messages.fleetOptions[0]}</option>
{fleetSizeValues.map((value, index) => (
<option key={value} value={value}>
{messages.fleetOptions[index + 1]}
{pricingFleetOnly ? null : <option value="">{messages.fleetOptions[0]}</option>}
{fleetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</Select>
@@ -428,7 +450,7 @@ export function DemoDialogHost({ locale, messages, enabled }: DemoDialogHostProp
? messages.submitting
: status === 'error'
? messages.retry
: messages.submit}
: submitLabel}
</Button>
<Button
type="button"
@@ -139,6 +139,7 @@ export interface HomepageContent {
recommended: string;
startingAt: string;
perMonth: string;
perYear: string;
annualEquivalent: string;
pricePending: string;
customPrice: string;
@@ -404,6 +405,7 @@ export function buildHomepageContent(
recommended: homepage.pricing.recommended,
startingAt: homepage.pricing.startingAt,
perMonth: homepage.pricing.perMonth,
perYear: homepage.pricing.perYear,
annualEquivalent: homepage.pricing.annualEquivalent,
pricePending: homepage.pricing.pricePending,
customPrice: homepage.pricing.customPrice,
@@ -166,7 +166,8 @@
"recommended": "موصى بها",
"startingAt": "يبدأ من",
"perMonth": "/ شهريًا",
"annualEquivalent": "ما يعادل شهريًا مع فوترة سنوية",
"perYear": "/ سنويًا",
"annualEquivalent": "تُدفع سنويًا بعد خصم الدفع الكامل",
"pricePending": "السعر الابتدائي بانتظار الاعتماد",
"customPrice": "تسعير مخصص",
"plans": [
@@ -199,12 +200,28 @@
],
"cta": "احجز عرضًا لباقة Growth"
},
{
"id": "pro",
"name": "Pro",
"audience": "للأساطيل القائمة التي تحتاج إلى تحكم متقدم عبر عمليات أكبر.",
"features": [
"كل ما تتضمنه Growth",
"حتى 150 مركبة نشطة",
"تقارير متقدمة",
"أدوار وصلاحيات الفريق",
"مسارات عمل ذات أولوية",
"وصول API",
"دعم مخصص"
],
"cta": "احجز عرضًا لباقة Pro"
},
{
"id": "enterprise",
"name": "مؤسسات",
"audience": "للأساطيل الكبيرة والامتيازات والعمليات ذات احتياجات التكامل المعقدة.",
"features": [
"كل ما تتضمنه Growth",
"كل ما تتضمنه Pro",
"150+ مركبة نشطة",
"تكاملات مخصصة ووصول إلى API",
"أدوار وصلاحيات متقدمة",
"تخطيط ترحيل البيانات",
@@ -316,7 +333,9 @@
},
"form": {
"title": "أنشئ حسابًا للمنتج",
"quoteTitle": "اطلب عرض سعر Enterprise",
"body": "أخبرنا كيف يعمل نشاط تأجير السيارات لديك. الحقول المطلوبة مميزة.",
"quoteBody": "شارك معلومات شركتك حتى نتمكن من إعداد سعر Enterprise مخصص.",
"name": "الاسم الكامل",
"email": "البريد الإلكتروني للعمل",
"company": "الشركة",
@@ -325,7 +344,9 @@
"language": "اللغة المفضلة",
"message": "ما الموضوع الذي ينبغي أن نركز عليه؟",
"submit": "إنشاء حسابي",
"quoteSubmit": "اطلب عرض سعر",
"privacy": "يتحقق وضع غير الإنتاج من سير العمل ثم يتخلص من الطلب. لا يُرسل أي عميل محتمل إلى نظام مبيعات. يظل نص الخصوصية والموافقة النهائي محظورًا إلى حين الاعتماد القانوني.",
"quotePrivacy": "شارك معلومات الشركة فقط. لا تُدرج معلومات شخصية حساسة أو معلومات دفع.",
"close": "إغلاق",
"required": "مطلوب",
"emailError": "أدخل عنوان بريد إلكتروني صالحًا للعمل.",
@@ -339,7 +360,9 @@
],
"langOptions": ["English", "Français", "العربية"],
"successTitle": "تم تسجيل إنشاء الحساب",
"quoteSuccessTitle": "تم تسجيل طلب عرض السعر",
"successBody": "اكتمل مسار الحجز في هذا النموذج الأولي. لم يُرسل أي طلب إلى نظام مبيعات فعلي.",
"quoteSuccessBody": "تم تسجيل طلب عرض سعر Enterprise الخاص بك.",
"submitAnother": "إرسال طلب آخر",
"submissionError": "تعذر على النموذج الأولي إكمال الطلب. ما زالت بياناتك متاحة. حاول مرة أخرى.",
"errorSummary": "راجع الحقول المميزة ثم حاول مرة أخرى.",
@@ -357,6 +380,7 @@
"rateLimitError": "تم استلام عدد كبير جدًا من الطلبات. حاول مرة أخرى لاحقًا.",
"genericError": "تعذر إكمال الطلب. لا تزال بياناتك متاحة.",
"localModeLabel": "موصل محلي آمن",
"quoteModeLabel": "عرض سعر Enterprise",
"marketDefault": "المغرب"
},
"preview": {
@@ -178,7 +178,8 @@
"recommended": "Recommended",
"startingAt": "Starting at",
"perMonth": "/ month",
"annualEquivalent": "monthly equivalent, billed annually",
"perYear": "/ year",
"annualEquivalent": "billed annually after full-payment discount",
"pricePending": "Starting price pending approval",
"customPrice": "Custom pricing",
"plans": [
@@ -211,12 +212,28 @@
],
"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",
"name": "Enterprise",
"audience": "For large fleets, franchises, and operations with complex integration needs.",
"features": [
"Everything in Growth",
"Everything in Pro",
"150+ active vehicles",
"Custom integrations and API access",
"Advanced roles and permissions",
"Data migration planning",
@@ -328,7 +345,9 @@
},
"form": {
"title": "Create account",
"quoteTitle": "Request Enterprise quote",
"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",
"email": "Work email",
"company": "Company",
@@ -338,7 +357,9 @@
"language": "Preferred language",
"message": "What should we focus on?",
"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.",
"quotePrivacy": "Share company information only. Do not include sensitive personal or payment information.",
"close": "Close",
"required": "Required",
"emailError": "Enter a valid work email address.",
@@ -352,7 +373,9 @@
],
"langOptions": ["English", "Français", "العربية"],
"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.",
"quoteSuccessBody": "Your Enterprise quote request has been recorded.",
"submitAnother": "Submit another request",
"submissionError": "The prototype could not complete the request. Your entries are still available. 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.",
"rateLimitError": "Too many requests were received. Try again later.",
"genericError": "The request could not be completed. Your entries are still available.",
"localModeLabel": "Safe local adapter"
"localModeLabel": "Safe local adapter",
"quoteModeLabel": "Enterprise quote"
},
"preview": {
"today": "Today",
@@ -197,7 +197,8 @@
"recommended": "Recommandé",
"startingAt": "À partir de",
"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",
"customPrice": "Tarification personnalisée",
"plans": [
@@ -230,12 +231,28 @@
],
"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",
"name": "Entreprise",
"audience": "Pour les grands parcs, franchises et exploitations ayant des besoins dintégration complexes.",
"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",
"Rôles et autorisations avancés",
"Planification de la migration des données",
@@ -347,7 +364,9 @@
},
"form": {
"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.",
"quoteBody": "Partagez les informations de votre entreprise afin que nous préparions un tarif Enterprise personnalisé.",
"name": "Nom complet",
"email": "Adresse e-mail professionnelle",
"company": "Entreprise",
@@ -356,7 +375,9 @@
"language": "Langue souhaitée",
"message": "Sur quoi devons-nous nous concentrer ?",
"submit": "Créer mon compte",
"quoteSubmit": "Demander un devis",
"privacy": "Le mode hors production valide le parcours puis supprime la demande. Aucun prospect nest envoyé à un système commercial. Le texte final de confidentialité et de consentement reste bloqué dans lattente de lapprobation juridique.",
"quotePrivacy": "Partagez uniquement les informations de lentreprise. Nincluez aucune donnée personnelle sensible ni information de paiement.",
"close": "Fermer",
"required": "Obligatoire",
"emailError": "Saisissez une adresse e-mail professionnelle valide.",
@@ -370,7 +391,9 @@
],
"langOptions": ["English", "Français", "العربية"],
"successTitle": "Création de compte enregistrée",
"quoteSuccessTitle": "Demande de devis enregistrée",
"successBody": "Ce prototype a terminé le parcours de réservation. Aucune demande na été envoyée à un véritable système commercial.",
"quoteSuccessBody": "Votre demande de devis Enterprise a été enregistrée.",
"submitAnother": "Envoyer une autre demande",
"submissionError": "Le prototype na pas pu terminer la demande. Vos informations sont conservées. Réessayez.",
"errorSummary": "Vérifiez les champs signalés, puis réessayez.",
@@ -388,6 +411,7 @@
"rateLimitError": "Trop de demandes ont été reçues. Réessayez plus tard.",
"genericError": "La demande na pas pu être terminée. Vos informations sont toujours disponibles.",
"localModeLabel": "Adaptateur local sécurisé",
"quoteModeLabel": "Devis Enterprise",
"marketDefault": "Maroc"
},
"preview": {
+23 -18
View File
@@ -1,7 +1,7 @@
export const fleetBandIds = ['small', 'growing', 'scale', 'enterprise'] as const;
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 BillingPeriod = 'monthly' | 'annual';
@@ -16,37 +16,42 @@ export interface PricingCommercialConfig {
/**
* This is the only file that should contain commercial pricing numbers.
* Keep publicPricesApproved=false until finance and product approve the amounts,
* included limits, onboarding policy, taxes, and annual billing terms.
* Public prices apply only to non-custom plans. Enterprise remains custom.
*/
export const pricingCommercialConfig: PricingCommercialConfig = {
currency: 'MAD',
annualDiscountPercent: null,
publicPricesApproved: false,
annualDiscountPercent: 20,
publicPricesApproved: true,
monthlyPrices: {
launch: {
small: 50,
growing: 50,
scale: 50,
enterprise: 50,
small: 149,
growing: 149,
scale: 149,
enterprise: 149,
},
growth: {
small: 200,
growing: 200,
scale: 200,
enterprise: 200,
small: 299,
growing: 299,
scale: 299,
enterprise: 299,
},
pro: {
small: 399,
growing: 399,
scale: 399,
enterprise: 399,
},
enterprise: {
small: 500,
growing: 500,
scale: 500,
enterprise: 500,
small: null,
growing: null,
scale: null,
enterprise: null,
},
},
recommendedPlanByFleetBand: {
small: 'launch',
growing: 'growth',
scale: 'growth',
scale: 'pro',
enterprise: 'enterprise',
},
};
+8 -1
View File
@@ -1,8 +1,15 @@
import type { Locale } from '@/lib/localization/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 });
if (theme === 'light' || theme === 'dark') params.set('theme', theme);
if (subscriptionPlan) params.set('plan', subscriptionPlan);
return `/dashboard/sign-up?${params.toString()}`;
}
+1 -1
View File
@@ -45,7 +45,7 @@ export const analyticsContextSchema = z
reason: safeText.optional(),
fleetBand: z.enum(['small', 'growing', 'scale', 'enterprise']).optional(),
billingPeriod: z.enum(['monthly', 'annual']).optional(),
plan: z.enum(['launch', 'growth', 'enterprise']).optional(),
plan: z.enum(['launch', 'growth', 'pro', 'enterprise']).optional(),
})
.strict();
@@ -1,10 +1,15 @@
export const themeBootstrapScript = `(function () {
var allowed = { light: true, dark: true, system: true };
var allowedResolved = { light: true, dark: true };
var cookieMatch = document.cookie.match(/(?:^|; )hpc-theme=([^;]+)/);
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 sharedStored = null;
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 resolved = preference === "system" ? (dark ? "dark" : "light") : preference;
var root = document.documentElement;
+9 -7
View File
@@ -1,6 +1,8 @@
import {
isThemePreference,
resolveTheme,
sharedThemeCookie,
sharedThemeStorageKey,
themeCookie,
themeCookieMaxAgeSeconds,
themeStorageKey,
@@ -18,20 +20,20 @@ export function applyTheme(preference: ThemePreference): void {
}
export function persistTheme(preference: ThemePreference): void {
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const resolved = resolveTheme(preference, systemDark);
try {
window.localStorage.setItem(themeStorageKey, preference);
window.localStorage.setItem(sharedThemeStorageKey, resolved);
} catch {
// Storage can be unavailable; the first-party cookie remains canonical.
}
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie =
[
`${themeCookie}=${encodeURIComponent(preference)}`,
'Path=/',
`Max-Age=${themeCookieMaxAgeSeconds}`,
'SameSite=Lax',
].join('; ') + secure;
const cookieAttributes = ['Path=/', `Max-Age=${themeCookieMaxAgeSeconds}`, 'SameSite=Lax'].join('; ') + secure;
document.cookie = `${themeCookie}=${encodeURIComponent(preference)}; ${cookieAttributes}`;
document.cookie = `${sharedThemeCookie}=${encodeURIComponent(resolved)}; ${cookieAttributes}`;
applyTheme(preference);
window.dispatchEvent(
new CustomEvent<ThemePreference>(themePreferenceEvent, { detail: preference }),
+2
View File
@@ -1,5 +1,7 @@
export const themeCookie = 'hpc-theme';
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 type ThemePreference = (typeof themePreferences)[number];
export type ResolvedTheme = 'light' | 'dark';
+11 -1
View File
@@ -13,6 +13,7 @@ import {
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
import {
isThemePreference,
sharedThemeCookie,
themeCookie,
themeCookieMaxAgeSeconds,
} from '@/lib/theme/config';
@@ -55,7 +56,9 @@ function resolveRequestLocale(request: NextRequest) {
function resolveRequestMode(request: NextRequest) {
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 {
@@ -76,6 +79,13 @@ function setModeCookie(response: NextResponse, mode: string): void {
secure: process.env.NODE_ENV === 'production',
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 {