V2 Payment Features (#4974)

* Added ability to add seats to enterprise
* first logged in date on people page
* Remove Premium config section				
* Cleanup add seat flow					
* Shrink numbers in plan 					
* Make editing text a server feature in the highlights
* default to dollar pricing				
* clear checkout logic when crash				
* Recongnise location and find pricing			
* Payment successful page

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-11-24 16:38:07 +00:00
committed by GitHub
co-authored by Connor Yoh
parent 050408639b
commit 5d18184e46
37 changed files with 2321 additions and 685 deletions
@@ -0,0 +1,309 @@
import React, { useEffect } from 'react';
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 '@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;
if (!STRIPE_KEY) {
console.error(
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
'Please add it to your .env file. ' +
'Get your key from https://dashboard.stripe.com/apikeys'
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
);
}
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
// Initialize all state via custom hook
const checkoutState = useCheckoutState(planGroup);
// Initialize navigation hooks
const navigation = useCheckoutNavigation(
checkoutState.state,
checkoutState.setState,
checkoutState.stageHistory,
checkoutState.setStageHistory
);
// Initialize license polling hook
const polling = useLicensePolling(
checkoutState.isMountedRef,
checkoutState.setPollingStatus,
checkoutState.setLicenseKey,
onLicenseActivated
);
// Initialize checkout session hook
const session = useCheckoutSession(
checkoutState.selectedPlan,
checkoutState.state,
checkoutState.setState,
checkoutState.installationId,
checkoutState.setInstallationId,
checkoutState.currentLicenseKey,
checkoutState.setCurrentLicenseKey,
checkoutState.setPollingStatus,
minimumSeats,
polling.pollForLicenseKey,
onSuccess,
onError,
onLicenseActivated
);
// Calculate savings
const savings = calculateSavings(planGroup, minimumSeats);
// Email submission handler
const handleEmailSubmit = () => {
const validation = validateEmail(checkoutState.emailInput);
if (validation.valid) {
checkoutState.setState(prev => ({ ...prev, email: checkoutState.emailInput }));
navigation.goToStage('plan-selection');
} else {
checkoutState.setEmailError(validation.error);
}
};
// Plan selection handler
const handlePlanSelect = (period: 'monthly' | 'yearly') => {
checkoutState.setSelectedPeriod(period);
navigation.goToStage('payment');
};
// Close handler
const handleClose = () => {
// Clear any active polling
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
checkoutState.resetState();
onClose();
};
// Cleanup on unmount
useEffect(() => {
checkoutState.isMountedRef.current = true;
return () => {
checkoutState.isMountedRef.current = false;
if (checkoutState.pollingTimeoutRef.current) {
clearTimeout(checkoutState.pollingTimeoutRef.current);
checkoutState.pollingTimeoutRef.current = null;
}
};
}, [checkoutState.isMountedRef, checkoutState.pollingTimeoutRef]);
// Initialize stage based on existing license
useEffect(() => {
if (!opened) return;
// Handle hosted checkout success - open directly to success state
if (hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
checkoutState.setCurrentLicenseKey('existing'); // Flag to indicate upgrade
checkoutState.setPollingStatus('ready');
} else if (hostedCheckoutSuccess.licenseKey) {
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
checkoutState.setPollingStatus('ready');
}
// Set to success state to show success UI
checkoutState.setState({ currentStage: 'success', loading: false });
return;
}
// Check for existing license to skip email stage
const checkExistingLicense = async () => {
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo && licenseInfo.licenseKey) {
// Has existing license - skip email stage
console.log('Existing license detected - skipping email stage');
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey);
checkoutState.setState({ currentStage: 'plan-selection', loading: false });
} else {
// No license - start at email stage
checkoutState.setState({ currentStage: 'email', loading: false });
}
} catch (error) {
console.warn('Could not check for existing license:', error);
// Default to email stage if check fails
checkoutState.setState({ currentStage: 'email', loading: false });
}
};
checkExistingLicense();
}, [opened, hostedCheckoutSuccess, checkoutState.setCurrentLicenseKey, checkoutState.setPollingStatus, checkoutState.setLicenseKey, checkoutState.setState]);
// Trigger checkout session creation when entering payment stage
useEffect(() => {
if (
checkoutState.state.currentStage === 'payment' &&
!checkoutState.state.clientSecret &&
!checkoutState.state.loading
) {
session.createCheckoutSession();
}
}, [checkoutState.state.currentStage, checkoutState.state.clientSecret, checkoutState.state.loading, session]);
// Render stage content
const renderContent = () => {
// Check if Stripe is configured
if (!stripePromise) {
return (
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.stripeNotConfiguredMessage',
'Stripe payment integration is not configured. Please contact your administrator.'
)}
</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
}
switch (checkoutState.state.currentStage) {
case 'email':
return (
<EmailStage
emailInput={checkoutState.emailInput}
setEmailInput={checkoutState.setEmailInput}
emailError={checkoutState.emailError}
onSubmit={handleEmailSubmit}
/>
);
case 'plan-selection':
return (
<PlanSelectionStage
planGroup={planGroup}
minimumSeats={minimumSeats}
savings={savings}
onSelectPlan={handlePlanSelect}
/>
);
case 'payment':
return (
<PaymentStage
clientSecret={checkoutState.state.clientSecret || null}
selectedPlan={checkoutState.selectedPlan}
onPaymentComplete={session.handlePaymentComplete}
/>
);
case 'success':
return (
<SuccessStage
pollingStatus={checkoutState.pollingStatus}
currentLicenseKey={checkoutState.currentLicenseKey}
licenseKey={checkoutState.licenseKey}
onClose={handleClose}
/>
);
case 'error':
return (
<ErrorStage
error={checkoutState.state.error || 'An unknown error occurred'}
onClose={handleClose}
/>
);
default:
return null;
}
};
const canGoBack = checkoutState.stageHistory.length > 0;
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<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={isMobile ? "100%" : 980}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {},
content: {
maxHeight: '95vh',
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
@@ -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 }) => {
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: '0.5rem', right: '0.5rem' }}
>
{label}
</Badge>
);
};
@@ -0,0 +1,38 @@
import { useCallback } from 'react';
import { CheckoutState, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* Stage navigation and history management hook
*/
export const useCheckoutNavigation = (
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
stageHistory: CheckoutStage[],
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>
) => {
const goToStage = useCallback((nextStage: CheckoutStage) => {
setStageHistory(prev => [...prev, state.currentStage]);
setState(prev => ({ ...prev, currentStage: nextStage }));
}, [state.currentStage, setState, setStageHistory]);
const goBack = useCallback(() => {
if (stageHistory.length > 0) {
const previousStage = stageHistory[stageHistory.length - 1];
setStageHistory(prev => prev.slice(0, -1));
// Reset payment state when going back from payment stage
if (state.currentStage === 'payment') {
setState(prev => ({
...prev,
currentStage: previousStage,
clientSecret: undefined,
loading: false
}));
} else {
setState(prev => ({ ...prev, currentStage: previousStage }));
}
}
}, [stageHistory, state.currentStage, setState, setStageHistory]);
return { goToStage, goBack };
};
@@ -0,0 +1,156 @@
import { useCallback } from 'react';
import licenseService, { PlanTier } from '@app/services/licenseService';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { CheckoutState, PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* Checkout session creation and payment handling hook
*/
export const useCheckoutSession = (
selectedPlan: PlanTier | null,
state: CheckoutState,
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
installationId: string | null,
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
currentLicenseKey: string | null,
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
minimumSeats: number,
pollForLicenseKey: (installId: string) => Promise<void>,
onSuccess?: (sessionId: string) => void,
onError?: (error: string) => void,
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
) => {
const createCheckoutSession = useCallback(async () => {
if (!selectedPlan) {
setState({
currentStage: 'error',
error: 'Selected plan period is not available',
loading: false,
});
return;
}
try {
setState(prev => ({ ...prev, loading: true }));
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
if (!fetchedInstallationId) {
fetchedInstallationId = await licenseService.getInstallationId();
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo && licenseInfo.licenseKey) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
console.log('Found existing license for upgrade');
}
} catch (error) {
console.warn('Could not fetch license info, proceeding as new license:', error);
}
const response = await licenseService.createCheckoutSession({
lookup_key: selectedPlan.lookupKey,
installation_id: fetchedInstallationId,
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
email: state.email, // Pass collected email from Stage 1
});
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log('Redirecting to Stripe hosted checkout:', response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState(prev => ({
...prev,
clientSecret: response.clientSecret,
sessionId: response.sessionId,
loading: false,
}));
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
currentStage: 'error',
error: errorMessage,
loading: false,
});
onError?.(errorMessage);
}
}, [
selectedPlan,
state.email,
installationId,
minimumSeats,
setState,
setInstallationId,
setCurrentLicenseKey,
onError
]);
const handlePaymentComplete = useCallback(async () => {
// Preserve state when changing stage
setState(prev => ({ ...prev, currentStage: 'success' }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license with Keygen');
setPollingStatus('polling');
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
onActivated: onLicenseActivated,
});
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus('ready');
} else {
console.error('Failed to sync upgraded license:', activation.error);
setPollingStatus('timeout');
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || '');
} else {
// NEW PLAN FLOW: Poll for new license key
console.log('New subscription - polling for license key');
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || '');
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || '');
}
}
}, [
currentLicenseKey,
installationId,
state.sessionId,
setState,
setPollingStatus,
pollForLicenseKey,
onSuccess,
onLicenseActivated
]);
return {
createCheckoutSession,
handlePaymentComplete,
};
};
@@ -0,0 +1,78 @@
import { useState, useCallback, useRef } from 'react';
import { PlanTierGroup } from '@app/services/licenseService';
import { CheckoutState, PollingStatus, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* Centralized state management hook for checkout flow
*/
export const useCheckoutState = (planGroup: PlanTierGroup) => {
const [state, setState] = useState<CheckoutState>({
currentStage: 'email',
loading: false
});
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
const [emailInput, setEmailInput] = useState<string>('');
const [emailError, setEmailError] = useState<string>('');
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
planGroup.yearly ? 'yearly' : 'monthly'
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<PollingStatus>('idle');
// Refs for polling cleanup
const isMountedRef = useRef(true);
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly'
? planGroup.yearly
: planGroup.monthly;
const resetState = useCallback(() => {
setState({
currentStage: 'email',
loading: false,
clientSecret: undefined,
sessionId: undefined,
error: undefined
});
setStageHistory([]);
setEmailInput('');
setEmailError('');
setPollingStatus('idle');
setCurrentLicenseKey(null);
setLicenseKey(null);
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
}, [planGroup]);
return {
// State
state,
setState,
stageHistory,
setStageHistory,
emailInput,
setEmailInput,
emailError,
setEmailError,
selectedPeriod,
setSelectedPeriod,
installationId,
setInstallationId,
currentLicenseKey,
setCurrentLicenseKey,
licenseKey,
setLicenseKey,
pollingStatus,
setPollingStatus,
// Refs
isMountedRef,
pollingTimeoutRef,
// Computed
selectedPlan,
// Actions
resetState,
};
};
@@ -0,0 +1,41 @@
import { useCallback } from 'react';
import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* License key polling and activation logic hook
*/
export const useLicensePolling = (
isMountedRef: React.RefObject<boolean>,
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
) => {
const pollForLicenseKey = useCallback(async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current!,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current!,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error('Failed to activate license key:', activation.error);
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
} else if (result.error) {
console.error('License key polling failed:', result.error);
}
}, [isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated]);
return { pollForLicenseKey };
};
@@ -0,0 +1,8 @@
export { default as StripeCheckout } from '@app/components/shared/stripeCheckout/StripeCheckout';
export type {
StripeCheckoutProps,
CheckoutStage,
CheckoutState,
PollingStatus,
SavingsCalculation
} from '@app/components/shared/stripeCheckout/types/checkout';
@@ -0,0 +1,50 @@
import React from 'react';
import { Stack, Text, TextInput, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface EmailStageProps {
emailInput: string;
setEmailInput: (email: string) => void;
emailError: string;
onSubmit: () => void;
}
export const EmailStage: React.FC<EmailStageProps> = ({
emailInput,
setEmailInput,
emailError,
onSubmit,
}) => {
const { t } = useTranslation();
return (
<Stack gap="lg" style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem 0' }}>
<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')}
placeholder={t('payment.emailStage.emailPlaceholder', '[email protected]')}
value={emailInput}
onChange={(e) => setEmailInput(e.currentTarget.value)}
error={emailError}
size="lg"
required
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSubmit();
}
}}
/>
<Button
size="lg"
onClick={onSubmit}
disabled={!emailInput.trim()}
>
{t('payment.emailStage.continue', 'Continue')}
</Button>
</Stack>
);
};
@@ -0,0 +1,23 @@
import React from 'react';
import { Alert, Stack, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ErrorStageProps {
error: string;
onClose: () => void;
}
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
const { t } = useTranslation();
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{error}</Text>
<Button variant="outline" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,61 @@
import React from 'react';
import { Stack, Text, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import { PlanTier } from '@app/services/licenseService';
// Load Stripe once
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface PaymentStageProps {
clientSecret: string | null;
selectedPlan: PlanTier | null;
onPaymentComplete: () => void;
}
export const PaymentStage: React.FC<PaymentStageProps> = ({
clientSecret,
selectedPlan,
onPaymentComplete,
}) => {
const { t } = useTranslation();
// Show loading while creating checkout session
if (!clientSecret || !selectedPlan) {
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</Stack>
);
}
if (!stripePromise) {
return (
<Text size="sm" c="red">
Stripe is not configured properly.
</Text>
);
}
return (
<Stack gap="md">
{/* Stripe Embedded Checkout */}
<EmbeddedCheckoutProvider
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret: clientSecret,
onComplete: onPaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Stack>
);
};
@@ -0,0 +1,165 @@
import React from 'react';
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 '@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 } from '@app/components/shared/stripeCheckout/utils/cardStyles';
interface PlanSelectionStageProps {
planGroup: PlanTierGroup;
minimumSeats: number;
savings: SavingsCalculation | null;
onSelectPlan: (period: 'monthly' | 'yearly') => void;
}
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
planGroup,
minimumSeats,
savings,
onSelectPlan,
}) => {
const { t } = useTranslation();
const isEnterprise = planGroup.tier === 'enterprise';
const seatCount = minimumSeats || 1;
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
{planGroup.monthly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => onSelectPlan('monthly')}
>
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
<Divider />
{isEnterprise && planGroup.monthly.seatPrice ? (
<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"
/>
) : (
<PriceDisplay
mode="simple"
price={planGroup.monthly?.price || 0}
currency={planGroup.monthly?.currency || '£'}
period={t('payment.perMonth', '/month')}
size="2.5rem"
/>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<Button variant="light" fullWidth size="lg">
{t('payment.planStage.selectMonthly', 'Select Monthly')}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
{/* Yearly Option */}
{planGroup.yearly && (
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle(!!savings)}
onClick={() => onSelectPlan('yearly')}
>
{savings && (
<PricingBadge
type="savings"
label={t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
/>
)}
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
<Divider />
{isEnterprise && planGroup.yearly.seatPrice ? (
<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: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2)
})}
</Text>
</Stack>
) : (
<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.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
currency: planGroup.yearly?.currency,
amount: planGroup.yearly?.price.toFixed(2)
})}
</Text>
</Stack>
)}
{savings && (
<Alert color="green" variant="light" p="sm">
<Text size="sm" fw={600}>
{t('payment.planStage.savingsAmount', 'You save {{amount}}', {
amount: formatPrice(savings.amount, savings.currency)
})}
</Text>
</Alert>
)}
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
<Button variant="filled" fullWidth size="lg">
{t('payment.planStage.selectYearly', 'Select Yearly')}
</Button>
</div>
</Stack>
</Paper>
</Grid.Col>
)}
</Grid>
</Stack>
);
};
@@ -0,0 +1,101 @@
import React from 'react';
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
interface SuccessStageProps {
pollingStatus: PollingStatus;
currentLicenseKey: string | null;
licenseKey: string | null;
onClose: () => void;
}
export const SuccessStage: React.FC<SuccessStageProps> = ({
pollingStatus,
currentLicenseKey,
licenseKey,
onClose,
}) => {
const { t } = useTranslation();
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.successMessage',
'Your subscription has been activated successfully.'
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === 'polling' && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t('payment.syncingLicense', 'Syncing your upgraded license...')
: t('payment.generatingLicense', 'Generating your license key...')}
</Text>
</Group>
)}
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('payment.licenseKey', 'Your License Key')}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t('common.copy', 'Copy to Clipboard')}
</Button>
<Text size="xs" c="dimmed">
{t(
'payment.licenseInstructions',
'This has been added to your installation. You will receive a copy in your email as well.'
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === 'ready' && currentLicenseKey && (
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
<Text size="sm">
{t(
'payment.upgradeCompleteMessage',
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'timeout' && (
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
<Text size="sm">
{t(
'payment.licenseDelayedMessage',
'Your license key is being generated. Please check your email shortly or contact support.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'ready' && (
<Text size="xs" c="dimmed">
{t('payment.canCloseWindow', 'You can now close this window.')}
</Text>
)}
<Button onClick={onClose} mt="md">
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
};
@@ -0,0 +1,34 @@
import { PlanTierGroup } from '@app/services/licenseService';
export interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
export type CheckoutStage = 'email' | 'plan-selection' | 'payment' | 'success' | 'error';
export type CheckoutState = {
currentStage: CheckoutStage;
email?: string;
clientSecret?: string;
error?: string;
sessionId?: string;
loading?: boolean;
};
export type PollingStatus = 'idle' | 'polling' | 'ready' | 'timeout';
export interface SavingsCalculation {
amount: number;
percent: number;
currency: string;
}
@@ -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,40 @@
import { TFunction } from 'i18next';
import { CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* Validate email address format
*/
export const validateEmail = (email: string): { valid: boolean; error: string } => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return {
valid: false,
error: 'Please enter a valid email address'
};
}
return { valid: true, error: '' };
};
/**
* Get dynamic modal title based on current stage
*/
export const getModalTitle = (
stage: CheckoutStage,
planName: string,
t: TFunction
): string => {
switch (stage) {
case 'email':
return t('payment.emailStage.modalTitle', 'Get Started - {{planName}}', { planName });
case 'plan-selection':
return t('payment.planStage.modalTitle', 'Select Billing Period - {{planName}}', { planName });
case 'payment':
return t('payment.paymentStage.modalTitle', 'Complete Payment - {{planName}}', { planName });
case 'success':
return t('payment.success', 'Payment Successful!');
case 'error':
return t('payment.error', 'Payment Error');
default:
return t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName });
}
};
@@ -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 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,
};
}
@@ -0,0 +1,38 @@
import { PlanTierGroup } from '@app/services/licenseService';
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
/**
* Calculate savings for yearly vs monthly plans
* Returns null if both monthly and yearly plans are not available
*/
export const calculateSavings = (
planGroup: PlanTierGroup,
minimumSeats: number
): SavingsCalculation | null => {
if (!planGroup.yearly || !planGroup.monthly) return null;
const isEnterprise = planGroup.tier === 'enterprise';
const seatCount = minimumSeats || 1;
let monthlyAnnual: number;
let yearlyTotal: number;
if (isEnterprise && planGroup.monthly.seatPrice && planGroup.yearly.seatPrice) {
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
monthlyAnnual = (planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)) * 12;
yearlyTotal = planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount);
} else {
// Server: price * 12 vs yearly price
monthlyAnnual = planGroup.monthly.price * 12;
yearlyTotal = planGroup.yearly.price;
}
const savings = monthlyAnnual - yearlyTotal;
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
return {
amount: savings,
percent: savingsPercent,
currency: planGroup.yearly.currency
};
};