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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user