reduce duplication and formatting

This commit is contained in:
Connor Yoh
2025-11-24 11:34:38 +00:00
parent 2848d4a5e6
commit ffdff7a03b
10 changed files with 372 additions and 241 deletions
@@ -5436,7 +5436,7 @@
"selectMonthly": "Select Monthly",
"selectYearly": "Select Yearly",
"savePercent": "Save {{percent}}%",
"savingsAmount": "You save {{currency}}{{amount}}",
"savingsAmount": "You save {{amount}}",
"modalTitle": "Select Billing Period - {{planName}}"
},
"paymentStage": {
@@ -1,7 +1,11 @@
import React from 'react';
import { Button, Card, Badge, Text, Stack, Divider, Tooltip } from '@mantine/core';
import { Button, Card, Text, Stack, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
interface PlanCardProps {
planGroup: PlanTierGroup;
@@ -22,24 +26,13 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
@@ -49,12 +42,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
</Text>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
£0
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.free.forever', 'Forever free')}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency="£"
period={t('plan.free.forever', 'Forever free')}
/>
</div>
<Divider />
@@ -87,47 +80,25 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
let displayPrice = monthly?.price || 0;
let displaySeatPrice = monthly?.seatPrice;
let displayCurrency = monthly?.currency || '£';
if (yearly) {
displayPrice = Math.round(yearly.price / 12);
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
displayCurrency = yearly.currency;
}
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(monthly, yearly);
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? (
<Badge
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
) : null}
<Stack gap="md" style={{ height: '100%' }}>
@@ -144,7 +115,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
<Text size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}{displaySeatPrice}/seat
</Text>
<Text size="sm" c="dimmed" mt="xs">
@@ -152,14 +123,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
</Text>
</>
) : (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')}
</Text>
</>
<PriceDisplay
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t('plan.perMonth', '/month')}
/>
)}
{/* Show seat count for enterprise plans when current */}
@@ -1,21 +1,23 @@
import React, { useEffect } from 'react';
import { Modal, Text, Alert, Stack, Button } from '@mantine/core';
import { Modal, Text, Alert, Stack, Button, Group, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { loadStripe } from '@stripe/stripe-js';
import licenseService from '@app/services/licenseService';
import { useIsMobile } from '@app/hooks/useIsMobile';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { StripeCheckoutProps } from './types/checkout';
import { validateEmail, getModalTitle } from './utils/checkoutUtils';
import { calculateSavings } from './utils/savingsCalculator';
import { useCheckoutState } from './hooks/useCheckoutState';
import { useCheckoutNavigation } from './hooks/useCheckoutNavigation';
import { useLicensePolling } from './hooks/useLicensePolling';
import { useCheckoutSession } from './hooks/useCheckoutSession';
import { EmailStage } from './stages/EmailStage';
import { PlanSelectionStage } from './stages/PlanSelectionStage';
import { PaymentStage } from './stages/PaymentStage';
import { SuccessStage } from './stages/SuccessStage';
import { ErrorStage } from './stages/ErrorStage';
import { StripeCheckoutProps } from '@app/components/shared/stripeCheckout/types/checkout';
import { validateEmail, getModalTitle } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { calculateSavings } from '@app/components/shared/stripeCheckout/utils/savingsCalculator';
import { useCheckoutState } from '@app/components/shared/stripeCheckout/hooks/useCheckoutState';
import { useCheckoutNavigation } from '@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation';
import { useLicensePolling } from '@app/components/shared/stripeCheckout/hooks/useLicensePolling';
import { useCheckoutSession } from '@app/components/shared/stripeCheckout/hooks/useCheckoutSession';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { PlanSelectionStage } from '@app/components/shared/stripeCheckout/stages/PlanSelectionStage';
import { PaymentStage } from '@app/components/shared/stripeCheckout/stages/PaymentStage';
import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/SuccessStage';
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
@@ -48,6 +50,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
// Initialize all state via custom hook
const checkoutState = useCheckoutState(planGroup);
@@ -224,8 +227,6 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
planGroup={planGroup}
minimumSeats={minimumSeats}
savings={savings}
canGoBack={checkoutState.stageHistory.length > 0}
onBack={navigation.goBack}
onSelectPlan={handlePlanSelect}
/>
);
@@ -238,8 +239,6 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
selectedPeriod={checkoutState.selectedPeriod}
planName={planGroup.name}
loading={checkoutState.state.loading || false}
canGoBack={checkoutState.stageHistory.length > 0}
onBack={navigation.goBack}
onPaymentComplete={session.handlePaymentComplete}
/>
);
@@ -267,20 +266,36 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
}
};
const canGoBack = checkoutState.stageHistory.length > 0;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
</Text>
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={navigation.goBack}
aria-label={t('common.back', 'Back')}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
</Text>
</Group>
}
size="lg"
size={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {},
@@ -0,0 +1,89 @@
import React from 'react';
import { Text, Stack } from '@mantine/core';
import { formatPrice } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { PRICE_FONT_WEIGHT } from '@app/components/shared/stripeCheckout/utils/cardStyles';
interface SimplePriceProps {
mode: 'simple';
price: number;
currency: string;
period: string;
size?: string;
}
interface EnterprisePriceProps {
mode: 'enterprise';
basePrice: number;
seatPrice: number;
totalPrice?: number;
currency: string;
period: 'month' | 'year';
seatCount?: number;
size?: 'sm' | 'md' | 'lg';
}
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
if (props.mode === 'simple') {
const fontSize = props.size || '2.25rem';
return (
<>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(props.price, props.currency)}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{props.period}
</Text>
</>
);
}
// Enterprise mode
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = 'md' } = props;
const fontSize = size === 'lg' ? '2rem' : size === 'sm' ? 'md' : 'xl';
const totalFontSize = size === 'lg' ? '2rem' : '2rem';
return (
<Stack gap="sm">
<div>
<Text size="sm" c="dimmed" mb="xs">
Base Price
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(basePrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
/{period}
</Text>
</Text>
</div>
<div>
<Text size="sm" c="dimmed" mb="xs">
Per Seat
</Text>
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
{formatPrice(seatPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
/seat/{period}
</Text>
</Text>
</div>
{totalPrice !== undefined && seatCount && (
<div>
<Text size="sm" c="dimmed" mb="xs">
Total ({seatCount} seats)
</Text>
<Text size={totalFontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
{formatPrice(totalPrice, currency)}
<Text component="span" size="sm" c="dimmed" fw={400}>
{' '}
/{period === 'year' ? 'month' : period}
</Text>
</Text>
</div>
)}
</Stack>
);
};
@@ -0,0 +1,24 @@
import React from 'react';
import { Badge } from '@mantine/core';
interface PricingBadgeProps {
type: 'current' | 'popular' | 'savings';
label: string;
savingsPercent?: number;
}
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label, savingsPercent }) => {
const color = type === 'current' || type === 'savings' ? 'green' : 'blue';
const size = type === 'savings' ? 'lg' : 'sm';
return (
<Badge
color={color}
variant="filled"
size={size}
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{label}
</Badge>
);
};
@@ -1,5 +1,5 @@
import React from 'react';
import { Stack, Title, Text, TextInput, Button } from '@mantine/core';
import { Stack, Text, TextInput, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface EmailStageProps {
@@ -19,14 +19,9 @@ export const EmailStage: React.FC<EmailStageProps> = ({
return (
<Stack gap="lg" style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem 0' }}>
<div>
<Title order={3} mb="xs">
{t('payment.emailStage.title', 'Enter Your Email')}
</Title>
<Text size="sm" c="dimmed">
{t('payment.emailStage.description', "We'll use this to send your license key and receipts.")}
</Text>
</div>
<Text size="sm" c="dimmed">
{t('payment.emailStage.description', "We'll use this to send your license key and receipts.")}
</Text>
<TextInput
label={t('payment.emailStage.emailLabel', 'Email Address')}
@@ -15,8 +15,6 @@ interface PaymentStageProps {
selectedPeriod: 'monthly' | 'yearly';
planName: string;
loading: boolean;
canGoBack: boolean;
onBack: () => void;
onPaymentComplete: () => void;
}
@@ -26,8 +24,6 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
selectedPeriod,
planName,
loading,
canGoBack,
onBack,
onPaymentComplete,
}) => {
const { t } = useTranslation();
@@ -54,18 +50,6 @@ export const PaymentStage: React.FC<PaymentStageProps> = ({
return (
<Stack gap="md">
{/* Back button */}
{canGoBack && (
<Button
variant="subtle"
onClick={onBack}
disabled={loading}
style={{ alignSelf: 'flex-start' }}
>
{t('payment.paymentStage.backToPlan', 'Back to Plan Selection')}
</Button>
)}
{/* Selected plan summary */}
<Paper withBorder p="md" radius="md" bg="gray.0">
<Group justify="space-between">
@@ -1,15 +1,17 @@
import React from 'react';
import { Stack, Button, Title, Text, Grid, Paper, Badge, Alert, Divider } from '@mantine/core';
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '../types/checkout';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { formatPrice, calculateMonthlyEquivalent, calculateTotalWithSeats } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getClickablePaperStyle, PRICE_FONT_WEIGHT } from '@app/components/shared/stripeCheckout/utils/cardStyles';
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
canGoBack: boolean;
onBack: () => void;
onSelectPlan: (period: 'monthly' | 'yearly') => void;
}
@@ -17,8 +19,6 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
canGoBack,
onBack,
onSelectPlan,
}) => {
const { t } = useTranslation();
@@ -26,24 +26,9 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: '1rem 0' }}>
{/* Back button */}
{canGoBack && (
<Button variant="subtle" onClick={onBack} style={{ alignSelf: 'flex-start' }}>
{t('common.back', 'Back')}
</Button>
)}
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<div>
<Title order={3} mb="xs">
{t('payment.planStage.title', 'Choose Your Billing Period')}
</Title>
<Text size="sm" c="dimmed">
{savings && t('payment.planStage.savingsNote', 'Save {{percent}}% with annual billing', { percent: savings.percent })}
</Text>
</div>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
@@ -51,15 +36,10 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
withBorder
p="xl"
radius="md"
style={{
cursor: 'pointer',
transition: 'all 0.2s',
height: '100%',
position: 'relative',
}}
style={getClickablePaperStyle()}
onClick={() => onSelectPlan('monthly')}
>
<Stack gap="md">
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
@@ -67,50 +47,31 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<Divider />
{isEnterprise && planGroup.monthly.seatPrice ? (
<>
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.basePrice', 'Base Price')}
</Text>
<Text size="xl" fw={700}>
{planGroup.monthly.currency}{planGroup.monthly.price.toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /month</Text>
</Text>
</div>
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.seatPrice', 'Per Seat')}
</Text>
<Text size="xl" fw={700}>
{planGroup.monthly.currency}{planGroup.monthly.seatPrice.toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /seat/month</Text>
</Text>
</div>
<Divider />
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.totalForSeats', 'Total ({{count}} seats)', { count: seatCount })}
</Text>
<Text size="2rem" fw={700}>
{planGroup.monthly.currency}{(planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)).toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /month</Text>
</Text>
</div>
</>
<PriceDisplay
mode="enterprise"
basePrice={planGroup.monthly.price}
seatPrice={planGroup.monthly.seatPrice}
totalPrice={calculateTotalWithSeats(planGroup.monthly.price, planGroup.monthly.seatPrice, seatCount)}
currency={planGroup.monthly.currency}
period="month"
seatCount={seatCount}
size="sm"
/>
) : (
<div>
<Text size="3rem" fw={700} style={{ lineHeight: 1 }}>
{planGroup.monthly?.currency}{planGroup.monthly?.price.toFixed(2)}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('payment.perMonth', '/month')}
</Text>
</div>
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || '£'}
period={t('payment.perMonth', '/month')}
size="2.5rem"
/>
)}
<Button variant="light" fullWidth size="lg" mt="md">
{t('payment.planStage.selectMonthly', 'Select Monthly')}
</Button>
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<Button variant="light" fullWidth size="lg">
{t('payment.planStage.selectMonthly', 'Select Monthly')}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
@@ -123,28 +84,17 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
withBorder
p="xl"
radius="md"
style={{
cursor: 'pointer',
transition: 'all 0.2s',
height: '100%',
position: 'relative',
borderColor: savings ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: savings ? '2px' : undefined,
}}
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan('yearly')}
>
{savings && (
<Badge
color="green"
variant="filled"
size="lg"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
</Badge>
<PricingBadge
type="savings"
label={t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
/>
)}
<Stack gap="md">
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
@@ -152,61 +102,62 @@ export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
<Divider />
{isEnterprise && planGroup.yearly.seatPrice ? (
<>
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.basePrice', 'Base Price')}
</Text>
<Text size="xl" fw={700}>
{planGroup.yearly.currency}{planGroup.yearly.price.toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /year</Text>
</Text>
</div>
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.seatPrice', 'Per Seat')}
</Text>
<Text size="xl" fw={700}>
{planGroup.yearly.currency}{planGroup.yearly.seatPrice.toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /seat/year</Text>
</Text>
</div>
<Divider />
<div>
<Text size="sm" c="dimmed" mb="xs">
{t('payment.planStage.totalForSeats', 'Total ({{count}} seats)', { count: seatCount })}
</Text>
<Text size="2rem" fw={700}>
{planGroup.yearly.currency}{(planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount)).toFixed(2)}
<Text component="span" size="sm" c="dimmed" fw={400}> /year</Text>
</Text>
</div>
</>
<Stack gap="sm">
<PriceDisplay
mode="enterprise"
basePrice={planGroup.yearly.price}
seatPrice={planGroup.yearly.seatPrice}
totalPrice={calculateMonthlyEquivalent(
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount)
)}
currency={planGroup.yearly.currency}
period="year"
seatCount={seatCount}
size="sm"
/>
<Text size="sm" c="dimmed">
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
currency: planGroup.yearly.currency,
amount: formatPrice(
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount),
planGroup.yearly.currency
)
})}
</Text>
</Stack>
) : (
<div>
<Text size="3rem" fw={700} style={{ lineHeight: 1 }}>
{planGroup.yearly?.currency}{planGroup.yearly?.price.toFixed(2)}
</Text>
<Stack gap={0}>
<PriceDisplay
mode="simple"
price={calculateMonthlyEquivalent(planGroup.yearly?.price || 0)}
currency={planGroup.yearly?.currency || '£'}
period={t('payment.perMonth', '/month')}
size="2.5rem"
/>
<Text size="sm" c="dimmed" mt="xs">
{t('payment.perYear', '/year')}
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2)
})}
</Text>
</div>
</Stack>
)}
{savings && (
<Alert color="green" variant="light" p="sm">
<Text size="sm" fw={600}>
{t('payment.planStage.savingsAmount', 'You save {{currency}}{{amount}}', {
currency: savings.currency,
amount: savings.amount.toFixed(2)
{t('payment.planStage.savingsAmount', 'You save {{amount}}', {
amount: formatPrice(savings.amount, savings.currency)
})}
</Text>
</Alert>
)}
<Button variant="filled" fullWidth size="lg" mt="md">
{t('payment.planStage.selectYearly', 'Select Yearly')}
</Button>
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<Button variant="filled" fullWidth size="lg">
{t('payment.planStage.selectYearly', 'Select Yearly')}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
@@ -0,0 +1,44 @@
import { CSSProperties } from 'react';
/**
* Shared styling utilities for plan cards
*/
export const CARD_MIN_HEIGHT = '400px';
export const PRICE_FONT_WEIGHT = 600;
/**
* Get card border style based on state
*/
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
return {
borderColor: isHighlighted ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isHighlighted ? '2px' : undefined,
};
}
/**
* Get base card style
*/
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
return {
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: CARD_MIN_HEIGHT,
...getCardBorderStyle(isHighlighted),
};
}
/**
* Get clickable paper style
*/
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
return {
cursor: 'pointer',
transition: 'all 0.2s',
height: '100%',
position: 'relative',
...getCardBorderStyle(isHighlighted),
};
}
@@ -0,0 +1,60 @@
/**
* Shared pricing utilities for plan cards and checkout
*/
export interface PriceCalculation {
displayPrice: number;
displaySeatPrice?: number;
displayCurrency: string;
}
/**
* Calculate monthly equivalent from yearly price
*/
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
return Math.round(yearlyPrice / 12);
}
/**
* Calculate total price including seats
*/
export function calculateTotalWithSeats(
basePrice: number,
seatPrice: number | undefined,
seatCount: number
): number {
if (seatPrice === undefined) return basePrice;
return basePrice + seatPrice * seatCount;
}
/**
* Format price with currency symbol
*/
export function formatPrice(amount: number, currency: string, decimals: number = 2): string {
return `${currency}${amount.toFixed(decimals)}`;
}
/**
* Calculate display pricing for a plan, showing yearly price divided by 12
* to show the lowest monthly equivalent
*/
export function calculateDisplayPricing(
monthly?: { price: number; seatPrice?: number; currency: string },
yearly?: { price: number; seatPrice?: number; currency: string }
): PriceCalculation {
// Default to monthly if no yearly exists
if (!yearly) {
return {
displayPrice: monthly?.price || 0,
displaySeatPrice: monthly?.seatPrice,
displayCurrency: monthly?.currency || '£',
};
}
// Use yearly price divided by 12 for best value display
return {
displayPrice: calculateMonthlyEquivalent(yearly.price),
displaySeatPrice: yearly.seatPrice ? calculateMonthlyEquivalent(yearly.seatPrice) : undefined,
displayCurrency: yearly.currency,
};
}