From 5d18184e46f0c305dd715d72aad8ed98ae05bd5d Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 24 Nov 2025 16:38:07 +0000 Subject: [PATCH] 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 --- .../public/locales/en-GB/translation.json | 63 ++- .../proprietary/components/AppProviders.tsx | 7 +- .../components/shared/StripeCheckout.tsx | 507 ------------------ .../components/shared/UpdateSeatsButton.tsx | 38 ++ .../components/shared/UpdateSeatsModal.tsx | 205 +++++++ .../components/shared/UpgradeBanner.tsx | 2 +- .../shared/config/configNavSections.tsx | 9 - .../configSections/AdminPlanSection.tsx | 94 ++-- .../config/configSections/PeopleSection.tsx | 18 +- .../plan/AvailablePlansSection.tsx | 56 +- .../plan/FeatureComparisonTable.tsx | 5 +- .../config/configSections/plan/PlanCard.tsx | 171 +++--- .../shared/stripeCheckout/StripeCheckout.tsx | 309 +++++++++++ .../components/PriceDisplay.tsx | 89 +++ .../components/PricingBadge.tsx | 24 + .../hooks/useCheckoutNavigation.ts | 38 ++ .../hooks/useCheckoutSession.ts | 156 ++++++ .../stripeCheckout/hooks/useCheckoutState.ts | 78 +++ .../stripeCheckout/hooks/useLicensePolling.ts | 41 ++ .../components/shared/stripeCheckout/index.ts | 8 + .../stripeCheckout/stages/EmailStage.tsx | 50 ++ .../stripeCheckout/stages/ErrorStage.tsx | 23 + .../stripeCheckout/stages/PaymentStage.tsx | 61 +++ .../stages/PlanSelectionStage.tsx | 165 ++++++ .../stripeCheckout/stages/SuccessStage.tsx | 101 ++++ .../shared/stripeCheckout/types/checkout.ts | 34 ++ .../shared/stripeCheckout/utils/cardStyles.ts | 44 ++ .../stripeCheckout/utils/checkoutUtils.ts | 40 ++ .../stripeCheckout/utils/pricingUtils.ts | 60 +++ .../stripeCheckout/utils/savingsCalculator.ts | 38 ++ .../proprietary/constants/planConstants.ts | 2 + .../proprietary/contexts/CheckoutContext.tsx | 14 +- .../proprietary/contexts/LicenseContext.tsx | 50 +- .../contexts/UpdateSeatsContext.tsx | 222 ++++++++ .../proprietary/services/licenseService.ts | 40 +- .../proprietary/utils/currencyDetection.ts | 142 +++++ .../proprietary/utils/protocolDetection.ts | 2 +- 37 files changed, 2321 insertions(+), 685 deletions(-) delete mode 100644 frontend/src/proprietary/components/shared/StripeCheckout.tsx create mode 100644 frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx create mode 100644 frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutNavigation.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutSession.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/hooks/useLicensePolling.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/index.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/types/checkout.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts create mode 100644 frontend/src/proprietary/components/shared/stripeCheckout/utils/savingsCalculator.ts create mode 100644 frontend/src/proprietary/contexts/UpdateSeatsContext.tsx create mode 100644 frontend/src/proprietary/utils/currencyDetection.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ed543d01f..df5a96a3d 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -197,6 +197,7 @@ }, "edit": "Edit", "delete": "Delete", + "never": "Never", "username": "Username", "password": "Password", "welcome": "Welcome", @@ -4940,7 +4941,8 @@ "done": "Done", "loading": "Loading...", "back": "Back", - "continue": "Continue" + "continue": "Continue", + "error": "Error" }, "config": { "overview": { @@ -5368,9 +5370,12 @@ "featureComparison": "Feature Comparison", "from": "From", "perMonth": "/month", + "perSeat": "/seat", + "withServer": "+ Server Plan", "licensedSeats": "Licensed: {{count}} seats", "includedInCurrent": "Included in Your Plan", "selectPlan": "Select Plan", + "manage": "Manage", "manageSubscription": { "description": "Manage your subscription, billing, and payment methods" }, @@ -5412,7 +5417,9 @@ "name": "Enterprise", "highlight1": "Custom pricing", "highlight2": "Dedicated support", - "highlight3": "Latest features" + "highlight3": "Latest features", + "requiresServer": "Requires Server", + "requiresServerMessage": "Please upgrade to the Server plan first before upgrading to Enterprise." }, "feature": { "title": "Feature", @@ -5440,7 +5447,24 @@ "manageBilling": "Manage Billing", "portal": { "error": "Failed to open billing portal" - } + }, + "updateSeats": "Update Seats", + "updateEnterpriseSeats": "Update Enterprise Seats", + "currentSeats": "Current Seats", + "minimumSeats": "Minimum Seats", + "basedOnUsers": "(current users)", + "newSeatCount": "New Seat Count", + "newSeatCountDescription": "Select the number of seats for your enterprise licence", + "whatHappensNext": "What happens next?", + "stripePortalRedirect": "You will be redirected to Stripe's billing portal to review and confirm the seat change. The prorated amount will be calculated automatically.", + "preparingUpdate": "Preparing seat update...", + "seatCountTooLow": "Seat count must be at least {{minimum}} (current number of users)", + "seatCountUnchanged": "Please select a different seat count", + "seatsUpdated": "Seats Updated", + "seatsUpdatedMessage": "Your enterprise seats have been updated to {{seats}}", + "updateProcessing": "Update Processing", + "updateProcessingMessage": "Your seat update is being processed. Please refresh in a few moments.", + "notEnterprise": "Seat management is only available for enterprise licences" }, "upgradeBanner": { "title": "Upgrade to Server Plan", @@ -5476,10 +5500,39 @@ "enterpriseNote": "Seats can be adjusted in checkout (1-1000).", "installationId": "Installation ID", "licenseKey": "Your License Key", - "licenseInstructions": "Enter this key in Settings → Admin Plan → License Key section", + "licenseInstructions": "This has been added to your installation. You will receive a copy in your email as well.", "canCloseWindow": "You can now close this window.", "licenseKeyProcessing": "License Key Processing", - "licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support." + "licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support.", + "perYear": "/year", + "perMonth": "/month", + "emailInvalid": "Please enter a valid email address", + "emailStage": { + "title": "Enter Your Email", + "description": "We'll use this to send your license key and receipts.", + "emailLabel": "Email Address", + "emailPlaceholder": "your@email.com", + "continue": "Continue", + "modalTitle": "Get Started - {{planName}}" + }, + "planStage": { + "title": "Choose Your Billing Period", + "savingsNote": "Save {{percent}}% with annual billing", + "basePrice": "Base Price", + "seatPrice": "Per Seat", + "totalForSeats": "Total ({{count}} seats)", + "selectMonthly": "Select Monthly", + "selectYearly": "Select Yearly", + "savePercent": "Save {{percent}}%", + "savingsAmount": "You save {{amount}}", + "modalTitle": "Select Billing Period - {{planName}}", + "billedYearly": "Billed yearly at {{currency}}{{amount}}" + }, + "paymentStage": { + "backToPlan": "Back to Plan Selection", + "selectedPlan": "Selected Plan", + "modalTitle": "Complete Payment - {{planName}}" + } }, "firstLogin": { "title": "First Time Login", diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index b865e4f26..00964a866 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -2,6 +2,7 @@ import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/compo import { AuthProvider } from "@app/auth/UseSession"; import { LicenseProvider } from "@app/contexts/LicenseContext"; import { CheckoutProvider } from "@app/contexts/CheckoutContext"; +import { UpdateSeatsProvider } from "@app/contexts/UpdateSeatsContext"; import UpgradeBanner from "@app/components/shared/UpgradeBanner"; export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { @@ -13,8 +14,10 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - - {children} + + + {children} + diff --git a/frontend/src/proprietary/components/shared/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/StripeCheckout.tsx deleted file mode 100644 index 6018dcafc..000000000 --- a/frontend/src/proprietary/components/shared/StripeCheckout.tsx +++ /dev/null @@ -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 = ({ - opened, - onClose, - planGroup, - minimumSeats = 1, - onSuccess, - onError, - onLicenseActivated, - hostedCheckoutSuccess, -}) => { - const { t } = useTranslation(); - const [state, setState] = useState({ 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(null); - const [currentLicenseKey, setCurrentLicenseKey] = useState(null); - const [licenseKey, setLicenseKey] = useState(null); - const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle'); - - // Refs for polling cleanup - const isMountedRef = React.useRef(true); - const pollingTimeoutRef = React.useRef(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 ( - - - - {t( - 'payment.stripeNotConfiguredMessage', - 'Stripe payment integration is not configured. Please contact your administrator.' - )} - - - - - ); - } - - switch (state.status) { - case 'loading': - return ( - - - - {t('payment.preparing', 'Preparing your checkout...')} - - - ); - - 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 ( - - {/* Left: Period Selector - only show if both periods available */} - {periodData.length > 1 && ( - - - - {t('payment.billingPeriod', 'Billing Period')} - - - {selectedPlan.requiresSeats && selectedPlan.seatPrice && ( - - {t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')} - - )} - - - )} - - {/* Right: Stripe Checkout */} - 1 ? 9 : 12}> - - - - - - ); - } - case 'success': - return ( - - - - {t( - 'payment.successMessage', - 'Your subscription has been activated successfully.' - )} - - - {/* License Key Polling Status */} - {pollingStatus === 'polling' && ( - - - - {currentLicenseKey - ? t('payment.syncingLicense', 'Syncing your upgraded license...') - : t('payment.generatingLicense', 'Generating your license key...')} - - - )} - - {pollingStatus === 'ready' && !currentLicenseKey && licenseKey && ( - - - - {t('payment.licenseKey', 'Your License Key')} - - {licenseKey} - - - {t( - 'payment.licenseInstructions', - 'Enter this key in Settings → Admin Plan → License Key section' - )} - - - - )} - - {pollingStatus === 'ready' && currentLicenseKey && ( - - - {t( - 'payment.upgradeCompleteMessage', - 'Your subscription has been upgraded successfully. Your existing license key has been updated.' - )} - - - )} - - {pollingStatus === 'timeout' && ( - - - {t( - 'payment.licenseDelayedMessage', - 'Your license key is being generated. Please check your email shortly or contact support.' - )} - - - )} - - {pollingStatus === 'ready' && ( - - {t('payment.canCloseWindow', 'You can now close this window.')} - - )} - - - ); - - case 'error': - return ( - - - {state.error} - - - - ); - - default: - return null; - } - }; - - return ( - - {t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })} - - } - size="90%" - centered - withCloseButton={true} - closeOnEscape={true} - closeOnClickOutside={false} - zIndex={Z_INDEX_OVER_CONFIG_MODAL} - styles={{ - body: { - minHeight: '85vh', - }, - content: { - maxHeight: '95vh', - }, - }} - > - {renderContent()} - - ); -}; - -export default StripeCheckout; diff --git a/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx b/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx new file mode 100644 index 000000000..ddc43350b --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpdateSeatsButton.tsx @@ -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 { + onSuccess?: () => void; + onError?: (error: string) => void; +} + +export const UpdateSeatsButton: React.FC = ({ + onSuccess, + onError, + ...buttonProps +}) => { + const { t } = useTranslation(); + const { openUpdateSeats, isLoading } = useUpdateSeats(); + + const handleClick = async () => { + await openUpdateSeats({ + onSuccess, + onError, + }); + }; + + return ( + + ); +}; + +export default UpdateSeatsButton; diff --git a/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx b/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx new file mode 100644 index 000000000..87827725f --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpdateSeatsModal.tsx @@ -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; // Returns billing portal URL +} + +type UpdateState = { + status: 'idle' | 'loading' | 'error'; + error?: string; +}; + +const UpdateSeatsModal: React.FC = ({ + opened, + onClose, + currentSeats, + minimumSeats, + _onSuccess, + onError, + onUpdateSeats, +}) => { + const { t } = useTranslation(); + const [state, setState] = useState({ status: 'idle' }); + const [newSeatCount, setNewSeatCount] = useState(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 ( + + + + {t('billing.preparingUpdate', 'Preparing seat update...')} + + + ); + } + + return ( + + {state.status === 'error' && ( + + {state.error} + + )} + + + + + {t('billing.currentSeats', 'Current Seats')}: + + + {currentSeats} + + + + + {t('billing.minimumSeats', 'Minimum Seats')}: + + + {minimumSeats} {t('billing.basedOnUsers', '(current users)')} + + + + + setNewSeatCount(typeof value === 'number' ? value : minimumSeats)} + min={minimumSeats} + max={10000} + step={1} + size="md" + styles={{ + input: { + fontSize: '1.5rem', + fontWeight: 500, + textAlign: 'center', + }, + }} + /> + + + + {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.' + )} + + + + + + + + + ); + }; + + return ( + + {t('billing.updateEnterpriseSeats', 'Update Enterprise Seats')} + + } + size="md" + centered + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default UpdateSeatsModal; diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx index 93a5eb827..bbb6a729b 100644 --- a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -75,7 +75,7 @@ const UpgradeBanner: React.FC = () => { // Handle upgrade button click const handleUpgrade = () => { openCheckout('server', { - currency: 'gbp', + // Currency auto-detected from locale in CheckoutContext minimumSeats: 1, onSuccess: () => { // Banner will auto-hide on next render when license is detected diff --git a/frontend/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/src/proprietary/components/shared/config/configNavSections.tsx index 2b27a4ba9..27e02bd89 100644 --- a/frontend/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/src/proprietary/components/shared/config/configNavSections.tsx @@ -9,7 +9,6 @@ import AdminPrivacySection from '@app/components/shared/config/configSections/Ad import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection'; import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection'; import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection'; -import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection'; import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; @@ -129,14 +128,6 @@ export const createConfigNavSections = ( sections.push({ title: 'Licensing & Analytics', items: [ - { - key: 'adminPremium', - label: 'Premium', - icon: 'star-rounded', - component: , - disabled: requiresLogin, - disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined - }, { key: 'adminPlan', label: 'Plan', diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 522658004..9be46d1d8 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -1,23 +1,25 @@ import React, { useState, useCallback, useEffect } from 'react'; -import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; +import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; -import licenseService, { PlanTierGroup } from '@app/services/licenseService'; +import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService'; import { useCheckout } from '@app/contexts/CheckoutContext'; import { useLicense } from '@app/contexts/LicenseContext'; import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection'; import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection'; import { alert } from '@app/components/toast'; import LocalIcon from '@app/components/shared/LocalIcon'; -import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; -import { ManageBillingButton } from '@app/components/shared/ManageBillingButton'; import { isSupabaseConfigured } from '@app/services/supabaseClient'; +import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection'; const AdminPlanSection: React.FC = () => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { openCheckout } = useCheckout(); const { licenseInfo, refetchLicense } = useLicense(); - const [currency, setCurrency] = useState('gbp'); + const [currency, setCurrency] = useState(() => { + // Initialize with auto-detected currency on first render + return getPreferredCurrency(i18n.language); + }); const [useStaticVersion, setUseStaticVersion] = useState(false); const [showLicenseKey, setShowLicenseKey] = useState(false); const [licenseKeyInput, setLicenseKeyInput] = useState(''); @@ -80,6 +82,36 @@ const AdminPlanSection: React.FC = () => { { value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' }, ]; + const handleManageClick = useCallback(async () => { + try { + if (!licenseInfo?.licenseKey) { + throw new Error('No license key found. Please activate a license first.'); + } + + // Create billing portal session with license key + const response = await licenseService.createBillingPortalSession( + window.location.href, + licenseInfo.licenseKey + ); + + // Open billing portal in new tab + window.open(response.url, '_blank'); + } catch (error: any) { + console.error('Failed to open billing portal:', error); + alert({ + alertType: 'error', + title: t('billing.portal.error', 'Failed to open billing portal'), + body: error.message || 'Please try again or contact support.', + }); + } + }, [licenseInfo, t]); + + const handleCurrencyChange = useCallback((newCurrency: string) => { + setCurrency(newCurrency); + // Persist user's manual selection to localStorage + setCachedCurrency(newCurrency); + }, []); + const handleUpgradeClick = useCallback( (planGroup: PlanTierGroup) => { // Only allow upgrades for server and enterprise tiers @@ -87,6 +119,20 @@ const AdminPlanSection: React.FC = () => { return; } + // Prevent free tier users from directly accessing enterprise (must have server first) + const currentTier = mapLicenseToTier(licenseInfo); + if (currentTier === 'free' && planGroup.tier === 'enterprise') { + alert({ + alertType: 'warning', + title: t('plan.enterprise.requiresServer', 'Server Plan Required'), + body: t( + 'plan.enterprise.requiresServerMessage', + 'Please upgrade to the Server plan first before upgrading to Enterprise.' + ), + }); + return; + } + // Use checkout context to open checkout modal openCheckout(planGroup.tier, { currency, @@ -97,7 +143,7 @@ const AdminPlanSection: React.FC = () => { }, }); }, - [openCheckout, currency, refetch] + [openCheckout, currency, refetch, licenseInfo, t] ); // Show static version if Stripe is not configured or there's an error @@ -129,40 +175,14 @@ const AdminPlanSection: React.FC = () => { return (
- {/* Currency Selection & Manage Subscription */} - - - - - {t('plan.currency', 'Currency')} - - onCurrencyChange(value || 'usd')} + data={currencyOptions} + searchable + clearable={false} + w={300} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + )} +
{groupedPlans.map((group) => ( @@ -86,7 +110,9 @@ const AvailablePlansSection: React.FC = ({ isCurrentTier={isCurrentTier(group)} isDowngrade={isDowngrade(group)} currentLicenseInfo={currentLicenseInfo} + currentTier={currentTier} onUpgradeClick={onUpgradeClick} + onManageClick={onManageClick} /> ))}
@@ -100,7 +126,7 @@ const AvailablePlansSection: React.FC = ({
- + ); diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx index 129d59b9f..d297853f0 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx @@ -12,9 +12,10 @@ interface PlanWithFeatures { interface FeatureComparisonTableProps { plans: PlanWithFeatures[]; + currentTier?: 'free' | 'server' | 'enterprise' | null; } -const FeatureComparisonTable: React.FC = ({ plans }) => { +const FeatureComparisonTable: React.FC = ({ plans, currentTier }) => { const { t } = useTranslation(); return ( @@ -41,7 +42,7 @@ const FeatureComparisonTable: React.FC = ({ plans } }} > {plan.name} - {plan.popular && ( + {plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && ( void; + onManageClick?: () => void; } -const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => { +const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick }) => { const { t } = useTranslation(); // Render Free plan if (planGroup.tier === 'free') { + // Get currency from the free plan + const freeCurrency = planGroup.monthly?.currency || '$'; + return ( {isCurrentTier && ( - - {t('plan.current', 'Current Plan')} - + )}
@@ -48,12 +46,12 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra {t('plan.from', 'From')} - - £0 - - - {t('plan.free.forever', 'Forever free')} - +
@@ -82,48 +80,32 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra const { monthly, yearly } = planGroup; const isEnterprise = planGroup.tier === 'enterprise'; - // Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent - let displayPrice = monthly?.price || 0; - let displaySeatPrice = monthly?.seatPrice; - let displayCurrency = monthly?.currency || '£'; + // Block enterprise for free tier users (must have server first) + const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free'; - if (yearly) { - displayPrice = Math.round(yearly.price / 12); - displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined; - displayCurrency = yearly.currency; - } + // Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent + const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing( + monthly || undefined, + yearly || undefined + ); return ( {isCurrentTier ? ( - - {t('plan.current', 'Current Plan')} - - ) : planGroup.popular ? ( - - {t('plan.popular', 'Popular')} - + + ) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? ( + ) : null} @@ -140,29 +122,23 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra {/* Price */} {isEnterprise && displaySeatPrice !== undefined ? ( <> - - {displayCurrency}{displayPrice} + + {displayCurrency}{displaySeatPrice.toFixed(2)} + + + {t('plan.perSeat', '/seat')} - + {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')} + {t('plan.perMonth', '/month')} {t('plan.withServer', '+ Server Plan')} ) : ( - <> - - {displayCurrency}{displayPrice} - - - {t('plan.perMonth', '/month')} - - - )} - - {/* Show seat count for enterprise plans when current */} - {isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && ( - - {t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })} - + )} @@ -179,21 +155,40 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra
+ + {/* Show seat count for enterprise plans when current */} + {isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && ( + + {t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })} + + )} + {/* Single Upgrade Button */} - + + + + ); diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx new file mode 100644 index 000000000..e0cac1a80 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/StripeCheckout.tsx @@ -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 = ({ + 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 ( + + + + {t( + 'payment.stripeNotConfiguredMessage', + 'Stripe payment integration is not configured. Please contact your administrator.' + )} + + + + + ); + } + + switch (checkoutState.state.currentStage) { + case 'email': + return ( + + ); + + case 'plan-selection': + return ( + + ); + + case 'payment': + return ( + + ); + + case 'success': + return ( + + ); + + case 'error': + return ( + + ); + + default: + return null; + } + }; + + const canGoBack = checkoutState.stageHistory.length > 0; + + return ( + + {canGoBack && ( + + + + )} + + {getModalTitle(checkoutState.state.currentStage, planGroup.name, t)} + + + } + 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()} + + ); +}; + +export default StripeCheckout; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx new file mode 100644 index 000000000..b0e3ff927 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/components/PriceDisplay.tsx @@ -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 = (props) => { + if (props.mode === 'simple') { + const fontSize = props.size || '2.25rem'; + return ( + <> + + {formatPrice(props.price, props.currency)} + + + {props.period} + + + ); + } + + // 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 ( + +
+ + Base Price + + + {formatPrice(basePrice, currency)} + + {' '} + /{period} + + +
+
+ + Per Seat + + + {formatPrice(seatPrice, currency)} + + {' '} + /seat/{period} + + +
+ {totalPrice !== undefined && seatCount && ( +
+ + Total ({seatCount} seats) + + + {formatPrice(totalPrice, currency)} + + {' '} + /{period === 'year' ? 'month' : period} + + +
+ )} +
+ ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx new file mode 100644 index 000000000..af1e68e42 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/components/PricingBadge.tsx @@ -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 = ({ type, label }) => { + const color = type === 'current' || type === 'savings' ? 'green' : 'blue'; + const size = type === 'savings' ? 'lg' : 'sm'; + + return ( + + {label} + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutNavigation.ts b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutNavigation.ts new file mode 100644 index 000000000..8ee39b4e9 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutNavigation.ts @@ -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>, + stageHistory: CheckoutStage[], + setStageHistory: React.Dispatch> +) => { + 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 }; +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutSession.ts b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutSession.ts new file mode 100644 index 000000000..09f7a5d7c --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutSession.ts @@ -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>, + installationId: string | null, + setInstallationId: React.Dispatch>, + currentLicenseKey: string | null, + setCurrentLicenseKey: React.Dispatch>, + setPollingStatus: React.Dispatch>, + minimumSeats: number, + pollForLicenseKey: (installId: string) => Promise, + 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, + }; +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts new file mode 100644 index 000000000..5c90491e5 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useCheckoutState.ts @@ -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({ + currentStage: 'email', + loading: false + }); + const [stageHistory, setStageHistory] = useState([]); + const [emailInput, setEmailInput] = useState(''); + const [emailError, setEmailError] = useState(''); + const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>( + planGroup.yearly ? 'yearly' : 'monthly' + ); + const [installationId, setInstallationId] = useState(null); + const [currentLicenseKey, setCurrentLicenseKey] = useState(null); + const [licenseKey, setLicenseKey] = useState(null); + const [pollingStatus, setPollingStatus] = useState('idle'); + + // Refs for polling cleanup + const isMountedRef = useRef(true); + const pollingTimeoutRef = useRef(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, + }; +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useLicensePolling.ts b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useLicensePolling.ts new file mode 100644 index 000000000..0978683f3 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/hooks/useLicensePolling.ts @@ -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, + setPollingStatus: React.Dispatch>, + setLicenseKey: React.Dispatch>, + 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 }; +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/index.ts b/frontend/src/proprietary/components/shared/stripeCheckout/index.ts new file mode 100644 index 000000000..bb53480b5 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/index.ts @@ -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'; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx new file mode 100644 index 000000000..c8ff4e415 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/EmailStage.tsx @@ -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 = ({ + emailInput, + setEmailInput, + emailError, + onSubmit, +}) => { + const { t } = useTranslation(); + + return ( + + + {t('payment.emailStage.description', "We'll use this to send your license key and receipts.")} + + + setEmailInput(e.currentTarget.value)} + error={emailError} + size="lg" + required + onKeyDown={(e) => { + if (e.key === 'Enter') { + onSubmit(); + } + }} + /> + + + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx new file mode 100644 index 000000000..934e75d77 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/ErrorStage.tsx @@ -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 = ({ error, onClose }) => { + const { t } = useTranslation(); + + return ( + + + {error} + + + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.tsx new file mode 100644 index 000000000..9cee1983c --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PaymentStage.tsx @@ -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 = ({ + clientSecret, + selectedPlan, + onPaymentComplete, +}) => { + const { t } = useTranslation(); + + // Show loading while creating checkout session + if (!clientSecret || !selectedPlan) { + return ( + + + + {t('payment.preparing', 'Preparing your checkout...')} + + + ); + } + + if (!stripePromise) { + return ( + + Stripe is not configured properly. + + ); + } + + return ( + + + {/* Stripe Embedded Checkout */} + + + + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx new file mode 100644 index 000000000..f7a6fdb34 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/PlanSelectionStage.tsx @@ -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 = ({ + planGroup, + minimumSeats, + savings, + onSelectPlan, +}) => { + const { t } = useTranslation(); + const isEnterprise = planGroup.tier === 'enterprise'; + const seatCount = minimumSeats || 1; + + return ( + + + + {/* Monthly Option */} + {planGroup.monthly && ( + + onSelectPlan('monthly')} + > + + + {t('payment.monthly', 'Monthly')} + + + + + {isEnterprise && planGroup.monthly.seatPrice ? ( + + ) : ( + + )} + +
+ +
+
+
+
+ )} + + {/* Yearly Option */} + {planGroup.yearly && ( + + onSelectPlan('yearly')} + > + {savings && ( + + )} + + + + {t('payment.yearly', 'Yearly')} + + + + + {isEnterprise && planGroup.yearly.seatPrice ? ( + + + + {t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', { + currency: planGroup.yearly.currency, + amount: calculateTotalWithSeats(planGroup.yearly.price, planGroup.yearly.seatPrice, seatCount).toFixed(2) + })} + + + ) : ( + + + + {t('payment.planStage.billedYearly', 'Billed yearly at {{currency}}{{amount}}', { + currency: planGroup.yearly?.currency, + amount: planGroup.yearly?.price.toFixed(2) + })} + + + )} + + {savings && ( + + + {t('payment.planStage.savingsAmount', 'You save {{amount}}', { + amount: formatPrice(savings.amount, savings.currency) + })} + + + )} + +
+ +
+
+
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx b/frontend/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx new file mode 100644 index 000000000..0d75db3c6 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/stages/SuccessStage.tsx @@ -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 = ({ + pollingStatus, + currentLicenseKey, + licenseKey, + onClose, +}) => { + const { t } = useTranslation(); + + return ( + + + + {t( + 'payment.successMessage', + 'Your subscription has been activated successfully.' + )} + + + {/* License Key Polling Status */} + {pollingStatus === 'polling' && ( + + + + {currentLicenseKey + ? t('payment.syncingLicense', 'Syncing your upgraded license...') + : t('payment.generatingLicense', 'Generating your license key...')} + + + )} + + {pollingStatus === 'ready' && !currentLicenseKey && licenseKey && ( + + + + {t('payment.licenseKey', 'Your License Key')} + + {licenseKey} + + + {t( + 'payment.licenseInstructions', + 'This has been added to your installation. You will receive a copy in your email as well.' + )} + + + + )} + + {pollingStatus === 'ready' && currentLicenseKey && ( + + + {t( + 'payment.upgradeCompleteMessage', + 'Your subscription has been upgraded successfully. Your existing license key has been updated.' + )} + + + )} + + {pollingStatus === 'timeout' && ( + + + {t( + 'payment.licenseDelayedMessage', + 'Your license key is being generated. Please check your email shortly or contact support.' + )} + + + )} + + {pollingStatus === 'ready' && ( + + {t('payment.canCloseWindow', 'You can now close this window.')} + + )} + + + + + ); +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/types/checkout.ts b/frontend/src/proprietary/components/shared/stripeCheckout/types/checkout.ts new file mode 100644 index 000000000..94d94e24e --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/types/checkout.ts @@ -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; +} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts new file mode 100644 index 000000000..ac4961436 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/cardStyles.ts @@ -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), + }; +} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts new file mode 100644 index 000000000..3be3fa555 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/checkoutUtils.ts @@ -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 }); + } +}; diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts new file mode 100644 index 000000000..9fc5c8416 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/pricingUtils.ts @@ -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, + }; +} diff --git a/frontend/src/proprietary/components/shared/stripeCheckout/utils/savingsCalculator.ts b/frontend/src/proprietary/components/shared/stripeCheckout/utils/savingsCalculator.ts new file mode 100644 index 000000000..3a0b4d6c8 --- /dev/null +++ b/frontend/src/proprietary/components/shared/stripeCheckout/utils/savingsCalculator.ts @@ -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 + }; +}; diff --git a/frontend/src/proprietary/constants/planConstants.ts b/frontend/src/proprietary/constants/planConstants.ts index 1865238df..ab14a2c55 100644 --- a/frontend/src/proprietary/constants/planConstants.ts +++ b/frontend/src/proprietary/constants/planConstants.ts @@ -74,12 +74,14 @@ export const PLAN_HIGHLIGHTS = { 'Self-hosted on your infrastructure', 'Unlimited users', 'Advanced integrations', + 'Editing text in PDFs', 'Cancel anytime' ], SERVER_YEARLY: [ 'Self-hosted on your infrastructure', 'Unlimited users', 'Advanced integrations', + 'Editing text in PDFs', 'Save with annual billing' ], ENTERPRISE_MONTHLY: [ diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx index f8558a650..13f753199 100644 --- a/frontend/src/proprietary/contexts/CheckoutContext.tsx +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -2,16 +2,17 @@ import React, { createContext, useContext, useState, useCallback, useEffect, Rea import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService'; -import StripeCheckout from '@app/components/shared/StripeCheckout'; +import { StripeCheckout } from '@app/components/shared/stripeCheckout'; import { userManagementService } from '@app/services/userManagementService'; import { alert } from '@app/components/toast'; import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils'; import { useLicense } from '@app/contexts/LicenseContext'; import { isSupabaseConfigured } from '@app/services/supabaseClient'; +import { getPreferredCurrency } from '@app/utils/currencyDetection'; export interface CheckoutOptions { minimumSeats?: number; // Override calculated seats for enterprise - currency?: string; // Optional currency override (defaults to 'gbp') + currency?: string; // Optional currency override (auto-detected from locale) onSuccess?: (sessionId: string) => void; // Callback after successful payment onError?: (error: string) => void; // Callback on error } @@ -35,15 +36,18 @@ interface CheckoutProviderProps { export const CheckoutProvider: React.FC = ({ children, - defaultCurrency = 'gbp' + defaultCurrency }) => { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const { refetchLicense } = useLicense(); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); const [minimumSeats, setMinimumSeats] = useState(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({}); const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{ isUpgrade: boolean; diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx index 9414dcf5d..a76596c2e 100644 --- a/frontend/src/proprietary/contexts/LicenseContext.tsx +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; +import React, { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef, ReactNode } from 'react'; import licenseService, { LicenseInfo } from '@app/services/licenseService'; import { useAppConfig } from '@app/contexts/AppConfigContext'; @@ -17,18 +17,45 @@ interface LicenseProviderProps { export const LicenseProvider: React.FC = ({ children }) => { const { config } = useAppConfig(); + const configRef = useRef(config); const [licenseInfo, setLicenseInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + // Keep ref updated with latest config + useEffect(() => { + configRef.current = config; + }, [config]); + const refetchLicense = useCallback(async () => { + // Wait for config to load if it's not available yet + let currentConfig = configRef.current; + if (!currentConfig) { + console.log('[LicenseContext] Config not loaded yet, waiting...'); + // Wait up to 5 seconds for config to load + const maxWait = 5000; + const startTime = Date.now(); + while (!configRef.current && Date.now() - startTime < maxWait) { + await new Promise(resolve => setTimeout(resolve, 100)); + currentConfig = configRef.current; + } + + if (!currentConfig) { + console.error('[LicenseContext] Config failed to load after waiting'); + setLoading(false); + return; + } + } + // Only fetch license info if user is an admin - if (!config?.isAdmin) { - console.debug('[LicenseContext] User is not an admin, skipping license fetch'); + if (!currentConfig.isAdmin) { + console.log('[LicenseContext] User is not an admin, skipping license fetch'); setLoading(false); return; } + console.log('[LicenseContext] Fetching license info'); + try { setLoading(true); setError(null); @@ -42,7 +69,7 @@ export const LicenseProvider: React.FC = ({ children }) => } finally { setLoading(false); } - }, [config?.isAdmin]); + }, []); // Fetch license info when config changes (only if user is admin) useEffect(() => { @@ -51,12 +78,15 @@ export const LicenseProvider: React.FC = ({ children }) => } }, [config, refetchLicense]); - const contextValue: LicenseContextValue = { - licenseInfo, - loading, - error, - refetchLicense, - }; + const contextValue: LicenseContextValue = useMemo( + () => ({ + licenseInfo, + loading, + error, + refetchLicense, + }), + [licenseInfo, loading, error, refetchLicense] + ); return ( diff --git a/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx b/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx new file mode 100644 index 000000000..7d3550eeb --- /dev/null +++ b/frontend/src/proprietary/contexts/UpdateSeatsContext.tsx @@ -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; + closeUpdateSeats: () => void; + isOpen: boolean; + isLoading: boolean; +} + +const UpdateSeatsContext = createContext(undefined); + +interface UpdateSeatsProviderProps { + children: ReactNode; +} + +export const UpdateSeatsProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentSeats, setCurrentSeats] = useState(1); + const [minimumSeats, setMinimumSeats] = useState(1); + const [currentOptions, setCurrentOptions] = useState({}); + + // 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 => { + 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 ( + + {children} + + + ); +}; + +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; diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 409dbfd74..819cbff70 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -43,6 +43,7 @@ export interface CheckoutSessionRequest { current_license_key?: string; // Current license key for upgrades requires_seats?: boolean; // Whether to add adjustable seat pricing seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI) + email?: string; // Customer email for checkout pre-fill successUrl?: string; cancelUrl?: string; } @@ -109,7 +110,7 @@ const licenseService = { /** * Get available plans with pricing for the specified currency */ - async getPlans(currency: string = 'gbp'): Promise { + async getPlans(currency: string = 'usd'): Promise { try { // Check if Supabase is configured if (!isSupabaseConfigured || !supabase) { @@ -320,6 +321,7 @@ const licenseService = { current_license_key: request.current_license_key, requires_seats: request.requires_seats, seat_count: request.seat_count || 1, + email: request.email, callback_base_url: baseUrl, ui_mode: checkoutMode, // For hosted checkout, provide success/cancel URLs @@ -443,6 +445,42 @@ const licenseService = { throw error; } }, + + /** + * Update enterprise seat count + * Creates a Stripe billing portal session for confirming seat changes + * @param newSeatCount - New number of seats + * @param licenseKey - Current license key for authentication + * @returns Billing portal URL for confirming the change + */ + async updateEnterpriseSeats(newSeatCount: number, licenseKey: string): Promise { + // 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; + }, }; /** diff --git a/frontend/src/proprietary/utils/currencyDetection.ts b/frontend/src/proprietary/utils/currencyDetection.ts new file mode 100644 index 000000000..408002a93 --- /dev/null +++ b/frontend/src/proprietary/utils/currencyDetection.ts @@ -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 = { + // 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; +} diff --git a/frontend/src/proprietary/utils/protocolDetection.ts b/frontend/src/proprietary/utils/protocolDetection.ts index 9bd9ce03b..eb5d0a29a 100644 --- a/frontend/src/proprietary/utils/protocolDetection.ts +++ b/frontend/src/proprietary/utils/protocolDetection.ts @@ -10,10 +10,10 @@ export function isSecureContext(): boolean { // Allow localhost for development (works with both HTTP and HTTPS) if (typeof window !== 'undefined') { - // const hostname = window.location.hostname; const protocol = window.location.protocol; // Localhost is considered secure for development + // const hostname = window.location.hostname; // if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') { // return true; // }