mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
V2 Payment Features (#4974)
* Added ability to add seats to enterprise * first logged in date on people page * Remove Premium config section * Cleanup add seat flow * Shrink numbers in plan * Make editing text a server feature in the highlights * default to dollar pricing * clear checkout logic when crash * Recongnise location and find pricing * Payment successful page --------- Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
@@ -197,6 +197,7 @@
|
|||||||
},
|
},
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"never": "Never",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
@@ -4940,7 +4941,8 @@
|
|||||||
"done": "Done",
|
"done": "Done",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"continue": "Continue"
|
"continue": "Continue",
|
||||||
|
"error": "Error"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"overview": {
|
"overview": {
|
||||||
@@ -5368,9 +5370,12 @@
|
|||||||
"featureComparison": "Feature Comparison",
|
"featureComparison": "Feature Comparison",
|
||||||
"from": "From",
|
"from": "From",
|
||||||
"perMonth": "/month",
|
"perMonth": "/month",
|
||||||
|
"perSeat": "/seat",
|
||||||
|
"withServer": "+ Server Plan",
|
||||||
"licensedSeats": "Licensed: {{count}} seats",
|
"licensedSeats": "Licensed: {{count}} seats",
|
||||||
"includedInCurrent": "Included in Your Plan",
|
"includedInCurrent": "Included in Your Plan",
|
||||||
"selectPlan": "Select Plan",
|
"selectPlan": "Select Plan",
|
||||||
|
"manage": "Manage",
|
||||||
"manageSubscription": {
|
"manageSubscription": {
|
||||||
"description": "Manage your subscription, billing, and payment methods"
|
"description": "Manage your subscription, billing, and payment methods"
|
||||||
},
|
},
|
||||||
@@ -5412,7 +5417,9 @@
|
|||||||
"name": "Enterprise",
|
"name": "Enterprise",
|
||||||
"highlight1": "Custom pricing",
|
"highlight1": "Custom pricing",
|
||||||
"highlight2": "Dedicated support",
|
"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": {
|
"feature": {
|
||||||
"title": "Feature",
|
"title": "Feature",
|
||||||
@@ -5440,7 +5447,24 @@
|
|||||||
"manageBilling": "Manage Billing",
|
"manageBilling": "Manage Billing",
|
||||||
"portal": {
|
"portal": {
|
||||||
"error": "Failed to open 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": {
|
"upgradeBanner": {
|
||||||
"title": "Upgrade to Server Plan",
|
"title": "Upgrade to Server Plan",
|
||||||
@@ -5476,10 +5500,39 @@
|
|||||||
"enterpriseNote": "Seats can be adjusted in checkout (1-1000).",
|
"enterpriseNote": "Seats can be adjusted in checkout (1-1000).",
|
||||||
"installationId": "Installation ID",
|
"installationId": "Installation ID",
|
||||||
"licenseKey": "Your License Key",
|
"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.",
|
"canCloseWindow": "You can now close this window.",
|
||||||
"licenseKeyProcessing": "License Key Processing",
|
"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": "[email protected]",
|
||||||
|
"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": {
|
"firstLogin": {
|
||||||
"title": "First Time Login",
|
"title": "First Time Login",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo
|
|||||||
import { AuthProvider } from "@app/auth/UseSession";
|
import { AuthProvider } from "@app/auth/UseSession";
|
||||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||||
|
import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext";
|
||||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||||
|
|
||||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||||
@@ -13,8 +14,10 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<LicenseProvider>
|
<LicenseProvider>
|
||||||
<CheckoutProvider>
|
<CheckoutProvider>
|
||||||
<UpgradeBanner />
|
<UpdateSeatsProvider>
|
||||||
{children}
|
<UpgradeBanner />
|
||||||
|
{children}
|
||||||
|
</UpdateSeatsProvider>
|
||||||
</CheckoutProvider>
|
</CheckoutProvider>
|
||||||
</LicenseProvider>
|
</LicenseProvider>
|
||||||
</AuthProvider>
|
</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;
|
||||||
@@ -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
|
// Handle upgrade button click
|
||||||
const handleUpgrade = () => {
|
const handleUpgrade = () => {
|
||||||
openCheckout('server', {
|
openCheckout('server', {
|
||||||
currency: 'gbp',
|
// Currency auto-detected from locale in CheckoutContext
|
||||||
minimumSeats: 1,
|
minimumSeats: 1,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Banner will auto-hide on next render when license is detected
|
// 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 AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
|
||||||
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
||||||
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
|
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 AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
|
||||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||||
@@ -129,14 +128,6 @@ export const createConfigNavSections = (
|
|||||||
sections.push({
|
sections.push({
|
||||||
title: 'Licensing & Analytics',
|
title: 'Licensing & Analytics',
|
||||||
items: [
|
items: [
|
||||||
{
|
|
||||||
key: 'adminPremium',
|
|
||||||
label: 'Premium',
|
|
||||||
icon: 'star-rounded',
|
|
||||||
component: <AdminPremiumSection />,
|
|
||||||
disabled: requiresLogin,
|
|
||||||
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'adminPlan',
|
key: 'adminPlan',
|
||||||
label: 'Plan',
|
label: 'Plan',
|
||||||
|
|||||||
+57
-37
@@ -1,23 +1,25 @@
|
|||||||
import React, { useState, useCallback, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { usePlans } from '@app/hooks/usePlans';
|
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 { useCheckout } from '@app/contexts/CheckoutContext';
|
||||||
import { useLicense } from '@app/contexts/LicenseContext';
|
import { useLicense } from '@app/contexts/LicenseContext';
|
||||||
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
||||||
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
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 { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||||
|
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
|
||||||
|
|
||||||
const AdminPlanSection: React.FC = () => {
|
const AdminPlanSection: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { openCheckout } = useCheckout();
|
const { openCheckout } = useCheckout();
|
||||||
const { licenseInfo, refetchLicense } = useLicense();
|
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 [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||||
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
|
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
|
||||||
@@ -80,6 +82,36 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
|
{ 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(
|
const handleUpgradeClick = useCallback(
|
||||||
(planGroup: PlanTierGroup) => {
|
(planGroup: PlanTierGroup) => {
|
||||||
// Only allow upgrades for server and enterprise tiers
|
// Only allow upgrades for server and enterprise tiers
|
||||||
@@ -87,6 +119,20 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
return;
|
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
|
// Use checkout context to open checkout modal
|
||||||
openCheckout(planGroup.tier, {
|
openCheckout(planGroup.tier, {
|
||||||
currency,
|
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
|
// Show static version if Stripe is not configured or there's an error
|
||||||
@@ -129,40 +175,14 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
<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
|
<AvailablePlansSection
|
||||||
plans={plans}
|
plans={plans}
|
||||||
currentLicenseInfo={licenseInfo}
|
currentLicenseInfo={licenseInfo}
|
||||||
onUpgradeClick={handleUpgradeClick}
|
onUpgradeClick={handleUpgradeClick}
|
||||||
|
onManageClick={handleManageClick}
|
||||||
|
currency={currency}
|
||||||
|
onCurrencyChange={handleCurrencyChange}
|
||||||
|
currencyOptions={currencyOptions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
|
|||||||
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
|
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
|
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() {
|
export default function PeopleSection() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { loginEnabled } = useLoginRequired();
|
const { loginEnabled } = useLoginRequired();
|
||||||
|
const { licenseInfo: globalLicenseInfo } = useLicense();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -342,6 +345,17 @@ export default function PeopleSection() {
|
|||||||
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Enterprise Seat Management Button */}
|
||||||
|
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
|
||||||
|
<>
|
||||||
|
<Text size="sm" c="dimmed" span>•</Text>
|
||||||
|
<UpdateSeatsButton
|
||||||
|
size="xs"
|
||||||
|
onSuccess={fetchData}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -494,9 +508,9 @@ export default function PeopleSection() {
|
|||||||
<div>
|
<div>
|
||||||
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
|
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
|
||||||
<Text size="xs">
|
<Text size="xs">
|
||||||
Last Activity: {user.lastRequest
|
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
|
||||||
? new Date(user.lastRequest).toLocaleString()
|
? new Date(user.lastRequest).toLocaleString()
|
||||||
: 'Never'}
|
:t('never', 'Never')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-15
@@ -1,21 +1,30 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||||
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
||||||
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
||||||
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
|
|
||||||
interface AvailablePlansSectionProps {
|
interface AvailablePlansSectionProps {
|
||||||
plans: PlanTier[];
|
plans: PlanTier[];
|
||||||
currentPlanId?: string;
|
currentPlanId?: string;
|
||||||
currentLicenseInfo?: LicenseInfo | null;
|
currentLicenseInfo?: LicenseInfo | null;
|
||||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||||
|
onManageClick?: () => void;
|
||||||
|
currency?: string;
|
||||||
|
onCurrencyChange?: (currency: string) => void;
|
||||||
|
currencyOptions?: { value: string; label: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||||
plans,
|
plans,
|
||||||
currentLicenseInfo,
|
currentLicenseInfo,
|
||||||
onUpgradeClick,
|
onUpgradeClick,
|
||||||
|
onManageClick,
|
||||||
|
currency,
|
||||||
|
onCurrencyChange,
|
||||||
|
currencyOptions,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showComparison, setShowComparison] = useState(false);
|
const [showComparison, setShowComparison] = useState(false);
|
||||||
@@ -58,25 +67,40 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
<Group justify="space-between" align="flex-start" mb="xs">
|
||||||
{t('plan.availablePlans.title', 'Available Plans')}
|
<div>
|
||||||
</h3>
|
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||||
<p
|
{t('plan.availablePlans.title', 'Available Plans')}
|
||||||
style={{
|
</h3>
|
||||||
margin: '0.25rem 0 1rem 0',
|
<p
|
||||||
color: 'var(--mantine-color-dimmed)',
|
style={{
|
||||||
fontSize: '0.875rem',
|
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>
|
>
|
||||||
|
{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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
marginBottom: '1rem',
|
marginBottom: '0.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groupedPlans.map((group) => (
|
{groupedPlans.map((group) => (
|
||||||
@@ -86,7 +110,9 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
|||||||
isCurrentTier={isCurrentTier(group)}
|
isCurrentTier={isCurrentTier(group)}
|
||||||
isDowngrade={isDowngrade(group)}
|
isDowngrade={isDowngrade(group)}
|
||||||
currentLicenseInfo={currentLicenseInfo}
|
currentLicenseInfo={currentLicenseInfo}
|
||||||
|
currentTier={currentTier}
|
||||||
onUpgradeClick={onUpgradeClick}
|
onUpgradeClick={onUpgradeClick}
|
||||||
|
onManageClick={onManageClick}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +126,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Collapse in={showComparison}>
|
<Collapse in={showComparison}>
|
||||||
<FeatureComparisonTable plans={groupedPlans} />
|
<FeatureComparisonTable plans={groupedPlans} currentTier={currentTier} />
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-2
@@ -12,9 +12,10 @@ interface PlanWithFeatures {
|
|||||||
|
|
||||||
interface FeatureComparisonTableProps {
|
interface FeatureComparisonTableProps {
|
||||||
plans: PlanWithFeatures[];
|
plans: PlanWithFeatures[];
|
||||||
|
currentTier?: 'free' | 'server' | 'enterprise' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
|
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans, currentTier }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -41,7 +42,7 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{plan.name}
|
{plan.name}
|
||||||
{plan.popular && (
|
{plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && (
|
||||||
<Badge
|
<Badge
|
||||||
color="blue"
|
color="blue"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
|
|||||||
+83
-88
@@ -1,44 +1,42 @@
|
|||||||
import React from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
|
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 {
|
interface PlanCardProps {
|
||||||
planGroup: PlanTierGroup;
|
planGroup: PlanTierGroup;
|
||||||
isCurrentTier: boolean;
|
isCurrentTier: boolean;
|
||||||
isDowngrade: boolean;
|
isDowngrade: boolean;
|
||||||
currentLicenseInfo?: LicenseInfo | null;
|
currentLicenseInfo?: LicenseInfo | null;
|
||||||
|
currentTier?: 'free' | 'server' | 'enterprise' | null;
|
||||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Render Free plan
|
// Render Free plan
|
||||||
if (planGroup.tier === 'free') {
|
if (planGroup.tier === 'free') {
|
||||||
|
// Get currency from the free plan
|
||||||
|
const freeCurrency = planGroup.monthly?.currency || '$';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
padding="lg"
|
padding="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={getBaseCardStyle(isCurrentTier)}
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: '400px',
|
|
||||||
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
|
|
||||||
borderWidth: isCurrentTier ? '2px' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isCurrentTier && (
|
{isCurrentTier && (
|
||||||
<Badge
|
<PricingBadge
|
||||||
color="green"
|
type="current"
|
||||||
variant="filled"
|
label={t('plan.current', 'Current Plan')}
|
||||||
size="sm"
|
/>
|
||||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
|
||||||
>
|
|
||||||
{t('plan.current', 'Current Plan')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
<Stack gap="md" style={{ height: '100%' }}>
|
<Stack gap="md" style={{ height: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
@@ -48,12 +46,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
|
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
|
||||||
{t('plan.from', 'From')}
|
{t('plan.from', 'From')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
<PriceDisplay
|
||||||
£0
|
mode="simple"
|
||||||
</Text>
|
price={0}
|
||||||
<Text size="sm" c="dimmed" mt="xs">
|
currency={freeCurrency}
|
||||||
{t('plan.free.forever', 'Forever free')}
|
period={t('plan.free.forever', 'Forever free')}
|
||||||
</Text>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -82,48 +80,32 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
const { monthly, yearly } = planGroup;
|
const { monthly, yearly } = planGroup;
|
||||||
const isEnterprise = planGroup.tier === 'enterprise';
|
const isEnterprise = planGroup.tier === 'enterprise';
|
||||||
|
|
||||||
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
// Block enterprise for free tier users (must have server first)
|
||||||
let displayPrice = monthly?.price || 0;
|
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
|
||||||
let displaySeatPrice = monthly?.seatPrice;
|
|
||||||
let displayCurrency = monthly?.currency || '£';
|
|
||||||
|
|
||||||
if (yearly) {
|
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
||||||
displayPrice = Math.round(yearly.price / 12);
|
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
|
||||||
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
|
monthly || undefined,
|
||||||
displayCurrency = yearly.currency;
|
yearly || undefined
|
||||||
}
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
padding="lg"
|
padding="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={getBaseCardStyle(isCurrentTier)}
|
||||||
position: 'relative',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: '400px',
|
|
||||||
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
|
|
||||||
borderWidth: isCurrentTier ? '2px' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isCurrentTier ? (
|
{isCurrentTier ? (
|
||||||
<Badge
|
<PricingBadge
|
||||||
color="green"
|
type="current"
|
||||||
variant="filled"
|
label={t('plan.current', 'Current Plan')}
|
||||||
size="sm"
|
/>
|
||||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? (
|
||||||
>
|
<PricingBadge
|
||||||
{t('plan.current', 'Current Plan')}
|
type="popular"
|
||||||
</Badge>
|
label={t('plan.popular', 'Popular')}
|
||||||
) : planGroup.popular ? (
|
/>
|
||||||
<Badge
|
|
||||||
variant="filled"
|
|
||||||
size="sm"
|
|
||||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
|
||||||
>
|
|
||||||
{t('plan.popular', 'Popular')}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Stack gap="md" style={{ height: '100%' }}>
|
<Stack gap="md" style={{ height: '100%' }}>
|
||||||
@@ -140,29 +122,23 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
{/* Price */}
|
{/* Price */}
|
||||||
{isEnterprise && displaySeatPrice !== undefined ? (
|
{isEnterprise && displaySeatPrice !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
|
||||||
{displayCurrency}{displayPrice}
|
{displayCurrency}{displaySeatPrice.toFixed(2)}
|
||||||
|
</Text>
|
||||||
|
<Text span size="1.5rem" c="dimmed" mt="xs">
|
||||||
|
{t('plan.perSeat', '/seat')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" mt="xs">
|
<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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<PriceDisplay
|
||||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
mode="simple"
|
||||||
{displayCurrency}{displayPrice}
|
price={displayPrice}
|
||||||
</Text>
|
currency={displayCurrency}
|
||||||
<Text size="sm" c="dimmed" mt="xs">
|
period={t('plan.perMonth', '/month')}
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,21 +155,40 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
|
|
||||||
<div style={{ flexGrow: 1 }} />
|
<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 */}
|
{/* Single Upgrade Button */}
|
||||||
<Button
|
<Tooltip
|
||||||
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
|
label={t('plan.enterprise.requiresServer', 'Requires Server plan')}
|
||||||
fullWidth
|
disabled={!isEnterpriseBlockedForFree}
|
||||||
onClick={() => onUpgradeClick(planGroup)}
|
position="top"
|
||||||
disabled={isCurrentTier || isDowngrade}
|
withArrow
|
||||||
>
|
>
|
||||||
{isCurrentTier
|
<Button
|
||||||
? t('plan.current', 'Current Plan')
|
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
|
||||||
: isDowngrade
|
fullWidth
|
||||||
? t('plan.includedInCurrent', 'Included in Your Plan')
|
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
|
||||||
: isEnterprise
|
disabled={isDowngrade || isEnterpriseBlockedForFree}
|
||||||
? t('plan.selectPlan', 'Select Plan')
|
>
|
||||||
: t('plan.upgrade', 'Upgrade')}
|
{isCurrentTier
|
||||||
</Button>
|
? 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>
|
</Stack>
|
||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { CheckoutState, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage navigation and history management hook
|
||||||
|
*/
|
||||||
|
export const useCheckoutNavigation = (
|
||||||
|
state: CheckoutState,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||||
|
stageHistory: CheckoutStage[],
|
||||||
|
setStageHistory: React.Dispatch<React.SetStateAction<CheckoutStage[]>>
|
||||||
|
) => {
|
||||||
|
const goToStage = useCallback((nextStage: CheckoutStage) => {
|
||||||
|
setStageHistory(prev => [...prev, state.currentStage]);
|
||||||
|
setState(prev => ({ ...prev, currentStage: nextStage }));
|
||||||
|
}, [state.currentStage, setState, setStageHistory]);
|
||||||
|
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
if (stageHistory.length > 0) {
|
||||||
|
const previousStage = stageHistory[stageHistory.length - 1];
|
||||||
|
setStageHistory(prev => prev.slice(0, -1));
|
||||||
|
|
||||||
|
// Reset payment state when going back from payment stage
|
||||||
|
if (state.currentStage === 'payment') {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentStage: previousStage,
|
||||||
|
clientSecret: undefined,
|
||||||
|
loading: false
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setState(prev => ({ ...prev, currentStage: previousStage }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [stageHistory, state.currentStage, setState, setStageHistory]);
|
||||||
|
|
||||||
|
return { goToStage, goBack };
|
||||||
|
};
|
||||||
+156
@@ -0,0 +1,156 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import licenseService, { PlanTier } from '@app/services/licenseService';
|
||||||
|
import { resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
||||||
|
import { CheckoutState, PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checkout session creation and payment handling hook
|
||||||
|
*/
|
||||||
|
export const useCheckoutSession = (
|
||||||
|
selectedPlan: PlanTier | null,
|
||||||
|
state: CheckoutState,
|
||||||
|
setState: React.Dispatch<React.SetStateAction<CheckoutState>>,
|
||||||
|
installationId: string | null,
|
||||||
|
setInstallationId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
currentLicenseKey: string | null,
|
||||||
|
setCurrentLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||||
|
minimumSeats: number,
|
||||||
|
pollForLicenseKey: (installId: string) => Promise<void>,
|
||||||
|
onSuccess?: (sessionId: string) => void,
|
||||||
|
onError?: (error: string) => void,
|
||||||
|
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
|
||||||
|
) => {
|
||||||
|
const createCheckoutSession = useCallback(async () => {
|
||||||
|
if (!selectedPlan) {
|
||||||
|
setState({
|
||||||
|
currentStage: 'error',
|
||||||
|
error: 'Selected plan period is not available',
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setState(prev => ({ ...prev, loading: true }));
|
||||||
|
|
||||||
|
// Fetch installation ID from backend
|
||||||
|
let fetchedInstallationId = installationId;
|
||||||
|
if (!fetchedInstallationId) {
|
||||||
|
fetchedInstallationId = await licenseService.getInstallationId();
|
||||||
|
setInstallationId(fetchedInstallationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current license key for upgrades
|
||||||
|
let existingLicenseKey: string | undefined;
|
||||||
|
try {
|
||||||
|
const licenseInfo = await licenseService.getLicenseInfo();
|
||||||
|
if (licenseInfo && licenseInfo.licenseKey) {
|
||||||
|
existingLicenseKey = licenseInfo.licenseKey;
|
||||||
|
setCurrentLicenseKey(existingLicenseKey);
|
||||||
|
console.log('Found existing license for upgrade');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not fetch license info, proceeding as new license:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await licenseService.createCheckoutSession({
|
||||||
|
lookup_key: selectedPlan.lookupKey,
|
||||||
|
installation_id: fetchedInstallationId,
|
||||||
|
current_license_key: existingLicenseKey,
|
||||||
|
requires_seats: selectedPlan.requiresSeats,
|
||||||
|
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
|
||||||
|
email: state.email, // Pass collected email from Stage 1
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we got a redirect URL (hosted checkout for HTTP)
|
||||||
|
if (response.url) {
|
||||||
|
console.log('Redirecting to Stripe hosted checkout:', response.url);
|
||||||
|
// Redirect to Stripe's hosted checkout page
|
||||||
|
window.location.href = response.url;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use embedded checkout (HTTPS)
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
clientSecret: response.clientSecret,
|
||||||
|
sessionId: response.sessionId,
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||||
|
setState({
|
||||||
|
currentStage: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
onError?.(errorMessage);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
selectedPlan,
|
||||||
|
state.email,
|
||||||
|
installationId,
|
||||||
|
minimumSeats,
|
||||||
|
setState,
|
||||||
|
setInstallationId,
|
||||||
|
setCurrentLicenseKey,
|
||||||
|
onError
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handlePaymentComplete = useCallback(async () => {
|
||||||
|
// Preserve state when changing stage
|
||||||
|
setState(prev => ({ ...prev, currentStage: 'success' }));
|
||||||
|
|
||||||
|
// Check if this is an upgrade (existing license key) or new plan
|
||||||
|
if (currentLicenseKey) {
|
||||||
|
// UPGRADE FLOW: Resync existing license with Keygen
|
||||||
|
console.log('Upgrade detected - resyncing existing license with Keygen');
|
||||||
|
setPollingStatus('polling');
|
||||||
|
|
||||||
|
const activation = await resyncExistingLicense({
|
||||||
|
isMounted: () => true, // Modal is open, no need to check
|
||||||
|
onActivated: onLicenseActivated,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activation.success) {
|
||||||
|
console.log(`License upgraded successfully: ${activation.licenseType}`);
|
||||||
|
setPollingStatus('ready');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to sync upgraded license:', activation.error);
|
||||||
|
setPollingStatus('timeout');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify parent (don't wait - upgrade is complete)
|
||||||
|
onSuccess?.(state.sessionId || '');
|
||||||
|
} else {
|
||||||
|
// NEW PLAN FLOW: Poll for new license key
|
||||||
|
console.log('New subscription - polling for license key');
|
||||||
|
|
||||||
|
if (installationId) {
|
||||||
|
pollForLicenseKey(installationId).finally(() => {
|
||||||
|
// Only notify parent after polling completes or times out
|
||||||
|
onSuccess?.(state.sessionId || '');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No installation ID, notify immediately
|
||||||
|
onSuccess?.(state.sessionId || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
currentLicenseKey,
|
||||||
|
installationId,
|
||||||
|
state.sessionId,
|
||||||
|
setState,
|
||||||
|
setPollingStatus,
|
||||||
|
pollForLicenseKey,
|
||||||
|
onSuccess,
|
||||||
|
onLicenseActivated
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
createCheckoutSession,
|
||||||
|
handlePaymentComplete,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { PlanTierGroup } from '@app/services/licenseService';
|
||||||
|
import { CheckoutState, PollingStatus, CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized state management hook for checkout flow
|
||||||
|
*/
|
||||||
|
export const useCheckoutState = (planGroup: PlanTierGroup) => {
|
||||||
|
const [state, setState] = useState<CheckoutState>({
|
||||||
|
currentStage: 'email',
|
||||||
|
loading: false
|
||||||
|
});
|
||||||
|
const [stageHistory, setStageHistory] = useState<CheckoutStage[]>([]);
|
||||||
|
const [emailInput, setEmailInput] = useState<string>('');
|
||||||
|
const [emailError, setEmailError] = useState<string>('');
|
||||||
|
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
|
||||||
|
planGroup.yearly ? 'yearly' : 'monthly'
|
||||||
|
);
|
||||||
|
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||||
|
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
|
||||||
|
const [licenseKey, setLicenseKey] = useState<string | null>(null);
|
||||||
|
const [pollingStatus, setPollingStatus] = useState<PollingStatus>('idle');
|
||||||
|
|
||||||
|
// Refs for polling cleanup
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
const pollingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Get the selected plan based on period
|
||||||
|
const selectedPlan = selectedPeriod === 'yearly'
|
||||||
|
? planGroup.yearly
|
||||||
|
: planGroup.monthly;
|
||||||
|
|
||||||
|
const resetState = useCallback(() => {
|
||||||
|
setState({
|
||||||
|
currentStage: 'email',
|
||||||
|
loading: false,
|
||||||
|
clientSecret: undefined,
|
||||||
|
sessionId: undefined,
|
||||||
|
error: undefined
|
||||||
|
});
|
||||||
|
setStageHistory([]);
|
||||||
|
setEmailInput('');
|
||||||
|
setEmailError('');
|
||||||
|
setPollingStatus('idle');
|
||||||
|
setCurrentLicenseKey(null);
|
||||||
|
setLicenseKey(null);
|
||||||
|
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
|
||||||
|
}, [planGroup]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
state,
|
||||||
|
setState,
|
||||||
|
stageHistory,
|
||||||
|
setStageHistory,
|
||||||
|
emailInput,
|
||||||
|
setEmailInput,
|
||||||
|
emailError,
|
||||||
|
setEmailError,
|
||||||
|
selectedPeriod,
|
||||||
|
setSelectedPeriod,
|
||||||
|
installationId,
|
||||||
|
setInstallationId,
|
||||||
|
currentLicenseKey,
|
||||||
|
setCurrentLicenseKey,
|
||||||
|
licenseKey,
|
||||||
|
setLicenseKey,
|
||||||
|
pollingStatus,
|
||||||
|
setPollingStatus,
|
||||||
|
// Refs
|
||||||
|
isMountedRef,
|
||||||
|
pollingTimeoutRef,
|
||||||
|
// Computed
|
||||||
|
selectedPlan,
|
||||||
|
// Actions
|
||||||
|
resetState,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { pollLicenseKeyWithBackoff, activateLicenseKey } from '@app/utils/licenseCheckoutUtils';
|
||||||
|
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* License key polling and activation logic hook
|
||||||
|
*/
|
||||||
|
export const useLicensePolling = (
|
||||||
|
isMountedRef: React.RefObject<boolean>,
|
||||||
|
setPollingStatus: React.Dispatch<React.SetStateAction<PollingStatus>>,
|
||||||
|
setLicenseKey: React.Dispatch<React.SetStateAction<string | null>>,
|
||||||
|
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void
|
||||||
|
) => {
|
||||||
|
const pollForLicenseKey = useCallback(async (installId: string) => {
|
||||||
|
// Use shared polling utility
|
||||||
|
const result = await pollLicenseKeyWithBackoff(installId, {
|
||||||
|
isMounted: () => isMountedRef.current!,
|
||||||
|
onStatusChange: setPollingStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.licenseKey) {
|
||||||
|
setLicenseKey(result.licenseKey);
|
||||||
|
|
||||||
|
// Activate the license key
|
||||||
|
const activation = await activateLicenseKey(result.licenseKey, {
|
||||||
|
isMounted: () => isMountedRef.current!,
|
||||||
|
onActivated: onLicenseActivated,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activation.success) {
|
||||||
|
console.error('Failed to activate license key:', activation.error);
|
||||||
|
}
|
||||||
|
} else if (result.timedOut) {
|
||||||
|
console.warn('License key polling timed out');
|
||||||
|
} else if (result.error) {
|
||||||
|
console.error('License key polling failed:', result.error);
|
||||||
|
}
|
||||||
|
}, [isMountedRef, setPollingStatus, setLicenseKey, onLicenseActivated]);
|
||||||
|
|
||||||
|
return { pollForLicenseKey };
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as StripeCheckout } from '@app/components/shared/stripeCheckout/StripeCheckout';
|
||||||
|
export type {
|
||||||
|
StripeCheckoutProps,
|
||||||
|
CheckoutStage,
|
||||||
|
CheckoutState,
|
||||||
|
PollingStatus,
|
||||||
|
SavingsCalculation
|
||||||
|
} from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, TextInput, Button } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface EmailStageProps {
|
||||||
|
emailInput: string;
|
||||||
|
setEmailInput: (email: string) => void;
|
||||||
|
emailError: string;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailStage: React.FC<EmailStageProps> = ({
|
||||||
|
emailInput,
|
||||||
|
setEmailInput,
|
||||||
|
emailError,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" style={{ maxWidth: '500px', margin: '0 auto', padding: '2rem 0' }}>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('payment.emailStage.description', "We'll use this to send your license key and receipts.")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label={t('payment.emailStage.emailLabel', 'Email Address')}
|
||||||
|
placeholder={t('payment.emailStage.emailPlaceholder', '[email protected]')}
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => setEmailInput(e.currentTarget.value)}
|
||||||
|
error={emailError}
|
||||||
|
size="lg"
|
||||||
|
required
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
onSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!emailInput.trim()}
|
||||||
|
>
|
||||||
|
{t('payment.emailStage.continue', 'Continue')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, Stack, Text, Button } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface ErrorStageProps {
|
||||||
|
error: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorStage: React.FC<ErrorStageProps> = ({ error, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">{error}</Text>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Text, Loader } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { loadStripe } from '@stripe/stripe-js';
|
||||||
|
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||||
|
import { PlanTier } from '@app/services/licenseService';
|
||||||
|
|
||||||
|
// Load Stripe once
|
||||||
|
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||||
|
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||||
|
|
||||||
|
interface PaymentStageProps {
|
||||||
|
clientSecret: string | null;
|
||||||
|
selectedPlan: PlanTier | null;
|
||||||
|
onPaymentComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentStage: React.FC<PaymentStageProps> = ({
|
||||||
|
clientSecret,
|
||||||
|
selectedPlan,
|
||||||
|
onPaymentComplete,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Show loading while creating checkout session
|
||||||
|
if (!clientSecret || !selectedPlan) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
<Text size="sm" c="dimmed" mt="md">
|
||||||
|
{t('payment.preparing', 'Preparing your checkout...')}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stripePromise) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="red">
|
||||||
|
Stripe is not configured properly.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
|
||||||
|
{/* Stripe Embedded Checkout */}
|
||||||
|
<EmbeddedCheckoutProvider
|
||||||
|
key={clientSecret}
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret: clientSecret,
|
||||||
|
onComplete: onPaymentComplete,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EmbeddedCheckout />
|
||||||
|
</EmbeddedCheckoutProvider>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Stack, Button, Text, Grid, Paper, Alert, Divider } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PlanTierGroup } from '@app/services/licenseService';
|
||||||
|
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
|
||||||
|
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
|
||||||
|
import { formatPrice, calculateMonthlyEquivalent, calculateTotalWithSeats } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
|
||||||
|
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
|
||||||
|
|
||||||
|
interface PlanSelectionStageProps {
|
||||||
|
planGroup: PlanTierGroup;
|
||||||
|
minimumSeats: number;
|
||||||
|
savings: SavingsCalculation | null;
|
||||||
|
onSelectPlan: (period: 'monthly' | 'yearly') => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlanSelectionStage: React.FC<PlanSelectionStageProps> = ({
|
||||||
|
planGroup,
|
||||||
|
minimumSeats,
|
||||||
|
savings,
|
||||||
|
onSelectPlan,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const isEnterprise = planGroup.tier === 'enterprise';
|
||||||
|
const seatCount = minimumSeats || 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
|
||||||
|
|
||||||
|
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
|
||||||
|
{/* Monthly Option */}
|
||||||
|
{planGroup.monthly && (
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
style={getClickablePaperStyle()}
|
||||||
|
onClick={() => onSelectPlan('monthly')}
|
||||||
|
>
|
||||||
|
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
{t('payment.monthly', 'Monthly')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isEnterprise && planGroup.monthly.seatPrice ? (
|
||||||
|
<PriceDisplay
|
||||||
|
mode="enterprise"
|
||||||
|
basePrice={planGroup.monthly.price}
|
||||||
|
seatPrice={planGroup.monthly.seatPrice}
|
||||||
|
totalPrice={calculateTotalWithSeats(planGroup.monthly.price, planGroup.monthly.seatPrice, seatCount)}
|
||||||
|
currency={planGroup.monthly.currency}
|
||||||
|
period="month"
|
||||||
|
seatCount={seatCount}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PriceDisplay
|
||||||
|
mode="simple"
|
||||||
|
price={planGroup.monthly?.price || 0}
|
||||||
|
currency={planGroup.monthly?.currency || '£'}
|
||||||
|
period={t('payment.perMonth', '/month')}
|
||||||
|
size="2.5rem"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||||
|
<Button variant="light" fullWidth size="lg">
|
||||||
|
{t('payment.planStage.selectMonthly', 'Select Monthly')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Yearly Option */}
|
||||||
|
{planGroup.yearly && (
|
||||||
|
<Grid.Col span={6}>
|
||||||
|
<Paper
|
||||||
|
withBorder
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
style={getClickablePaperStyle(!!savings)}
|
||||||
|
onClick={() => onSelectPlan('yearly')}
|
||||||
|
>
|
||||||
|
{savings && (
|
||||||
|
<PricingBadge
|
||||||
|
type="savings"
|
||||||
|
label={t('payment.planStage.savePercent', 'Save {{percent}}%', { percent: savings.percent })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Stack gap="md" style={{ height: '100%' }} justify="space-between">
|
||||||
|
<Text size="lg" fw={600}>
|
||||||
|
{t('payment.yearly', 'Yearly')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{isEnterprise && planGroup.yearly.seatPrice ? (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<PriceDisplay
|
||||||
|
mode="enterprise"
|
||||||
|
basePrice={planGroup.yearly.price}
|
||||||
|
seatPrice={planGroup.yearly.seatPrice}
|
||||||
|
totalPrice={calculateMonthlyEquivalent(
|
||||||
|
calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount)
|
||||||
|
)}
|
||||||
|
currency={planGroup.yearly.currency}
|
||||||
|
period="year"
|
||||||
|
seatCount={seatCount}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
|
||||||
|
currency: planGroup.yearly.currency,
|
||||||
|
amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2)
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<PriceDisplay
|
||||||
|
mode="simple"
|
||||||
|
price={calculateMonthlyEquivalent(planGroup.yearly?.price || 0)}
|
||||||
|
currency={planGroup.yearly?.currency || '£'}
|
||||||
|
period={t('payment.perMonth', '/month')}
|
||||||
|
size="2.5rem"
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
|
{t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', {
|
||||||
|
currency: planGroup.yearly?.currency,
|
||||||
|
amount: planGroup.yearly?.price.toFixed(2)
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{savings && (
|
||||||
|
<Alert color="green" variant="light" p="sm">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t('payment.planStage.savingsAmount', 'You save {{amount}}', {
|
||||||
|
amount: formatPrice(savings.amount, savings.currency)
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 'auto', paddingTop: '1rem' }}>
|
||||||
|
<Button variant="filled" fullWidth size="lg">
|
||||||
|
{t('payment.planStage.selectYearly', 'Select Yearly')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</Grid.Col>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Alert, Stack, Text, Paper, Code, Button, Group, Loader } from '@mantine/core';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { PollingStatus } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
interface SuccessStageProps {
|
||||||
|
pollingStatus: PollingStatus;
|
||||||
|
currentLicenseKey: string | null;
|
||||||
|
licenseKey: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SuccessStage: React.FC<SuccessStageProps> = ({
|
||||||
|
pollingStatus,
|
||||||
|
currentLicenseKey,
|
||||||
|
licenseKey,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'payment.successMessage',
|
||||||
|
'Your subscription has been activated successfully.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* License Key Polling Status */}
|
||||||
|
{pollingStatus === 'polling' && (
|
||||||
|
<Group gap="xs">
|
||||||
|
<Loader size="sm" />
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{currentLicenseKey
|
||||||
|
? t('payment.syncingLicense', 'Syncing your upgraded license...')
|
||||||
|
: t('payment.generatingLicense', 'Generating your license key...')}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
|
||||||
|
<Paper withBorder p="md" radius="md" bg="gray.1">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
{t('payment.licenseKey', 'Your License Key')}
|
||||||
|
</Text>
|
||||||
|
<Code block>{licenseKey}</Code>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigator.clipboard.writeText(licenseKey)}
|
||||||
|
>
|
||||||
|
{t('common.copy', 'Copy to Clipboard')}
|
||||||
|
</Button>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
'payment.licenseInstructions',
|
||||||
|
'This has been added to your installation. You will receive a copy in your email as well.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pollingStatus === 'ready' && currentLicenseKey && (
|
||||||
|
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'payment.upgradeCompleteMessage',
|
||||||
|
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pollingStatus === 'timeout' && (
|
||||||
|
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'payment.licenseDelayedMessage',
|
||||||
|
'Your license key is being generated. Please check your email shortly or contact support.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pollingStatus === 'ready' && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('payment.canCloseWindow', 'You can now close this window.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onClick={onClose} mt="md">
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { PlanTierGroup } from '@app/services/licenseService';
|
||||||
|
|
||||||
|
export interface StripeCheckoutProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
planGroup: PlanTierGroup;
|
||||||
|
minimumSeats?: number;
|
||||||
|
onSuccess?: (sessionId: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
|
||||||
|
hostedCheckoutSuccess?: {
|
||||||
|
isUpgrade: boolean;
|
||||||
|
licenseKey?: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CheckoutStage = 'email' | 'plan-selection' | 'payment' | 'success' | 'error';
|
||||||
|
|
||||||
|
export type CheckoutState = {
|
||||||
|
currentStage: CheckoutStage;
|
||||||
|
email?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
error?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PollingStatus = 'idle' | 'polling' | 'ready' | 'timeout';
|
||||||
|
|
||||||
|
export interface SavingsCalculation {
|
||||||
|
amount: number;
|
||||||
|
percent: number;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { CSSProperties } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared styling utilities for plan cards
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CARD_MIN_HEIGHT = '400px';
|
||||||
|
export const PRICE_FONT_WEIGHT = 600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get card border style based on state
|
||||||
|
*/
|
||||||
|
export function getCardBorderStyle(isHighlighted: boolean): CSSProperties {
|
||||||
|
return {
|
||||||
|
borderColor: isHighlighted ? 'var(--mantine-color-green-6)' : undefined,
|
||||||
|
borderWidth: isHighlighted ? '2px' : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get base card style
|
||||||
|
*/
|
||||||
|
export function getBaseCardStyle(isHighlighted: boolean = false): CSSProperties {
|
||||||
|
return {
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: CARD_MIN_HEIGHT,
|
||||||
|
...getCardBorderStyle(isHighlighted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get clickable paper style
|
||||||
|
*/
|
||||||
|
export function getClickablePaperStyle(isHighlighted: boolean = false): CSSProperties {
|
||||||
|
return {
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
height: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
...getCardBorderStyle(isHighlighted),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { TFunction } from 'i18next';
|
||||||
|
import { CheckoutStage } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate email address format
|
||||||
|
*/
|
||||||
|
export const validateEmail = (email: string): { valid: boolean; error: string } => {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(email)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: 'Please enter a valid email address'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { valid: true, error: '' };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dynamic modal title based on current stage
|
||||||
|
*/
|
||||||
|
export const getModalTitle = (
|
||||||
|
stage: CheckoutStage,
|
||||||
|
planName: string,
|
||||||
|
t: TFunction
|
||||||
|
): string => {
|
||||||
|
switch (stage) {
|
||||||
|
case 'email':
|
||||||
|
return t('payment.emailStage.modalTitle', 'Get Started - {{planName}}', { planName });
|
||||||
|
case 'plan-selection':
|
||||||
|
return t('payment.planStage.modalTitle', 'Select Billing Period - {{planName}}', { planName });
|
||||||
|
case 'payment':
|
||||||
|
return t('payment.paymentStage.modalTitle', 'Complete Payment - {{planName}}', { planName });
|
||||||
|
case 'success':
|
||||||
|
return t('payment.success', 'Payment Successful!');
|
||||||
|
case 'error':
|
||||||
|
return t('payment.error', 'Payment Error');
|
||||||
|
default:
|
||||||
|
return t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Shared pricing utilities for plan cards and checkout
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PriceCalculation {
|
||||||
|
displayPrice: number;
|
||||||
|
displaySeatPrice?: number;
|
||||||
|
displayCurrency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate monthly equivalent from yearly price
|
||||||
|
*/
|
||||||
|
export function calculateMonthlyEquivalent(yearlyPrice: number): number {
|
||||||
|
return yearlyPrice / 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total price including seats
|
||||||
|
*/
|
||||||
|
export function calculateTotalWithSeats(
|
||||||
|
basePrice: number,
|
||||||
|
seatPrice: number | undefined,
|
||||||
|
seatCount: number
|
||||||
|
): number {
|
||||||
|
if (seatPrice === undefined) return basePrice;
|
||||||
|
return basePrice + seatPrice * seatCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price with currency symbol
|
||||||
|
*/
|
||||||
|
export function formatPrice(amount: number, currency: string, decimals: number = 2): string {
|
||||||
|
return `${currency}${amount.toFixed(decimals)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate display pricing for a plan, showing yearly price divided by 12
|
||||||
|
* to show the lowest monthly equivalent
|
||||||
|
*/
|
||||||
|
export function calculateDisplayPricing(
|
||||||
|
monthly?: { price: number; seatPrice?: number; currency: string },
|
||||||
|
yearly?: { price: number; seatPrice?: number; currency: string }
|
||||||
|
): PriceCalculation {
|
||||||
|
// Default to monthly if no yearly exists
|
||||||
|
if (!yearly) {
|
||||||
|
return {
|
||||||
|
displayPrice: monthly?.price || 0,
|
||||||
|
displaySeatPrice: monthly?.seatPrice,
|
||||||
|
displayCurrency: monthly?.currency || '£',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use yearly price divided by 12 for best value display
|
||||||
|
return {
|
||||||
|
displayPrice: calculateMonthlyEquivalent(yearly.price),
|
||||||
|
displaySeatPrice: yearly.seatPrice ? calculateMonthlyEquivalent(yearly.seatPrice) : undefined,
|
||||||
|
displayCurrency: yearly.currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { PlanTierGroup } from '@app/services/licenseService';
|
||||||
|
import { SavingsCalculation } from '@app/components/shared/stripeCheckout/types/checkout';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate savings for yearly vs monthly plans
|
||||||
|
* Returns null if both monthly and yearly plans are not available
|
||||||
|
*/
|
||||||
|
export const calculateSavings = (
|
||||||
|
planGroup: PlanTierGroup,
|
||||||
|
minimumSeats: number
|
||||||
|
): SavingsCalculation | null => {
|
||||||
|
if (!planGroup.yearly || !planGroup.monthly) return null;
|
||||||
|
|
||||||
|
const isEnterprise = planGroup.tier === 'enterprise';
|
||||||
|
const seatCount = minimumSeats || 1;
|
||||||
|
|
||||||
|
let monthlyAnnual: number;
|
||||||
|
let yearlyTotal: number;
|
||||||
|
|
||||||
|
if (isEnterprise && planGroup.monthly.seatPrice && planGroup.yearly.seatPrice) {
|
||||||
|
// Enterprise: (base + seats) * 12 vs (base + seats) yearly
|
||||||
|
monthlyAnnual = (planGroup.monthly.price + (planGroup.monthly.seatPrice * seatCount)) * 12;
|
||||||
|
yearlyTotal = planGroup.yearly.price + (planGroup.yearly.seatPrice * seatCount);
|
||||||
|
} else {
|
||||||
|
// Server: price * 12 vs yearly price
|
||||||
|
monthlyAnnual = planGroup.monthly.price * 12;
|
||||||
|
yearlyTotal = planGroup.yearly.price;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savings = monthlyAnnual - yearlyTotal;
|
||||||
|
const savingsPercent = Math.round((savings / monthlyAnnual) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
amount: savings,
|
||||||
|
percent: savingsPercent,
|
||||||
|
currency: planGroup.yearly.currency
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -74,12 +74,14 @@ export const PLAN_HIGHLIGHTS = {
|
|||||||
'Self-hosted on your infrastructure',
|
'Self-hosted on your infrastructure',
|
||||||
'Unlimited users',
|
'Unlimited users',
|
||||||
'Advanced integrations',
|
'Advanced integrations',
|
||||||
|
'Editing text in PDFs',
|
||||||
'Cancel anytime'
|
'Cancel anytime'
|
||||||
],
|
],
|
||||||
SERVER_YEARLY: [
|
SERVER_YEARLY: [
|
||||||
'Self-hosted on your infrastructure',
|
'Self-hosted on your infrastructure',
|
||||||
'Unlimited users',
|
'Unlimited users',
|
||||||
'Advanced integrations',
|
'Advanced integrations',
|
||||||
|
'Editing text in PDFs',
|
||||||
'Save with annual billing'
|
'Save with annual billing'
|
||||||
],
|
],
|
||||||
ENTERPRISE_MONTHLY: [
|
ENTERPRISE_MONTHLY: [
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import React, { createContext, useContext, useState, useCallback, useEffect, Rea
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { usePlans } from '@app/hooks/usePlans';
|
import { usePlans } from '@app/hooks/usePlans';
|
||||||
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
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 { userManagementService } from '@app/services/userManagementService';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
||||||
import { useLicense } from '@app/contexts/LicenseContext';
|
import { useLicense } from '@app/contexts/LicenseContext';
|
||||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||||
|
import { getPreferredCurrency } from '@app/utils/currencyDetection';
|
||||||
|
|
||||||
export interface CheckoutOptions {
|
export interface CheckoutOptions {
|
||||||
minimumSeats?: number; // Override calculated seats for enterprise
|
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
|
onSuccess?: (sessionId: string) => void; // Callback after successful payment
|
||||||
onError?: (error: string) => void; // Callback on error
|
onError?: (error: string) => void; // Callback on error
|
||||||
}
|
}
|
||||||
@@ -35,15 +36,18 @@ interface CheckoutProviderProps {
|
|||||||
|
|
||||||
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||||
children,
|
children,
|
||||||
defaultCurrency = 'gbp'
|
defaultCurrency
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { refetchLicense } = useLicense();
|
const { refetchLicense } = useLicense();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
|
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
|
||||||
const [minimumSeats, setMinimumSeats] = useState<number>(1);
|
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 [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
|
||||||
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
|
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
|
||||||
isUpgrade: boolean;
|
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 licenseService, { LicenseInfo } from '@app/services/licenseService';
|
||||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
@@ -17,18 +17,45 @@ interface LicenseProviderProps {
|
|||||||
|
|
||||||
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
|
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
|
const configRef = useRef(config);
|
||||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Keep ref updated with latest config
|
||||||
|
useEffect(() => {
|
||||||
|
configRef.current = config;
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const refetchLicense = useCallback(async () => {
|
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
|
// Only fetch license info if user is an admin
|
||||||
if (!config?.isAdmin) {
|
if (!currentConfig.isAdmin) {
|
||||||
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
|
console.log('[LicenseContext] User is not an admin, skipping license fetch');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[LicenseContext] Fetching license info');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -42,7 +69,7 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [config?.isAdmin]);
|
}, []);
|
||||||
|
|
||||||
// Fetch license info when config changes (only if user is admin)
|
// Fetch license info when config changes (only if user is admin)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,12 +78,15 @@ export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) =>
|
|||||||
}
|
}
|
||||||
}, [config, refetchLicense]);
|
}, [config, refetchLicense]);
|
||||||
|
|
||||||
const contextValue: LicenseContextValue = {
|
const contextValue: LicenseContextValue = useMemo(
|
||||||
licenseInfo,
|
() => ({
|
||||||
loading,
|
licenseInfo,
|
||||||
error,
|
loading,
|
||||||
refetchLicense,
|
error,
|
||||||
};
|
refetchLicense,
|
||||||
|
}),
|
||||||
|
[licenseInfo, loading, error, refetchLicense]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LicenseContext.Provider value={contextValue}>
|
<LicenseContext.Provider value={contextValue}>
|
||||||
|
|||||||
@@ -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
|
current_license_key?: string; // Current license key for upgrades
|
||||||
requires_seats?: boolean; // Whether to add adjustable seat pricing
|
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)
|
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;
|
successUrl?: string;
|
||||||
cancelUrl?: string;
|
cancelUrl?: string;
|
||||||
}
|
}
|
||||||
@@ -109,7 +110,7 @@ const licenseService = {
|
|||||||
/**
|
/**
|
||||||
* Get available plans with pricing for the specified currency
|
* Get available plans with pricing for the specified currency
|
||||||
*/
|
*/
|
||||||
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
|
async getPlans(currency: string = 'usd'): Promise<PlansResponse> {
|
||||||
try {
|
try {
|
||||||
// Check if Supabase is configured
|
// Check if Supabase is configured
|
||||||
if (!isSupabaseConfigured || !supabase) {
|
if (!isSupabaseConfigured || !supabase) {
|
||||||
@@ -320,6 +321,7 @@ const licenseService = {
|
|||||||
current_license_key: request.current_license_key,
|
current_license_key: request.current_license_key,
|
||||||
requires_seats: request.requires_seats,
|
requires_seats: request.requires_seats,
|
||||||
seat_count: request.seat_count || 1,
|
seat_count: request.seat_count || 1,
|
||||||
|
email: request.email,
|
||||||
callback_base_url: baseUrl,
|
callback_base_url: baseUrl,
|
||||||
ui_mode: checkoutMode,
|
ui_mode: checkoutMode,
|
||||||
// For hosted checkout, provide success/cancel URLs
|
// For hosted checkout, provide success/cancel URLs
|
||||||
@@ -443,6 +445,42 @@ const licenseService = {
|
|||||||
throw error;
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 {
|
export function isSecureContext(): boolean {
|
||||||
// Allow localhost for development (works with both HTTP and HTTPS)
|
// Allow localhost for development (works with both HTTP and HTTPS)
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// const hostname = window.location.hostname;
|
|
||||||
const protocol = window.location.protocol;
|
const protocol = window.location.protocol;
|
||||||
|
|
||||||
// Localhost is considered secure for development
|
// Localhost is considered secure for development
|
||||||
|
// const hostname = window.location.hostname;
|
||||||
// if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
|
// if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
|
||||||
// return true;
|
// return true;
|
||||||
// }
|
// }
|
||||||
|
|||||||
Reference in New Issue
Block a user