mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
V2 Payment Features (#4974)
* Added ability to add seats to enterprise * first logged in date on people page * Remove Premium config section * Cleanup add seat flow * Shrink numbers in plan * Make editing text a server feature in the highlights * default to dollar pricing * clear checkout logic when crash * Recongnise location and find pricing * Payment successful page --------- Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
parent
050408639b
commit
5d18184e46
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
205
frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx
Normal file
205
frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx
Normal 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;
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -0,0 +1,309 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Text, Alert, Stack, Button, Group, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import licenseService from '@app/services/licenseService';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { StripeCheckoutProps } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
import { validateEmail, getModalTitle } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
|
||||
import { calculateSavings } from '@app/components/shared/stripeCheckout/utils/savingsCalculator';
|
||||
import { useCheckoutState } from '@app/components/shared/stripeCheckout/hooks/useCheckoutState';
|
||||
import { useCheckoutNavigation } from '@app/components/shared/stripeCheckout/hooks/useCheckoutNavigation';
|
||||
import { useLicensePolling } from '@app/components/shared/stripeCheckout/hooks/useLicensePolling';
|
||||
import { useCheckoutSession } from '@app/components/shared/stripeCheckout/hooks/useCheckoutSession';
|
||||
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
|
||||
import { PlanSelectionStage } from '@app/components/shared/stripeCheckout/stages/PlanSelectionStage';
|
||||
import { PaymentStage } from '@app/components/shared/stripeCheckout/stages/PaymentStage';
|
||||
import { SuccessStage } from '@app/components/shared/stripeCheckout/stages/SuccessStage';
|
||||
import { ErrorStage } from '@app/components/shared/stripeCheckout/stages/ErrorStage';
|
||||
|
||||
// Validate Stripe key (static validation, no dynamic imports)
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!STRIPE_KEY) {
|
||||
console.error(
|
||||
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
|
||||
'Please add it to your .env file. ' +
|
||||
'Get your key from https://dashboard.stripe.com/apikeys'
|
||||
);
|
||||
}
|
||||
|
||||
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
|
||||
console.error(
|
||||
`Invalid Stripe publishable key format. ` +
|
||||
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
|
||||
);
|
||||
}
|
||||
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planGroup,
|
||||
minimumSeats = 1,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLicenseActivated,
|
||||
hostedCheckoutSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Initialize all state via custom hook
|
||||
const checkoutState = useCheckoutState(planGroup);
|
||||
|
||||
// Initialize navigation hooks
|
||||
const navigation = useCheckoutNavigation(
|
||||
checkoutState.state,
|
||||
checkoutState.setState,
|
||||
checkoutState.stageHistory,
|
||||
checkoutState.setStageHistory
|
||||
);
|
||||
|
||||
// Initialize license polling hook
|
||||
const polling = useLicensePolling(
|
||||
checkoutState.isMountedRef,
|
||||
checkoutState.setPollingStatus,
|
||||
checkoutState.setLicenseKey,
|
||||
onLicenseActivated
|
||||
);
|
||||
|
||||
// Initialize checkout session hook
|
||||
const session = useCheckoutSession(
|
||||
checkoutState.selectedPlan,
|
||||
checkoutState.state,
|
||||
checkoutState.setState,
|
||||
checkoutState.installationId,
|
||||
checkoutState.setInstallationId,
|
||||
checkoutState.currentLicenseKey,
|
||||
checkoutState.setCurrentLicenseKey,
|
||||
checkoutState.setPollingStatus,
|
||||
minimumSeats,
|
||||
polling.pollForLicenseKey,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLicenseActivated
|
||||
);
|
||||
|
||||
// Calculate savings
|
||||
const savings = calculateSavings(planGroup, minimumSeats);
|
||||
|
||||
// Email submission handler
|
||||
const handleEmailSubmit = () => {
|
||||
const validation = validateEmail(checkoutState.emailInput);
|
||||
if (validation.valid) {
|
||||
checkoutState.setState(prev => ({ ...prev, email: checkoutState.emailInput }));
|
||||
navigation.goToStage('plan-selection');
|
||||
} else {
|
||||
checkoutState.setEmailError(validation.error);
|
||||
}
|
||||
};
|
||||
|
||||
// Plan selection handler
|
||||
const handlePlanSelect = (period: 'monthly' | 'yearly') => {
|
||||
checkoutState.setSelectedPeriod(period);
|
||||
navigation.goToStage('payment');
|
||||
};
|
||||
|
||||
// Close handler
|
||||
const handleClose = () => {
|
||||
// Clear any active polling
|
||||
if (checkoutState.pollingTimeoutRef.current) {
|
||||
clearTimeout(checkoutState.pollingTimeoutRef.current);
|
||||
checkoutState.pollingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
checkoutState.resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
checkoutState.isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
checkoutState.isMountedRef.current = false;
|
||||
if (checkoutState.pollingTimeoutRef.current) {
|
||||
clearTimeout(checkoutState.pollingTimeoutRef.current);
|
||||
checkoutState.pollingTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [checkoutState.isMountedRef, checkoutState.pollingTimeoutRef]);
|
||||
|
||||
// Initialize stage based on existing license
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
|
||||
// Handle hosted checkout success - open directly to success state
|
||||
if (hostedCheckoutSuccess) {
|
||||
console.log('Opening modal to success state for hosted checkout return');
|
||||
|
||||
// Set appropriate state based on upgrade vs new subscription
|
||||
if (hostedCheckoutSuccess.isUpgrade) {
|
||||
checkoutState.setCurrentLicenseKey('existing'); // Flag to indicate upgrade
|
||||
checkoutState.setPollingStatus('ready');
|
||||
} else if (hostedCheckoutSuccess.licenseKey) {
|
||||
checkoutState.setLicenseKey(hostedCheckoutSuccess.licenseKey);
|
||||
checkoutState.setPollingStatus('ready');
|
||||
}
|
||||
|
||||
// Set to success state to show success UI
|
||||
checkoutState.setState({ currentStage: 'success', loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing license to skip email stage
|
||||
const checkExistingLicense = async () => {
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
if (licenseInfo && licenseInfo.licenseKey) {
|
||||
// Has existing license - skip email stage
|
||||
console.log('Existing license detected - skipping email stage');
|
||||
checkoutState.setCurrentLicenseKey(licenseInfo.licenseKey);
|
||||
checkoutState.setState({ currentStage: 'plan-selection', loading: false });
|
||||
} else {
|
||||
// No license - start at email stage
|
||||
checkoutState.setState({ currentStage: 'email', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not check for existing license:', error);
|
||||
// Default to email stage if check fails
|
||||
checkoutState.setState({ currentStage: 'email', loading: false });
|
||||
}
|
||||
};
|
||||
|
||||
checkExistingLicense();
|
||||
}, [opened, hostedCheckoutSuccess, checkoutState.setCurrentLicenseKey, checkoutState.setPollingStatus, checkoutState.setLicenseKey, checkoutState.setState]);
|
||||
|
||||
// Trigger checkout session creation when entering payment stage
|
||||
useEffect(() => {
|
||||
if (
|
||||
checkoutState.state.currentStage === 'payment' &&
|
||||
!checkoutState.state.clientSecret &&
|
||||
!checkoutState.state.loading
|
||||
) {
|
||||
session.createCheckoutSession();
|
||||
}
|
||||
}, [checkoutState.state.currentStage, checkoutState.state.clientSecret, checkoutState.state.loading, session]);
|
||||
|
||||
// Render stage content
|
||||
const renderContent = () => {
|
||||
// Check if Stripe is configured
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.stripeNotConfiguredMessage',
|
||||
'Stripe payment integration is not configured. Please contact your administrator.'
|
||||
)}
|
||||
</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
switch (checkoutState.state.currentStage) {
|
||||
case 'email':
|
||||
return (
|
||||
<EmailStage
|
||||
emailInput={checkoutState.emailInput}
|
||||
setEmailInput={checkoutState.setEmailInput}
|
||||
emailError={checkoutState.emailError}
|
||||
onSubmit={handleEmailSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'plan-selection':
|
||||
return (
|
||||
<PlanSelectionStage
|
||||
planGroup={planGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
savings={savings}
|
||||
onSelectPlan={handlePlanSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'payment':
|
||||
return (
|
||||
<PaymentStage
|
||||
clientSecret={checkoutState.state.clientSecret || null}
|
||||
selectedPlan={checkoutState.selectedPlan}
|
||||
onPaymentComplete={session.handlePaymentComplete}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<SuccessStage
|
||||
pollingStatus={checkoutState.pollingStatus}
|
||||
currentLicenseKey={checkoutState.currentLicenseKey}
|
||||
licenseKey={checkoutState.licenseKey}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<ErrorStage
|
||||
error={checkoutState.state.error || 'An unknown error occurred'}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const canGoBack = checkoutState.stageHistory.length > 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canGoBack && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={navigation.goBack}
|
||||
aria-label={t('common.back', 'Back')}
|
||||
>
|
||||
<LocalIcon icon="arrow-back" width={20} height={20} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
<Text fw={600} size="lg">
|
||||
{getModalTitle(checkoutState.state.currentStage, planGroup.name, t)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
fullScreen={isMobile}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {},
|
||||
content: {
|
||||
maxHeight: '95vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Text, Stack } from '@mantine/core';
|
||||
import { formatPrice } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
|
||||
import { PRICE_FONT_WEIGHT } from '@app/components/shared/stripeCheckout/utils/cardStyles';
|
||||
|
||||
interface SimplePriceProps {
|
||||
mode: 'simple';
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface EnterprisePriceProps {
|
||||
mode: 'enterprise';
|
||||
basePrice: number;
|
||||
seatPrice: number;
|
||||
totalPrice?: number;
|
||||
currency: string;
|
||||
period: 'month' | 'year';
|
||||
seatCount?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
type PriceDisplayProps = SimplePriceProps | EnterprisePriceProps;
|
||||
|
||||
export const PriceDisplay: React.FC<PriceDisplayProps> = (props) => {
|
||||
if (props.mode === 'simple') {
|
||||
const fontSize = props.size || '2.25rem';
|
||||
return (
|
||||
<>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
|
||||
{formatPrice(props.price, props.currency)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{props.period}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Enterprise mode
|
||||
const { basePrice, seatPrice, totalPrice, currency, period, seatCount, size = 'md' } = props;
|
||||
const fontSize = size === 'lg' ? '2rem' : size === 'sm' ? 'md' : 'xl';
|
||||
const totalFontSize = size === 'lg' ? '2rem' : '2rem';
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Base Price
|
||||
</Text>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
|
||||
{formatPrice(basePrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{' '}
|
||||
/{period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Per Seat
|
||||
</Text>
|
||||
<Text size={fontSize} fw={PRICE_FONT_WEIGHT}>
|
||||
{formatPrice(seatPrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{' '}
|
||||
/seat/{period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
{totalPrice !== undefined && seatCount && (
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Total ({seatCount} seats)
|
||||
</Text>
|
||||
<Text size={totalFontSize} fw={PRICE_FONT_WEIGHT} style={{ lineHeight: 1 }}>
|
||||
{formatPrice(totalPrice, currency)}
|
||||
<Text component="span" size="sm" c="dimmed" fw={400}>
|
||||
{' '}
|
||||
/{period === 'year' ? 'month' : period}
|
||||
</Text>
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@mantine/core';
|
||||
|
||||
interface PricingBadgeProps {
|
||||
type: 'current' | 'popular' | 'savings';
|
||||
label: string;
|
||||
savingsPercent?: number;
|
||||
}
|
||||
|
||||
export const PricingBadge: React.FC<PricingBadgeProps> = ({ type, label }) => {
|
||||
const color = type === 'current' || type === 'savings' ? 'green' : 'blue';
|
||||
const size = type === 'savings' ? 'lg' : 'sm';
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={color}
|
||||
variant="filled"
|
||||
size={size}
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { CheckoutState, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* Stage navigation and history management hook
|
||||
*/
|
||||
export const useCheckoutNavigation = (
|
||||
state: CheckoutState,
|
||||
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||
stageHistory: CheckoutStage[],
|
||||
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>
|
||||
) => {
|
||||
const goToStage = useCallback((nextStage: CheckoutStage) => {
|
||||
setStageHistory(prev => [...prev, state.currentStage]);
|
||||
setState(prev => ({ ...prev, currentStage: nextStage }));
|
||||
}, [state.currentStage, setState, setStageHistory]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (stageHistory.length > 0) {
|
||||
const previousStage = stageHistory[stageHistory.length - 1];
|
||||
setStageHistory(prev => prev.slice(0, -1));
|
||||
|
||||
// Reset payment state when going back from payment stage
|
||||
if (state.currentStage === 'payment') {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
currentStage: previousStage,
|
||||
clientSecret: undefined,
|
||||
loading: false
|
||||
}));
|
||||
} else {
|
||||
setState(prev => ({ ...prev, currentStage: previousStage }));
|
||||
}
|
||||
}
|
||||
}, [stageHistory, state.currentStage, setState, setStageHistory]);
|
||||
|
||||
return { goToStage, goBack };
|
||||
};
|
||||
@ -0,0 +1,156 @@
|
||||
import { useCallback } from 'react';
|
||||
import licenseService, { PlanTier } from '@app/services/licenseService';
|
||||
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
||||
import { CheckoutState, PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* Checkout session creation and payment handling hook
|
||||
*/
|
||||
export const useCheckoutSession = (
|
||||
selectedPlan: PlanTier | null,
|
||||
state: CheckoutState,
|
||||
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||
installationId: string | null,
|
||||
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
currentLicenseKey: string | null,
|
||||
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||
minimumSeats: number,
|
||||
pollForLicenseKey: (installId: string) => Promise<void>,
|
||||
onSuccess?: (sessionId: string) => void,
|
||||
onError?: (error: string) => void,
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
|
||||
) => {
|
||||
const createCheckoutSession = useCallback(async () => {
|
||||
if (!selectedPlan) {
|
||||
setState({
|
||||
currentStage: 'error',
|
||||
error: 'Selected plan period is not available',
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
// Fetch installation ID from backend
|
||||
let fetchedInstallationId = installationId;
|
||||
if (!fetchedInstallationId) {
|
||||
fetchedInstallationId = await licenseService.getInstallationId();
|
||||
setInstallationId(fetchedInstallationId);
|
||||
}
|
||||
|
||||
// Fetch current license key for upgrades
|
||||
let existingLicenseKey: string | undefined;
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
if (licenseInfo && licenseInfo.licenseKey) {
|
||||
existingLicenseKey = licenseInfo.licenseKey;
|
||||
setCurrentLicenseKey(existingLicenseKey);
|
||||
console.log('Found existing license for upgrade');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch license info, proceeding as new license:', error);
|
||||
}
|
||||
|
||||
const response = await licenseService.createCheckoutSession({
|
||||
lookup_key: selectedPlan.lookupKey,
|
||||
installation_id: fetchedInstallationId,
|
||||
current_license_key: existingLicenseKey,
|
||||
requires_seats: selectedPlan.requiresSeats,
|
||||
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
|
||||
email: state.email, // Pass collected email from Stage 1
|
||||
});
|
||||
|
||||
// Check if we got a redirect URL (hosted checkout for HTTP)
|
||||
if (response.url) {
|
||||
console.log('Redirecting to Stripe hosted checkout:', response.url);
|
||||
// Redirect to Stripe's hosted checkout page
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use embedded checkout (HTTPS)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
clientSecret: response.clientSecret,
|
||||
sessionId: response.sessionId,
|
||||
loading: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||
setState({
|
||||
currentStage: 'error',
|
||||
error: errorMessage,
|
||||
loading: false,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
}, [
|
||||
selectedPlan,
|
||||
state.email,
|
||||
installationId,
|
||||
minimumSeats,
|
||||
setState,
|
||||
setInstallationId,
|
||||
setCurrentLicenseKey,
|
||||
onError
|
||||
]);
|
||||
|
||||
const handlePaymentComplete = useCallback(async () => {
|
||||
// Preserve state when changing stage
|
||||
setState(prev => ({ ...prev, currentStage: 'success' }));
|
||||
|
||||
// Check if this is an upgrade (existing license key) or new plan
|
||||
if (currentLicenseKey) {
|
||||
// UPGRADE FLOW: Resync existing license with Keygen
|
||||
console.log('Upgrade detected - resyncing existing license with Keygen');
|
||||
setPollingStatus('polling');
|
||||
|
||||
const activation = await resyncExistingLicense({
|
||||
isMounted: () => true, // Modal is open, no need to check
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (activation.success) {
|
||||
console.log(`License upgraded successfully: ${activation.licenseType}`);
|
||||
setPollingStatus('ready');
|
||||
} else {
|
||||
console.error('Failed to sync upgraded license:', activation.error);
|
||||
setPollingStatus('timeout');
|
||||
}
|
||||
|
||||
// Notify parent (don't wait - upgrade is complete)
|
||||
onSuccess?.(state.sessionId || '');
|
||||
} else {
|
||||
// NEW PLAN FLOW: Poll for new license key
|
||||
console.log('New subscription - polling for license key');
|
||||
|
||||
if (installationId) {
|
||||
pollForLicenseKey(installationId).finally(() => {
|
||||
// Only notify parent after polling completes or times out
|
||||
onSuccess?.(state.sessionId || '');
|
||||
});
|
||||
} else {
|
||||
// No installation ID, notify immediately
|
||||
onSuccess?.(state.sessionId || '');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentLicenseKey,
|
||||
installationId,
|
||||
state.sessionId,
|
||||
setState,
|
||||
setPollingStatus,
|
||||
pollForLicenseKey,
|
||||
onSuccess,
|
||||
onLicenseActivated
|
||||
]);
|
||||
|
||||
return {
|
||||
createCheckoutSession,
|
||||
handlePaymentComplete,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,78 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { CheckoutState, PollingStatus, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* Centralized state management hook for checkout flow
|
||||
*/
|
||||
export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||
const [state, setState] = useState<CheckoutState>({
|
||||
currentStage: 'email',
|
||||
loading: false
|
||||
});
|
||||
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
|
||||
const [emailInput, setEmailInput] = useState<string>('');
|
||||
const [emailError, setEmailError] = useState<string>('');
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
|
||||
planGroup.yearly ? 'yearly' : 'monthly'
|
||||
);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
|
||||
const [licenseKey, setLicenseKey] = useState<string | null>(null);
|
||||
const [pollingStatus, setPollingStatus] = useState<PollingStatus>('idle');
|
||||
|
||||
// Refs for polling cleanup
|
||||
const isMountedRef = useRef(true);
|
||||
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Get the selected plan based on period
|
||||
const selectedPlan = selectedPeriod === 'yearly'
|
||||
? planGroup.yearly
|
||||
: planGroup.monthly;
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setState({
|
||||
currentStage: 'email',
|
||||
loading: false,
|
||||
clientSecret: undefined,
|
||||
sessionId: undefined,
|
||||
error: undefined
|
||||
});
|
||||
setStageHistory([]);
|
||||
setEmailInput('');
|
||||
setEmailError('');
|
||||
setPollingStatus('idle');
|
||||
setCurrentLicenseKey(null);
|
||||
setLicenseKey(null);
|
||||
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
|
||||
}, [planGroup]);
|
||||
|
||||
return {
|
||||
// State
|
||||
state,
|
||||
setState,
|
||||
stageHistory,
|
||||
setStageHistory,
|
||||
emailInput,
|
||||
setEmailInput,
|
||||
emailError,
|
||||
setEmailError,
|
||||
selectedPeriod,
|
||||
setSelectedPeriod,
|
||||
installationId,
|
||||
setInstallationId,
|
||||
currentLicenseKey,
|
||||
setCurrentLicenseKey,
|
||||
licenseKey,
|
||||
setLicenseKey,
|
||||
pollingStatus,
|
||||
setPollingStatus,
|
||||
// Refs
|
||||
isMountedRef,
|
||||
pollingTimeoutRef,
|
||||
// Computed
|
||||
selectedPlan,
|
||||
// Actions
|
||||
resetState,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,41 @@
|
||||
import { useCallback } from 'react';
|
||||
import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils';
|
||||
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* License key polling and activation logic hook
|
||||
*/
|
||||
export const useLicensePolling = (
|
||||
isMountedRef: React.RefObject<boolean>,
|
||||
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
|
||||
) => {
|
||||
const pollForLicenseKey = useCallback(async (installId: string) => {
|
||||
// Use shared polling utility
|
||||
const result = await pollLicenseKeyWithBackoff(installId, {
|
||||
isMounted: () => isMountedRef.current!,
|
||||
onStatusChange: setPollingStatus,
|
||||
});
|
||||
|
||||
if (result.success && result.licenseKey) {
|
||||
setLicenseKey(result.licenseKey);
|
||||
|
||||
// Activate the license key
|
||||
const activation = await activateLicenseKey(result.licenseKey, {
|
||||
isMounted: () => isMountedRef.current!,
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (!activation.success) {
|
||||
console.error('Failed to activate license key:', activation.error);
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
console.warn('License key polling timed out');
|
||||
} else if (result.error) {
|
||||
console.error('License key polling failed:', result.error);
|
||||
}
|
||||
}, [isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated]);
|
||||
|
||||
return { pollForLicenseKey };
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
export { default as StripeCheckout } from '@app/components/shared/stripeCheckout/StripeCheckout';
|
||||
export type {
|
||||
StripeCheckoutProps,
|
||||
CheckoutStage,
|
||||
CheckoutState,
|
||||
PollingStatus,
|
||||
SavingsCalculation
|
||||
} from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, TextInput, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EmailStageProps {
|
||||
emailInput: string;
|
||||
setEmailInput: (email: string) => void;
|
||||
emailError: string;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export const EmailStage: React.FC<EmailStageProps> = ({
|
||||
emailInput,
|
||||
setEmailInput,
|
||||
emailError,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg" style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem 0' }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('payment.emailStage.description', "We'll use this to send your license key and receipts.")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label={t('payment.emailStage.emailLabel', 'Email Address')}
|
||||
placeholder={t('payment.emailStage.emailPlaceholder', '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>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Alert, Stack, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ErrorStageProps {
|
||||
error: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{error}</Text>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Loader } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
|
||||
// Load Stripe once
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface PaymentStageProps {
|
||||
clientSecret: string | null;
|
||||
selectedPlan: PlanTier | null;
|
||||
onPaymentComplete: () => void;
|
||||
}
|
||||
|
||||
export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||
clientSecret,
|
||||
selectedPlan,
|
||||
onPaymentComplete,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Show loading while creating checkout session
|
||||
if (!clientSecret || !selectedPlan) {
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.preparing', 'Preparing your checkout...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
<Text size="sm" c="red">
|
||||
Stripe is not configured properly.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
|
||||
{/* Stripe Embedded Checkout */}
|
||||
<EmbeddedCheckoutProvider
|
||||
key={clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: clientSecret,
|
||||
onComplete: onPaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
|
||||
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
|
||||
import { formatPrice, calculateMonthlyEquivalent, calculateTotalWithSeats } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
|
||||
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
|
||||
|
||||
interface PlanSelectionStageProps {
|
||||
planGroup: PlanTierGroup;
|
||||
minimumSeats: number;
|
||||
savings: SavingsCalculation | null;
|
||||
onSelectPlan: (period: 'monthly' | 'yearly') => void;
|
||||
}
|
||||
|
||||
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
|
||||
planGroup,
|
||||
minimumSeats,
|
||||
savings,
|
||||
onSelectPlan,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isEnterprise = planGroup.tier === 'enterprise';
|
||||
const seatCount = minimumSeats || 1;
|
||||
|
||||
return (
|
||||
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
|
||||
|
||||
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
|
||||
{/* Monthly Option */}
|
||||
{planGroup.monthly && (
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle()}
|
||||
onClick={() => onSelectPlan('monthly')}
|
||||
>
|
||||
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('payment.monthly', 'Monthly')}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isEnterprise && planGroup.monthly.seatPrice ? (
|
||||
<PriceDisplay
|
||||
mode="enterprise"
|
||||
basePrice={planGroup.monthly.price}
|
||||
seatPrice={planGroup.monthly.seatPrice}
|
||||
totalPrice={calculateTotalWithSeats(planGroup.monthly.price, planGroup.monthly.seatPrice, seatCount)}
|
||||
currency={planGroup.monthly.currency}
|
||||
period="month"
|
||||
seatCount={seatCount}
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={planGroup.monthly?.price || 0}
|
||||
currency={planGroup.monthly?.currency || '£'}
|
||||
period={t('payment.perMonth', '/month')}
|
||||
size="2.5rem"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||
<Button variant="light" fullWidth size="lg">
|
||||
{t('payment.planStage.selectMonthly', 'Select Monthly')}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{/* Yearly Option */}
|
||||
{planGroup.yearly && (
|
||||
<Grid.Col span={6}>
|
||||
<Paper
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
style={getClickablePaperStyle(!!savings)}
|
||||
onClick={() => onSelectPlan('yearly')}
|
||||
>
|
||||
{savings && (
|
||||
<PricingBadge
|
||||
type="savings"
|
||||
label={t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('payment.yearly', 'Yearly')}
|
||||
</Text>
|
||||
|
||||
<Divider />
|
||||
|
||||
{isEnterprise && planGroup.yearly.seatPrice ? (
|
||||
<Stack gap="sm">
|
||||
<PriceDisplay
|
||||
mode="enterprise"
|
||||
basePrice={planGroup.yearly.price}
|
||||
seatPrice={planGroup.yearly.seatPrice}
|
||||
totalPrice={calculateMonthlyEquivalent(
|
||||
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount)
|
||||
)}
|
||||
currency={planGroup.yearly.currency}
|
||||
period="year"
|
||||
seatCount={seatCount}
|
||||
size="sm"
|
||||
/>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
|
||||
currency: planGroup.yearly.currency,
|
||||
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2)
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
<PriceDisplay
|
||||
mode="simple"
|
||||
price={calculateMonthlyEquivalent(planGroup.yearly?.price || 0)}
|
||||
currency={planGroup.yearly?.currency || '£'}
|
||||
period={t('payment.perMonth', '/month')}
|
||||
size="2.5rem"
|
||||
/>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
|
||||
currency: planGroup.yearly?.currency,
|
||||
amount: planGroup.yearly?.price.toFixed(2)
|
||||
})}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{savings && (
|
||||
<Alert color="green" variant="light" p="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.planStage.savingsAmount', 'You save {{amount}}', {
|
||||
amount: formatPrice(savings.amount, savings.currency)
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||
<Button variant="filled" fullWidth size="lg">
|
||||
{t('payment.planStage.selectYearly', 'Select Yearly')}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,101 @@
|
||||
import React from 'react';
|
||||
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
interface SuccessStageProps {
|
||||
pollingStatus: PollingStatus;
|
||||
currentLicenseKey: string | null;
|
||||
licenseKey: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const SuccessStage: React.FC<SuccessStageProps> = ({
|
||||
pollingStatus,
|
||||
currentLicenseKey,
|
||||
licenseKey,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.successMessage',
|
||||
'Your subscription has been activated successfully.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* License Key Polling Status */}
|
||||
{pollingStatus === 'polling' && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentLicenseKey
|
||||
? t('payment.syncingLicense', 'Syncing your upgraded license...')
|
||||
: t('payment.generatingLicense', 'Generating your license key...')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
|
||||
<Paper withBorder p="md" radius="md" bg="gray.1">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.licenseKey', 'Your License Key')}
|
||||
</Text>
|
||||
<Code block>{licenseKey}</Code>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(licenseKey)}
|
||||
>
|
||||
{t('common.copy', 'Copy to Clipboard')}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'payment.licenseInstructions',
|
||||
'This has been added to your installation. You will receive a copy in your email as well.'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && currentLicenseKey && (
|
||||
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.upgradeCompleteMessage',
|
||||
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'timeout' && (
|
||||
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.licenseDelayedMessage',
|
||||
'Your license key is being generated. Please check your email shortly or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.canCloseWindow', 'You can now close this window.')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Button onClick={onClose} mt="md">
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,34 @@
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
|
||||
export interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planGroup: PlanTierGroup;
|
||||
minimumSeats?: number;
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export type CheckoutStage = 'email' | 'plan-selection' | 'payment' | 'success' | 'error';
|
||||
|
||||
export type CheckoutState = {
|
||||
currentStage: CheckoutStage;
|
||||
email?: string;
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type PollingStatus = 'idle' | 'polling' | 'ready' | 'timeout';
|
||||
|
||||
export interface SavingsCalculation {
|
||||
amount: number;
|
||||
percent: number;
|
||||
currency: string;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { CSSProperties } from 'react';
|
||||
|
||||
/**
|
||||
* Shared styling utilities for plan cards
|
||||
*/
|
||||
|
||||
export const CARD_MIN_HEIGHT = '400px';
|
||||
export const PRICE_FONT_WEIGHT = 600;
|
||||
|
||||
/**
|
||||
* Get card border style based on state
|
||||
*/
|
||||
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
|
||||
return {
|
||||
borderColor: isHighlighted ? 'var(--mantine-color-green-6)' : undefined,
|
||||
borderWidth: isHighlighted ? '2px' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base card style
|
||||
*/
|
||||
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
|
||||
return {
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: CARD_MIN_HEIGHT,
|
||||
...getCardBorderStyle(isHighlighted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get clickable paper style
|
||||
*/
|
||||
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
|
||||
return {
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
...getCardBorderStyle(isHighlighted),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import { CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* Validate email address format
|
||||
*/
|
||||
export const validateEmail = (email: string): { valid: boolean; error: string } => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Please enter a valid email address'
|
||||
};
|
||||
}
|
||||
return { valid: true, error: '' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get dynamic modal title based on current stage
|
||||
*/
|
||||
export const getModalTitle = (
|
||||
stage: CheckoutStage,
|
||||
planName: string,
|
||||
t: TFunction
|
||||
): string => {
|
||||
switch (stage) {
|
||||
case 'email':
|
||||
return t('payment.emailStage.modalTitle', 'Get Started - {{planName}}', { planName });
|
||||
case 'plan-selection':
|
||||
return t('payment.planStage.modalTitle', 'Select Billing Period - {{planName}}', { planName });
|
||||
case 'payment':
|
||||
return t('payment.paymentStage.modalTitle', 'Complete Payment - {{planName}}', { planName });
|
||||
case 'success':
|
||||
return t('payment.success', 'Payment Successful!');
|
||||
case 'error':
|
||||
return t('payment.error', 'Payment Error');
|
||||
default:
|
||||
return t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName });
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Shared pricing utilities for plan cards and checkout
|
||||
*/
|
||||
|
||||
export interface PriceCalculation {
|
||||
displayPrice: number;
|
||||
displaySeatPrice?: number;
|
||||
displayCurrency: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate monthly equivalent from yearly price
|
||||
*/
|
||||
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
|
||||
return yearlyPrice / 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total price including seats
|
||||
*/
|
||||
export function calculateTotalWithSeats(
|
||||
basePrice: number,
|
||||
seatPrice: number | undefined,
|
||||
seatCount: number
|
||||
): number {
|
||||
if (seatPrice === undefined) return basePrice;
|
||||
return basePrice + seatPrice * seatCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price with currency symbol
|
||||
*/
|
||||
export function formatPrice(amount: number, currency: string, decimals: number = 2): string {
|
||||
return `${currency}${amount.toFixed(decimals)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate display pricing for a plan, showing yearly price divided by 12
|
||||
* to show the lowest monthly equivalent
|
||||
*/
|
||||
export function calculateDisplayPricing(
|
||||
monthly?: { price: number; seatPrice?: number; currency: string },
|
||||
yearly?: { price: number; seatPrice?: number; currency: string }
|
||||
): PriceCalculation {
|
||||
// Default to monthly if no yearly exists
|
||||
if (!yearly) {
|
||||
return {
|
||||
displayPrice: monthly?.price || 0,
|
||||
displaySeatPrice: monthly?.seatPrice,
|
||||
displayCurrency: monthly?.currency || '£',
|
||||
};
|
||||
}
|
||||
|
||||
// Use yearly price divided by 12 for best value display
|
||||
return {
|
||||
displayPrice: calculateMonthlyEquivalent(yearly.price),
|
||||
displaySeatPrice: yearly.seatPrice ? calculateMonthlyEquivalent(yearly.seatPrice) : undefined,
|
||||
displayCurrency: yearly.currency,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||
|
||||
/**
|
||||
* Calculate savings for yearly vs monthly plans
|
||||
* Returns null if both monthly and yearly plans are not available
|
||||
*/
|
||||
export const calculateSavings = (
|
||||
planGroup: PlanTierGroup,
|
||||
minimumSeats: number
|
||||
): SavingsCalculation | null => {
|
||||
if (!planGroup.yearly || !planGroup.monthly) return null;
|
||||
|
||||
const isEnterprise = planGroup.tier === 'enterprise';
|
||||
const seatCount = minimumSeats || 1;
|
||||
|
||||
let monthlyAnnual: number;
|
||||
let yearlyTotal: number;
|
||||
|
||||
if (isEnterprise && planGroup.monthly.seatPrice && planGroup.yearly.seatPrice) {
|
||||
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
|
||||
monthlyAnnual = (planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)) * 12;
|
||||
yearlyTotal = planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount);
|
||||
} else {
|
||||
// Server: price * 12 vs yearly price
|
||||
monthlyAnnual = planGroup.monthly.price * 12;
|
||||
yearlyTotal = planGroup.yearly.price;
|
||||
}
|
||||
|
||||
const savings = monthlyAnnual - yearlyTotal;
|
||||
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
|
||||
|
||||
return {
|
||||
amount: savings,
|
||||
percent: savingsPercent,
|
||||
currency: planGroup.yearly.currency
|
||||
};
|
||||
};
|
||||
@ -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: [
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}>
|
||||
|
||||
222
frontend/src/proprietary/contexts/UpdateSeatsContext.tsx
Normal file
222
frontend/src/proprietary/contexts/UpdateSeatsContext.tsx
Normal 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;
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
142
frontend/src/proprietary/utils/currencyDetection.ts
Normal file
142
frontend/src/proprietary/utils/currencyDetection.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
// }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user