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 <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh 2025-11-24 16:38:07 +00:00 committed by GitHub
parent 050408639b
commit 5d18184e46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2321 additions and 685 deletions

View File

@ -197,6 +197,7 @@
},
"edit": "Edit",
"delete": "Delete",
"never": "Never",
"username": "Username",
"password": "Password",
"welcome": "Welcome",
@ -4940,7 +4941,8 @@
"done": "Done",
"loading": "Loading...",
"back": "Back",
"continue": "Continue"
"continue": "Continue",
"error": "Error"
},
"config": {
"overview": {
@ -5368,9 +5370,12 @@
"featureComparison": "Feature Comparison",
"from": "From",
"perMonth": "/month",
"perSeat": "/seat",
"withServer": "+ Server Plan",
"licensedSeats": "Licensed: {{count}} seats",
"includedInCurrent": "Included in Your Plan",
"selectPlan": "Select Plan",
"manage": "Manage",
"manageSubscription": {
"description": "Manage your subscription, billing, and payment methods"
},
@ -5412,7 +5417,9 @@
"name": "Enterprise",
"highlight1": "Custom pricing",
"highlight2": "Dedicated support",
"highlight3": "Latest features"
"highlight3": "Latest features",
"requiresServer": "Requires Server",
"requiresServerMessage": "Please upgrade to the Server plan first before upgrading to Enterprise."
},
"feature": {
"title": "Feature",
@ -5440,7 +5447,24 @@
"manageBilling": "Manage Billing",
"portal": {
"error": "Failed to open billing portal"
}
},
"updateSeats": "Update Seats",
"updateEnterpriseSeats": "Update Enterprise Seats",
"currentSeats": "Current Seats",
"minimumSeats": "Minimum Seats",
"basedOnUsers": "(current users)",
"newSeatCount": "New Seat Count",
"newSeatCountDescription": "Select the number of seats for your enterprise licence",
"whatHappensNext": "What happens next?",
"stripePortalRedirect": "You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.",
"preparingUpdate": "Preparing seat update...",
"seatCountTooLow": "Seat count must be at least {{minimum}} (current number of users)",
"seatCountUnchanged": "Please select a different seat count",
"seatsUpdated": "Seats Updated",
"seatsUpdatedMessage": "Your enterprise seats have been updated to {{seats}}",
"updateProcessing": "Update Processing",
"updateProcessingMessage": "Your seat update is being processed. Please refresh in a few moments.",
"notEnterprise": "Seat management is only available for enterprise licences"
},
"upgradeBanner": {
"title": "Upgrade to Server Plan",
@ -5476,10 +5500,39 @@
"enterpriseNote": "Seats can be adjusted in checkout (1-1000).",
"installationId": "Installation ID",
"licenseKey": "Your License Key",
"licenseInstructions": "Enter this key in Settings → Admin Plan → License Key section",
"licenseInstructions": "This has been added to your installation. You will receive a copy in your email as well.",
"canCloseWindow": "You can now close this window.",
"licenseKeyProcessing": "License Key Processing",
"licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support."
"licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support.",
"perYear": "/year",
"perMonth": "/month",
"emailInvalid": "Please enter a valid email address",
"emailStage": {
"title": "Enter Your Email",
"description": "We'll use this to send your license key and receipts.",
"emailLabel": "Email Address",
"emailPlaceholder": "your@email.com",
"continue": "Continue",
"modalTitle": "Get Started - {{planName}}"
},
"planStage": {
"title": "Choose Your Billing Period",
"savingsNote": "Save {{percent}}% with annual billing",
"basePrice": "Base Price",
"seatPrice": "Per Seat",
"totalForSeats": "Total ({{count}} seats)",
"selectMonthly": "Select Monthly",
"selectYearly": "Select Yearly",
"savePercent": "Save {{percent}}%",
"savingsAmount": "You save {{amount}}",
"modalTitle": "Select Billing Period - {{planName}}",
"billedYearly": "Billed yearly at {{currency}}{{amount}}"
},
"paymentStage": {
"backToPlan": "Back to Plan Selection",
"selectedPlan": "Selected Plan",
"modalTitle": "Complete Payment - {{planName}}"
}
},
"firstLogin": {
"title": "First Time Login",

View File

@ -2,6 +2,7 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
@ -13,8 +14,10 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<AuthProvider>
<LicenseProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
<UpdateSeatsProvider>
<UpgradeBanner />
{children}
</UpdateSeatsProvider>
</CheckoutProvider>
</LicenseProvider>
</AuthProvider>

View File

@ -1,507 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
// 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;
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;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionId?: string;
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
// Default to yearly if available (better value), otherwise monthly
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<'idle' | 'polling' | 'ready' | 'timeout'>('idle');
// Refs for polling cleanup
const isMountedRef = React.useRef(true);
const pollingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly;
const createCheckoutSession = async () => {
if (!selectedPlan) {
setState({
status: 'error',
error: 'Selected plan period is not available',
});
return;
}
try {
setState({ status: 'loading' });
// 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)),
});
// 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({
status: 'ready',
clientSecret: response.clientSecret,
sessionId: response.sessionId,
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage,
});
onError?.(errorMessage);
}
};
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);
}
}, [onLicenseActivated]);
const handlePaymentComplete = async () => {
// Preserve state when changing status
setState(prev => ({ ...prev, status: '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 || '');
}
}
};
const handleClose = () => {
// Clear any active polling
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
setState({ status: 'idle' });
setPollingStatus('idle');
setCurrentLicenseKey(null);
setLicenseKey(null);
// Reset to default period on close
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
onClose();
};
const handlePeriodChange = (value: string) => {
setSelectedPeriod(value as 'monthly' | 'yearly');
// Reset state to trigger checkout reload
setState({ status: 'idle' });
};
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
};
}, []);
// Handle hosted checkout success - open directly to success state
useEffect(() => {
if (opened && hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
setCurrentLicenseKey('existing'); // Flag to indicate upgrade
setPollingStatus('ready');
} else if (hostedCheckoutSuccess.licenseKey) {
setLicenseKey(hostedCheckoutSuccess.licenseKey);
setPollingStatus('ready');
}
// Set to success state to show success UI
setState({ status: 'success' });
}
}, [opened, hostedCheckoutSuccess]);
// Initialize checkout when modal opens or period changes
useEffect(() => {
// Don't reset if we're showing success state (license key)
if (state.status === 'success') {
return;
}
// Skip initialization if opening for hosted checkout success
if (hostedCheckoutSuccess) {
return;
}
if (opened && state.status === 'idle') {
createCheckoutSession();
} else if (!opened) {
setState({ status: 'idle' });
}
}, [opened, selectedPeriod, state.status, hostedCheckoutSuccess]);
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 (state.status) {
case 'loading':
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>
);
case 'ready':
{
if (!state.clientSecret || !selectedPlan) return null;
// Build period selector data with prices
const periodData = [];
if (planGroup.monthly) {
const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice
? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat`
: `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`;
periodData.push({
value: 'monthly',
label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`,
});
}
if (planGroup.yearly) {
const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice
? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat`
: `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`;
periodData.push({
value: 'yearly',
label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`,
});
}
return (
<Grid gutter="md">
{/* Left: Period Selector - only show if both periods available */}
{periodData.length > 1 && (
<Grid.Col span={3}>
<Stack gap="sm" style={{ height: '100%' }}>
<Text size="sm" fw={600}>
{t('payment.billingPeriod', 'Billing Period')}
</Text>
<SegmentedControl
value={selectedPeriod}
onChange={handlePeriodChange}
data={periodData}
orientation="vertical"
fullWidth
/>
{selectedPlan.requiresSeats && selectedPlan.seatPrice && (
<Text size="xs" c="dimmed" mt="md">
{t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')}
</Text>
)}
</Stack>
</Grid.Col>
)}
{/* Right: Stripe Checkout */}
<Grid.Col span={periodData.length > 1 ? 9 : 12}>
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Grid.Col>
</Grid>
);
}
case 'success':
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',
'Enter this key in Settings → Admin Plan → License Key section'
)}
</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>
)}
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })}
</Text>
}
size="90%"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
minHeight: '85vh',
},
content: {
maxHeight: '95vh',
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { Button, ButtonProps } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useUpdateSeats } from '@app/contexts/UpdateSeatsContext';
interface UpdateSeatsButtonProps extends Omit<ButtonProps, 'onClick' | 'loading'> {
onSuccess?: () => void;
onError?: (error: string) => void;
}
export const UpdateSeatsButton: React.FC<UpdateSeatsButtonProps> = ({
onSuccess,
onError,
...buttonProps
}) => {
const { t } = useTranslation();
const { openUpdateSeats, isLoading } = useUpdateSeats();
const handleClick = async () => {
await openUpdateSeats({
onSuccess,
onError,
});
};
return (
<Button
variant="outline"
onClick={handleClick}
loading={isLoading}
{...buttonProps}
>
{t('billing.updateSeats', 'Update Seats')}
</Button>
);
};
export default UpdateSeatsButton;

View File

@ -0,0 +1,205 @@
import React, { useState, useEffect } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack, Group, NumberInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface UpdateSeatsModalProps {
opened: boolean;
onClose: () => void;
currentSeats: number;
minimumSeats: number;
_onSuccess?: () => void;
onError?: (error: string) => void;
onUpdateSeats?: (newSeats: number) => Promise<string>; // Returns billing portal URL
}
type UpdateState = {
status: 'idle' | 'loading' | 'error';
error?: string;
};
const UpdateSeatsModal: React.FC<UpdateSeatsModalProps> = ({
opened,
onClose,
currentSeats,
minimumSeats,
_onSuccess,
onError,
onUpdateSeats,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<UpdateState>({ status: 'idle' });
const [newSeatCount, setNewSeatCount] = useState<number>(minimumSeats);
// Reset seat count when modal opens
useEffect(() => {
if (opened) {
setNewSeatCount(minimumSeats);
setState({ status: 'idle' });
}
}, [opened, minimumSeats]);
const handleUpdateSeats = async () => {
if (!onUpdateSeats) {
setState({
status: 'error',
error: 'Update function not provided',
});
return;
}
if (newSeatCount < minimumSeats) {
setState({
status: 'error',
error: t(
'billing.seatCountTooLow',
'Seat count must be at least {{minimum}} (current number of users)',
{ minimum: minimumSeats }
),
});
return;
}
if (newSeatCount === currentSeats) {
setState({
status: 'error',
error: t('billing.seatCountUnchanged', 'Please select a different seat count'),
});
return;
}
try {
setState({ status: 'loading' });
// Call the update function (will call manage-billing)
const portalUrl = await onUpdateSeats(newSeatCount);
// Redirect to Stripe billing portal
console.log('Redirecting to Stripe billing portal:', portalUrl);
window.location.href = portalUrl;
// Note: No need to call onSuccess here since we're redirecting
// The return flow will handle success notification
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to update seat count';
setState({
status: 'error',
error: errorMessage,
});
onError?.(errorMessage);
}
};
const handleClose = () => {
setState({ status: 'idle' });
setNewSeatCount(currentSeats);
onClose();
};
const renderContent = () => {
if (state.status === 'loading') {
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('billing.preparingUpdate', 'Preparing seat update...')}
</Text>
</Stack>
);
}
return (
<Stack gap="lg">
{state.status === 'error' && (
<Alert color="red" title={t('common.error', 'Error')}>
{state.error}
</Alert>
)}
<Stack gap="md" mt="md">
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.currentSeats', 'Current Seats')}:
</Text>
<Text size="sm" fw={600}>
{currentSeats}
</Text>
</Group>
<Group justify="space-between">
<Text size="sm" fw={500}>
{t('billing.minimumSeats', 'Minimum Seats')}:
</Text>
<Text size="sm" c="dimmed">
{minimumSeats} {t('billing.basedOnUsers', '(current users)')}
</Text>
</Group>
</Stack>
<NumberInput
label={t('billing.newSeatCount', 'New Seat Count')}
description={t(
'billing.newSeatCountDescription',
'Select the number of seats for your enterprise license'
)}
value={newSeatCount}
onChange={(value) => setNewSeatCount(typeof value === 'number' ? value : minimumSeats)}
min={minimumSeats}
max={10000}
step={1}
size="md"
styles={{
input: {
fontSize: '1.5rem',
fontWeight: 500,
textAlign: 'center',
},
}}
/>
<Alert color="blue" title={t('billing.whatHappensNext', 'What happens next?')}>
<Text size="sm">
{t(
'billing.stripePortalRedirect',
'You will be redirected to Stripe\'s billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.'
)}
</Text>
</Alert>
<Group justify="flex-end" gap="sm">
<Button variant="outline" onClick={handleClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
onClick={handleUpdateSeats}
disabled={newSeatCount === currentSeats || newSeatCount < minimumSeats}
>
{t('billing.updateSeats', 'Update Seats')}
</Button>
</Group>
</Stack>
);
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('billing.updateEnterpriseSeats', 'Update Enterprise Seats')}
</Text>
}
size="md"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default UpdateSeatsModal;

View File

@ -75,7 +75,7 @@ const UpgradeBanner: React.FC = () => {
// Handle upgrade button click
const handleUpgrade = () => {
openCheckout('server', {
currency: 'gbp',
// Currency auto-detected from locale in CheckoutContext
minimumSeats: 1,
onSuccess: () => {
// Banner will auto-hide on next render when license is detected

View File

@ -9,7 +9,6 @@ import AdminPrivacySection from '@app/components/shared/config/configSections/Ad
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
@ -129,14 +128,6 @@ export const createConfigNavSections = (
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPlan',
label: 'Plan',

View File

@ -1,23 +1,25 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const [currency, setCurrency] = useState<string>('gbp');
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
@ -80,6 +82,36 @@ const AdminPlanSection: React.FC = () => {
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
];
const handleManageClick = useCallback(async () => {
try {
if (!licenseInfo?.licenseKey) {
throw new Error('No license key found. Please activate a license first.');
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey
);
// Open billing portal in new tab
window.open(response.url, '_blank');
} catch (error: any) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: error.message || 'Please try again or contact support.',
});
}
}, [licenseInfo, t]);
const handleCurrencyChange = useCallback((newCurrency: string) => {
setCurrency(newCurrency);
// Persist user's manual selection to localStorage
setCachedCurrency(newCurrency);
}, []);
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Only allow upgrades for server and enterprise tiers
@ -87,6 +119,20 @@ const AdminPlanSection: React.FC = () => {
return;
}
// Prevent free tier users from directly accessing enterprise (must have server first)
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'free' && planGroup.tier === 'enterprise') {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
),
});
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
@ -97,7 +143,7 @@ const AdminPlanSection: React.FC = () => {
},
});
},
[openCheckout, currency, refetch]
[openCheckout, currency, refetch, licenseInfo, t]
);
// Show static version if Stripe is not configured or there's an error
@ -129,40 +175,14 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selection & Manage Subscription */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</Group>
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
)}
</Stack>
</Paper>
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
onManageClick={handleManageClick}
currency={currency}
onCurrencyChange={handleCurrencyChange}
currencyOptions={currencyOptions}
/>
<Divider />

View File

@ -27,11 +27,14 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
@ -342,6 +345,17 @@ export default function PeopleSection() {
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
</Badge>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
<>
<Text size="sm" c="dimmed" span></Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
</>
)}
</Group>
)}
@ -494,9 +508,9 @@ export default function PeopleSection() {
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: 'Never'}
:t('never', 'Never')}
</Text>
</div>
}

View File

@ -1,21 +1,30 @@
import React, { useState, useMemo } from 'react';
import { Button, Collapse } from '@mantine/core';
import { Button, Collapse, Select, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: { value: string; label: string }[];
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
onManageClick,
currency,
onCurrencyChange,
currencyOptions,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
@ -58,25 +67,40 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<Group justify="space-between" align="flex-start" mb="xs">
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 0 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || 'usd')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
)}
</Group>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '1rem',
marginBottom: '0.5rem',
}}
>
{groupedPlans.map((group) => (
@ -86,7 +110,9 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
currentTier={currentTier}
onUpgradeClick={onUpgradeClick}
onManageClick={onManageClick}
/>
))}
</div>
@ -100,7 +126,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable plans={groupedPlans} />
<FeatureComparisonTable plans={groupedPlans} currentTier={currentTier} />
</Collapse>
</div>
);

View File

@ -12,9 +12,10 @@ interface PlanWithFeatures {
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: 'free' | 'server' | 'enterprise' | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans, currentTier }) => {
const { t } = useTranslation();
return (
@ -41,7 +42,7 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }
}}
>
{plan.name}
{plan.popular && (
{plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && (
<Badge
color="blue"
variant="filled"

View File

@ -1,44 +1,42 @@
import React from 'react';
import { Button, Card, Badge, Text, Stack, Divider } 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;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
currentTier?: 'free' | 'server' | 'enterprise' | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
}
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick }) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || '$';
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
@ -48,12 +46,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
</Text>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
£0
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.free.forever', 'Forever free')}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t('plan.free.forever', 'Forever free')}
/>
</div>
<Divider />
@ -82,48 +80,32 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
// 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 || '£';
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
if (yearly) {
displayPrice = Math.round(yearly.price / 12);
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
displayCurrency = yearly.currency;
}
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
monthly || undefined,
yearly || undefined
);
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
) : planGroup.popular ? (
<Badge
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? (
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
) : null}
<Stack gap="md" style={{ height: '100%' }}>
@ -140,29 +122,23 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t('plan.perSeat', '/seat')}
</Text>
<Text size="sm" c="dimmed" mt="xs">
+ {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')}
{t('plan.perMonth', '/month')} {t('plan.withServer', '+ Server Plan')}
</Text>
</>
) : (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')}
</Text>
</>
)}
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} mt="xs">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
<PriceDisplay
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t('plan.perMonth', '/month')}
/>
)}
</div>
@ -179,21 +155,40 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<div style={{ flexGrow: 1 }} />
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
{/* Single Upgrade Button */}
<Button
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
fullWidth
onClick={() => onUpgradeClick(planGroup)}
disabled={isCurrentTier || isDowngrade}
<Tooltip
label={t('plan.enterprise.requiresServer', 'Requires Server plan')}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
{isCurrentTier
? t('plan.current', 'Current Plan')
: isDowngrade
? t('plan.includedInCurrent', 'Included in Your Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={isDowngrade || isEnterpriseBlockedForFree}
>
{isCurrentTier
? t('plan.manage', 'Manage')
: isDowngrade
? t('plan.free.included', 'Included')
: isEnterpriseBlockedForFree
? t('plan.enterprise.requiresServer', 'Requires Server')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Tooltip>
</Stack>
</Stack>
</Card>
);

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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 };
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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 };
};

View File

@ -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';

View File

@ -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', 'your@email.com')}
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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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;
}

View File

@ -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),
};
}

View File

@ -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 });
}
};

View File

@ -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,
};
}

View File

@ -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
};
};

View File

@ -74,12 +74,14 @@ export const PLAN_HIGHLIGHTS = {
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Editing text in PDFs',
'Cancel anytime'
],
SERVER_YEARLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Editing text in PDFs',
'Save with annual billing'
],
ENTERPRISE_MONTHLY: [

View File

@ -2,16 +2,17 @@ import React, { createContext, useContext, useState, useCallback, useEffect, Rea
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import { StripeCheckout } from '@app/components/shared/stripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { useLicense } from '@app/contexts/LicenseContext';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency } from '@app/utils/currencyDetection';
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (defaults to 'gbp')
currency?: string; // Optional currency override (auto-detected from locale)
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
@ -35,15 +36,18 @@ interface CheckoutProviderProps {
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency = 'gbp'
defaultCurrency
}) => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentCurrency, setCurrentCurrency] = useState(() => {
// Use provided default or auto-detect from locale
return defaultCurrency || getPreferredCurrency(i18n.language);
});
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
isUpgrade: boolean;

View File

@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
@ -17,18 +17,45 @@ interface LicenseProviderProps {
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const configRef = useRef(config);
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Keep ref updated with latest config
useEffect(() => {
configRef.current = config;
}, [config]);
const refetchLicense = useCallback(async () => {
// Wait for config to load if it's not available yet
let currentConfig = configRef.current;
if (!currentConfig) {
console.log('[LicenseContext] Config not loaded yet, waiting...');
// Wait up to 5 seconds for config to load
const maxWait = 5000;
const startTime = Date.now();
while (!configRef.current && Date.now() - startTime < maxWait) {
await new Promise(resolve => setTimeout(resolve, 100));
currentConfig = configRef.current;
}
if (!currentConfig) {
console.error('[LicenseContext] Config failed to load after waiting');
setLoading(false);
return;
}
}
// Only fetch license info if user is an admin
if (!config?.isAdmin) {
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
if (!currentConfig.isAdmin) {
console.log('[LicenseContext] User is not an admin, skipping license fetch');
setLoading(false);
return;
}
console.log('[LicenseContext] Fetching license info');
try {
setLoading(true);
setError(null);
@ -42,7 +69,7 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
} finally {
setLoading(false);
}
}, [config?.isAdmin]);
}, []);
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
@ -51,12 +78,15 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
}
}, [config, refetchLicense]);
const contextValue: LicenseContextValue = {
licenseInfo,
loading,
error,
refetchLicense,
};
const contextValue: LicenseContextValue = useMemo(
() => ({
licenseInfo,
loading,
error,
refetchLicense,
}),
[licenseInfo, loading, error, refetchLicense]
);
return (
<LicenseContext.Provider value={contextValue}>

View File

@ -0,0 +1,222 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import licenseService, {} from '@app/services/licenseService';
import UpdateSeatsModal from '@app/components/shared/UpdateSeatsModal';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { useLicense } from '@app/contexts/LicenseContext';
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
export interface UpdateSeatsOptions {
onSuccess?: () => void;
onError?: (error: string) => void;
}
interface UpdateSeatsContextValue {
openUpdateSeats: (options?: UpdateSeatsOptions) => Promise<void>;
closeUpdateSeats: () => void;
isOpen: boolean;
isLoading: boolean;
}
const UpdateSeatsContext = createContext<UpdateSeatsContextValue | undefined>(undefined);
interface UpdateSeatsProviderProps {
children: ReactNode;
}
export const UpdateSeatsProvider: React.FC<UpdateSeatsProviderProps> = ({ children }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [currentSeats, setCurrentSeats] = useState<number>(1);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentOptions, setCurrentOptions] = useState<UpdateSeatsOptions>({});
// Handle return from Stripe billing portal
useEffect(() => {
const handleBillingReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const seatsUpdated = urlParams.get('seats_updated');
if (seatsUpdated === 'true') {
console.log('Seats updated successfully, syncing license with Keygen');
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
try {
// Wait a moment for Stripe webhook to process
await new Promise(resolve => setTimeout(resolve, 2000));
// Resync license with Keygen (not just local fetch)
console.log('Seat update detected - resyncing license with Keygen');
const activation = await resyncExistingLicense();
if (activation.success) {
console.log('License synced successfully after seat update');
// Refresh global license context
await refetchLicense();
// Get updated license info for notification
const updatedLicense = await licenseService.getLicenseInfo();
alert({
alertType: 'success',
title: t('billing.seatsUpdated', 'Seats Updated'),
body: t(
'billing.seatsUpdatedMessage',
'Your enterprise seats have been updated to {{seats}}',
{ seats: updatedLicense.maxUsers }
),
});
} else {
throw new Error(activation.error || 'Failed to sync license');
}
} catch (error) {
console.error('Failed to sync license after seat update:', error);
alert({
alertType: 'warning',
title: t('billing.updateProcessing', 'Update Processing'),
body: t(
'billing.updateProcessingMessage',
'Your seat update is being processed. Please refresh in a few moments.'
),
});
}
}
};
handleBillingReturn();
}, [t, refetchLicense]);
const openUpdateSeats = useCallback(async (options: UpdateSeatsOptions = {}) => {
try {
setIsLoading(true);
// Fetch current license info and user count
const [licenseInfo, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers(),
]);
// Validate this is an enterprise license
if (!licenseInfo || licenseInfo.licenseType !== 'ENTERPRISE') {
throw new Error(
t('billing.notEnterprise', 'Seat management is only available for enterprise licenses')
);
}
const currentLicenseSeats = licenseInfo.maxUsers || 1;
const currentUserCount = userData.totalUsers || 0;
// Minimum seats must be at least the current number of users
const calculatedMinSeats = Math.max(currentUserCount, 1);
console.log(
`Opening seat update: current seats=${currentLicenseSeats}, current users=${currentUserCount}, minimum=${calculatedMinSeats}`
);
setCurrentSeats(currentLicenseSeats);
setMinimumSeats(calculatedMinSeats);
setCurrentOptions(options);
setIsOpen(true);
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to open seat update';
console.error('Error opening seat update:', errorMessage);
alert({
alertType: 'error',
title: t('common.error', 'Error'),
body: errorMessage,
});
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
}, [t]);
const closeUpdateSeats = useCallback(() => {
setIsOpen(false);
setCurrentOptions({});
// Refetch license after modal closes to update UI
refetchLicense();
}, [refetchLicense]);
const handleUpdateSeats = useCallback(
async (newSeatCount: number): Promise<string> => {
try {
// Get current license key
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo?.licenseKey) {
throw new Error('No license key found');
}
console.log(`Updating seats from ${currentSeats} to ${newSeatCount}`);
// Call manage-billing function with new seat count
const portalUrl = await licenseService.updateEnterpriseSeats(
newSeatCount,
licenseInfo.licenseKey
);
return portalUrl;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to update seats';
console.error('Error updating seats:', errorMessage);
currentOptions.onError?.(errorMessage);
throw err;
}
},
[currentSeats, currentOptions]
);
const handleSuccess = useCallback(() => {
console.log('Seat update initiated successfully');
currentOptions.onSuccess?.();
}, [currentOptions]);
const handleError = useCallback(
(error: string) => {
console.error('Seat update error:', error);
currentOptions.onError?.(error);
},
[currentOptions]
);
return (
<UpdateSeatsContext.Provider
value={{
openUpdateSeats,
closeUpdateSeats,
isOpen,
isLoading,
}}
>
{children}
<UpdateSeatsModal
opened={isOpen}
onClose={closeUpdateSeats}
currentSeats={currentSeats}
minimumSeats={minimumSeats}
_onSuccess={handleSuccess}
onError={handleError}
onUpdateSeats={handleUpdateSeats}
/>
</UpdateSeatsContext.Provider>
);
};
export const useUpdateSeats = (): UpdateSeatsContextValue => {
const context = useContext(UpdateSeatsContext);
if (!context) {
throw new Error('useUpdateSeats must be used within an UpdateSeatsProvider');
}
return context;
};
export default UpdateSeatsContext;

View File

@ -43,6 +43,7 @@ export interface CheckoutSessionRequest {
current_license_key?: string; // Current license key for upgrades
requires_seats?: boolean; // Whether to add adjustable seat pricing
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
email?: string; // Customer email for checkout pre-fill
successUrl?: string;
cancelUrl?: string;
}
@ -109,7 +110,7 @@ const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
async getPlans(currency: string = 'usd'): Promise<PlansResponse> {
try {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
@ -320,6 +321,7 @@ const licenseService = {
current_license_key: request.current_license_key,
requires_seats: request.requires_seats,
seat_count: request.seat_count || 1,
email: request.email,
callback_base_url: baseUrl,
ui_mode: checkoutMode,
// For hosted checkout, provide success/cancel URLs
@ -443,6 +445,42 @@ const licenseService = {
throw error;
}
},
/**
* Update enterprise seat count
* Creates a Stripe billing portal session for confirming seat changes
* @param newSeatCount - New number of seats
* @param licenseKey - Current license key for authentication
* @returns Billing portal URL for confirming the change
*/
async updateEnterpriseSeats(newSeatCount: number, licenseKey: string): Promise<string> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Seat updates are not available.');
}
const baseUrl = window.location.origin;
const returnUrl = `${baseUrl}/settings/adminPlan?seats_updated=true`;
const { data, error } = await supabase.functions.invoke('manage-billing', {
body: {
return_url: returnUrl,
license_key: licenseKey,
self_hosted: true,
new_seat_count: newSeatCount,
},
});
if (error) {
throw new Error(`Failed to update seat count: ${error.message}`);
}
if (!data || !data.url) {
throw new Error('No billing portal URL returned');
}
return data.url;
},
};
/**

View File

@ -0,0 +1,142 @@
/**
* Currency detection utility
* Auto-detects user's preferred currency from browser locale
*/
const STORAGE_KEY = 'preferredCurrency';
/**
* Map of locale codes to currency codes
* Covers all major locales and their corresponding currencies
*/
const LOCALE_TO_CURRENCY_MAP: Record<string, string> = {
// English variants
'en-US': 'usd',
'en-CA': 'usd',
'en-AU': 'usd',
'en-NZ': 'usd',
'en-GB': 'gbp',
'en-IE': 'eur',
// European locales - Euro
'de-DE': 'eur',
'de-AT': 'eur',
'de-CH': 'eur',
'fr-FR': 'eur',
'fr-BE': 'eur',
'fr-CH': 'eur',
'it-IT': 'eur',
'es-ES': 'eur',
'pt-PT': 'eur',
'nl-NL': 'eur',
'nl-BE': 'eur',
'pl-PL': 'eur',
'ro-RO': 'eur',
'el-GR': 'eur',
'fi-FI': 'eur',
'sv-SE': 'eur',
'da-DK': 'eur',
'no-NO': 'eur',
// Chinese variants
'zh-CN': 'cny',
'zh-TW': 'cny',
'zh-HK': 'cny',
'zh-SG': 'cny',
// Indian locales
'hi-IN': 'inr',
'en-IN': 'inr',
'bn-IN': 'inr',
'te-IN': 'inr',
'ta-IN': 'inr',
'mr-IN': 'inr',
// Brazilian Portuguese
'pt-BR': 'brl',
// Indonesian
'id-ID': 'idr',
'jv-ID': 'idr',
// Other major locales defaulting to USD
'ja-JP': 'usd',
'ko-KR': 'usd',
'ru-RU': 'usd',
'ar-SA': 'usd',
'th-TH': 'usd',
'vi-VN': 'usd',
'tr-TR': 'usd',
};
/**
* Detect currency from browser locale
* @param locale - Browser locale string (e.g., 'en-US', 'de-DE')
* @returns Currency code ('usd', 'gbp', 'eur', etc.)
*/
export function detectCurrencyFromLocale(locale: string): string {
// Try exact match first
if (LOCALE_TO_CURRENCY_MAP[locale]) {
return LOCALE_TO_CURRENCY_MAP[locale];
}
// Try matching just the language code (e.g., 'en' from 'en-US')
const languageCode = locale.split('-')[0];
const matchingLocale = Object.keys(LOCALE_TO_CURRENCY_MAP).find(
key => key.startsWith(languageCode)
);
if (matchingLocale) {
return LOCALE_TO_CURRENCY_MAP[matchingLocale];
}
// Default fallback to USD
return 'usd';
}
/**
* Get cached currency preference from localStorage
* @returns Cached currency code or null if not set
*/
export function getCachedCurrency(): string | null {
try {
return localStorage.getItem(STORAGE_KEY);
} catch (error) {
console.warn('Failed to read currency from localStorage:', error);
return null;
}
}
/**
* Save currency preference to localStorage
* @param currency - Currency code to cache
*/
export function setCachedCurrency(currency: string): void {
try {
localStorage.setItem(STORAGE_KEY, currency);
} catch (error) {
console.warn('Failed to save currency to localStorage:', error);
}
}
/**
* Get preferred currency with auto-detection fallback
* Priority: localStorage > locale detection > default (USD)
* @param currentLocale - Current browser/i18n locale
* @returns Currency code
*/
export function getPreferredCurrency(currentLocale: string): string {
// 1. Check localStorage (user has previously selected)
const cached = getCachedCurrency();
if (cached) {
return cached;
}
// 2. Auto-detect from locale
const detected = detectCurrencyFromLocale(currentLocale);
// 3. Cache the detection for future visits
setCachedCurrency(detected);
return detected;
}

View File

@ -10,10 +10,10 @@
export function isSecureContext(): boolean {
// Allow localhost for development (works with both HTTP and HTTPS)
if (typeof window !== 'undefined') {
// const hostname = window.location.hostname;
const protocol = window.location.protocol;
// Localhost is considered secure for development
// const hostname = window.location.hostname;
// if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
// return true;
// }