reduce duplication and formatting
This commit is contained in:
@@ -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": {
|
||||
|
||||
+33
-64
@@ -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">
|
||||
|
||||
+81
-130
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user