diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 88ca1056ad..9d330bcc71 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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": { diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index 4cf53eab61..2594d91d48 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -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 = ({ 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 && ( - - {t('plan.current', 'Current Plan')} - + )}
@@ -49,12 +42,12 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra {t('plan.from', 'From')} - - £0 - - - {t('plan.free.forever', 'Forever free')} - +
@@ -87,47 +80,25 @@ const PlanCard: React.FC = ({ 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 ( {isCurrentTier ? ( - - {t('plan.current', 'Current Plan')} - + ) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? ( - - {t('plan.popular', 'Popular')} - + ) : null} @@ -144,7 +115,7 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra {/* Price */} {isEnterprise && displaySeatPrice !== undefined ? ( <> - + {displayCurrency}{displaySeatPrice}/seat @@ -152,14 +123,12 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra ) : ( - <> - - {displayCurrency}{displayPrice} - - - {t('plan.perMonth', '/month')} - - + )} {/* Show seat count for enterprise plans when current */} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx index 21e51fc6ae..bdfd396ebc 100644 --- a/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx +++ b/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx @@ -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 = ({ 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 = ({ planGroup={planGroup} minimumSeats={minimumSeats} savings={savings} - canGoBack={checkoutState.stageHistory.length > 0} - onBack={navigation.goBack} onSelectPlan={handlePlanSelect} /> ); @@ -238,8 +239,6 @@ const StripeCheckout: React.FC = ({ 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 = ({ } }; + const canGoBack = checkoutState.stageHistory.length > 0; + return ( - {getModalTitle(checkoutState.state.currentStage, planGroup.name, t)} - + + {canGoBack && ( + + + + )} + + {getModalTitle(checkoutState.state.currentStage, planGroup.name, t)} + + } - 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: {}, diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx new file mode 100644 index 0000000000..b0e3ff9279 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx @@ -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 = (props) => { + if (props.mode === 'simple') { + const fontSize = props.size || '2.25rem'; + return ( + <> + + {formatPrice(props.price, props.currency)} + + + {props.period} + + + ); + } + + // 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 ( + +
+ + Base Price + + + {formatPrice(basePrice, currency)} + + {' '} + /{period} + + +
+
+ + Per Seat + + + {formatPrice(seatPrice, currency)} + + {' '} + /seat/{period} + + +
+ {totalPrice !== undefined && seatCount && ( +
+ + Total ({seatCount} seats) + + + {formatPrice(totalPrice, currency)} + + {' '} + /{period === 'year' ? 'month' : period} + + +
+ )} +
+ ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx new file mode 100644 index 0000000000..22b331c008 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx @@ -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 = ({ type, label, savingsPercent }) => { + const color = type === 'current' || type === 'savings' ? 'green' : 'blue'; + const size = type === 'savings' ? 'lg' : 'sm'; + + return ( + + {label} + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx index 9772699bcc..c8ff4e4159 100644 --- a/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx @@ -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 = ({ return ( -
- - {t('payment.emailStage.title', 'Enter Your Email')} - - - {t('payment.emailStage.description', "We'll use this to send your license key and receipts.")} - -
+ + {t('payment.emailStage.description', "We'll use this to send your license key and receipts.")} + void; onPaymentComplete: () => void; } @@ -26,8 +24,6 @@ export const PaymentStage: React.FC = ({ selectedPeriod, planName, loading, - canGoBack, - onBack, onPaymentComplete, }) => { const { t } = useTranslation(); @@ -54,18 +50,6 @@ export const PaymentStage: React.FC = ({ return ( - {/* Back button */} - {canGoBack && ( - - )} - {/* Selected plan summary */} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx index f9bf94fac9..967a27adef 100644 --- a/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx @@ -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 = ({ planGroup, minimumSeats, savings, - canGoBack, - onBack, onSelectPlan, }) => { const { t } = useTranslation(); @@ -26,24 +26,9 @@ export const PlanSelectionStage: React.FC = ({ const seatCount = minimumSeats || 1; return ( - - {/* Back button */} - {canGoBack && ( - - )} + -
- - {t('payment.planStage.title', 'Choose Your Billing Period')} - - - {savings && t('payment.planStage.savingsNote', 'Save {{percent}}% with annual billing', { percent: savings.percent })} - -
- - + {/* Monthly Option */} {planGroup.monthly && ( @@ -51,15 +36,10 @@ export const PlanSelectionStage: React.FC = ({ withBorder p="xl" radius="md" - style={{ - cursor: 'pointer', - transition: 'all 0.2s', - height: '100%', - position: 'relative', - }} + style={getClickablePaperStyle()} onClick={() => onSelectPlan('monthly')} > - + {t('payment.monthly', 'Monthly')} @@ -67,50 +47,31 @@ export const PlanSelectionStage: React.FC = ({ {isEnterprise && planGroup.monthly.seatPrice ? ( - <> -
- - {t('payment.planStage.basePrice', 'Base Price')} - - - {planGroup.monthly.currency}{planGroup.monthly.price.toFixed(2)} - /month - -
-
- - {t('payment.planStage.seatPrice', 'Per Seat')} - - - {planGroup.monthly.currency}{planGroup.monthly.seatPrice.toFixed(2)} - /seat/month - -
- -
- - {t('payment.planStage.totalForSeats', 'Total ({{count}} seats)', { count: seatCount })} - - - {planGroup.monthly.currency}{(planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)).toFixed(2)} - /month - -
- + ) : ( -
- - {planGroup.monthly?.currency}{planGroup.monthly?.price.toFixed(2)} - - - {t('payment.perMonth', '/month')} - -
+ )} - +
+ +
@@ -123,28 +84,17 @@ export const PlanSelectionStage: React.FC = ({ 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 && ( - - {t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })} - + )} - + {t('payment.yearly', 'Yearly')} @@ -152,61 +102,62 @@ export const PlanSelectionStage: React.FC = ({ {isEnterprise && planGroup.yearly.seatPrice ? ( - <> -
- - {t('payment.planStage.basePrice', 'Base Price')} - - - {planGroup.yearly.currency}{planGroup.yearly.price.toFixed(2)} - /year - -
-
- - {t('payment.planStage.seatPrice', 'Per Seat')} - - - {planGroup.yearly.currency}{planGroup.yearly.seatPrice.toFixed(2)} - /seat/year - -
- -
- - {t('payment.planStage.totalForSeats', 'Total ({{count}} seats)', { count: seatCount })} - - - {planGroup.yearly.currency}{(planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount)).toFixed(2)} - /year - -
- + + + + {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 + ) + })} + + ) : ( -
- - {planGroup.yearly?.currency}{planGroup.yearly?.price.toFixed(2)} - + + - {t('payment.perYear', '/year')} + {t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', { + currency: planGroup.yearly?.currency, + amount: planGroup.yearly?.price.toFixed(2) + })} -
+
)} {savings && ( - {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) })} )} - +
+ +
diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts new file mode 100644 index 0000000000..ac49614364 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts @@ -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), + }; +} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts new file mode 100644 index 0000000000..09248f873b --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts @@ -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, + }; +}