mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
+38
@@ -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 };
|
||||
};
|
||||
+156
@@ -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>
|
||||
);
|
||||
};
|
||||
+165
@@ -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
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user