From e474cc76ad9f7e5df7620127f25abf23a4c84986 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:13:20 +0000 Subject: [PATCH] Improved static upgrade flow (#5214) image image image image image image image --- .../public/locales/en-GB/translation.toml | 22 ++ .../configSections/AdminPlanSection.tsx | 240 +------------ .../plan/AvailablePlansSection.tsx | 23 +- .../configSections/plan/LicenseKeySection.tsx | 273 ++++++++++++++ .../config/configSections/plan/PlanCard.tsx | 5 +- .../plan/StaticCheckoutModal.tsx | 338 ++++++++++++++++++ .../configSections/plan/StaticPlanSection.tsx | 322 +++++++++-------- .../constants/staticStripeLinks.ts | 56 +++ .../src/proprietary/utils/planTierUtils.ts | 40 +++ 9 files changed, 917 insertions(+), 402 deletions(-) create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/plan/StaticCheckoutModal.tsx create mode 100644 frontend/src/proprietary/constants/staticStripeLinks.ts create mode 100644 frontend/src/proprietary/utils/planTierUtils.ts diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 9d9278922..9be190247 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -5569,6 +5569,28 @@ contactSales = "Contact Sales" contactToUpgrade = "Contact us to upgrade or customize your plan" maxUsers = "Max Users" upTo = "Up to" +getLicense = "Get Server License" +upgradeToEnterprise = "Upgrade to Enterprise" +selectPeriod = "Select Billing Period" +monthlyBilling = "Monthly Billing" +yearlyBilling = "Yearly Billing" +checkoutOpened = "Checkout Opened" +checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key." +activateLicense = "Activate Your License" + +[plan.static.licenseActivation] +checkoutOpened = "Checkout Opened in New Tab" +instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key." +enterKey = "Enter your license key below to activate your plan:" +keyDescription = "Paste the license key from your email" +activate = "Activate License" +doLater = "I'll do this later" +success = "License Activated!" +successMessage = "Your license has been successfully activated. You can now close this window." + +[plan.static.billingPortal] +title = "Email Verification Required" +message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link." [plan.period] month = "month" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 2c86c9ced..0b47cf148 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; -import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core'; +import { Divider, Loader, Alert } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePlans } from '@app/hooks/usePlans'; import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService'; @@ -7,30 +7,25 @@ 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 LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection'; import { alert } from '@app/components/toast'; -import LocalIcon from '@app/components/shared/LocalIcon'; import { InfoBanner } from '@app/components/shared/InfoBanner'; import { useLicenseAlert } from '@app/hooks/useLicenseAlert'; -import { isSupabaseConfigured } from '@app/services/supabaseClient'; import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection'; import { useLoginRequired } from '@app/hooks/useLoginRequired'; import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner'; +import { isSupabaseConfigured } from '@app/services/supabaseClient'; const AdminPlanSection: React.FC = () => { const { t, i18n } = useTranslation(); const { loginEnabled, validateLoginEnabled } = useLoginRequired(); const { openCheckout } = useCheckout(); - const { licenseInfo, refetchLicense } = useLicense(); + const { licenseInfo } = useLicense(); 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(''); - const [savingLicense, setSavingLicense] = useState(false); - const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); - const [licenseFile, setLicenseFile] = useState(null); const { plans, loading, error, refetch } = usePlans(currency); const licenseAlert = useLicenseAlert(); @@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => { } }, [error]); - const handleSaveLicense = async () => { - // Block save if login is disabled - if (!validateLoginEnabled()) { - return; - } - - try { - setSavingLicense(true); - - let response; - - if (inputMethod === 'file' && licenseFile) { - // Upload file - response = await licenseService.saveLicenseFile(licenseFile); - } else if (inputMethod === 'text' && licenseKeyInput.trim()) { - // Save key string (allow empty string to clear/remove license) - response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); - } else { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.premium.noInput', 'Please provide a license key or file'), - }); - return; - } - - if (response.success) { - // Refresh license context to update all components - await refetchLicense(); - - const successMessage = inputMethod === 'file' - ? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully') - : t('admin.settings.premium.key.successMessage', 'License key activated successfully'); - - alert({ - alertType: 'success', - title: t('success', 'Success'), - body: successMessage, - }); - - // Clear inputs - setLicenseKeyInput(''); - setLicenseFile(null); - setInputMethod('text'); // Reset to default - } else { - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: response.error || t('admin.settings.saveError', 'Failed to save license'), - }); - } - } catch (error) { - console.error('Failed to save license:', error); - alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save license'), - }); - } finally { - setSavingLicense(false); - } - }; - const currencyOptions = [ { value: 'gbp', label: 'British pound (GBP, £)' }, { value: 'usd', label: 'US dollar (USD, $)' }, @@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => { {/* License Key Section */} -
- - - - - } - > - - {t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')} - - - - {/* Severe warning if license already exists */} - {licenseInfo?.licenseKey && ( - } - title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')} - > - - - {t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')} - - - {t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')} - - - {t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')} - - - - )} - - {/* Show current license source */} - {licenseInfo?.licenseKey && ( - } - > - - - {t('admin.settings.premium.currentLicense.title', 'Active License')} - - - {licenseInfo.licenseKey.startsWith('file:') - ? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', { - path: licenseInfo.licenseKey.substring(5) - }) - : t('admin.settings.premium.currentLicense.key', 'Source: License key')} - - - {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { - type: licenseInfo.licenseType - })} - - - - )} - - {/* Input method selector */} - { - setInputMethod(value as 'text' | 'file'); - // Clear opposite input when switching - if (value === 'text') setLicenseFile(null); - if (value === 'file') setLicenseKeyInput(''); - }} - data={[ - { - label: t('admin.settings.premium.inputMethod.text', 'License Key'), - value: 'text' - }, - { - label: t('admin.settings.premium.inputMethod.file', 'Certificate File'), - value: 'file' - } - ]} - disabled={!loginEnabled || savingLicense} - /> - - {/* Input area */} - - - {inputMethod === 'text' ? ( - /* Existing text input */ - setLicenseKeyInput(e.target.value)} - placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} - type="password" - disabled={!loginEnabled || savingLicense} - /> - ) : ( - /* File upload */ -
- - {t('admin.settings.premium.file.label', 'License Certificate File')} - - - {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} - - - {(props) => ( - - )} - - {licenseFile && ( - - {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { - filename: licenseFile.name, - size: (licenseFile.size / 1024).toFixed(2) + ' KB' - })} - - )} -
- )} - - - - -
-
-
-
-
+ ); }; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx index a3b8800d1..9aed2ff7d 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard'; import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable'; import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; +import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils'; interface AvailablePlansSectionProps { plans: PlanTier[]; @@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC = ({ // Determine if the current tier matches (checks both Stripe subscription and license) const isCurrentTier = (tierGroup: PlanTierGroup): boolean => { - // Check license tier match - if (currentTier && tierGroup.tier === currentTier) { - return true; - } - return false; + return checkIsCurrentTier(currentTier, tierGroup.tier); }; // Determine if selecting this plan would be a downgrade const isDowngrade = (tierGroup: PlanTierGroup): boolean => { - if (!currentTier) return false; - - // Define tier hierarchy: enterprise > server > free - const tierHierarchy: Record = { - 'enterprise': 3, - 'server': 2, - 'free': 1 - }; - - const currentLevel = tierHierarchy[currentTier] || 0; - const targetLevel = tierHierarchy[tierGroup.tier] || 0; - - return currentLevel > targetLevel; + return checkIsDowngrade(currentTier, tierGroup.tier); }; return ( @@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC = ({ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', - marginBottom: '0.5rem', + marginBottom: '0.1rem', }} > {groupedPlans.map((group) => ( diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx new file mode 100644 index 000000000..e5e90d048 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/LicenseKeySection.tsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react'; +import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { alert } from '@app/components/toast'; +import { LicenseInfo } from '@app/services/licenseService'; +import licenseService from '@app/services/licenseService'; +import { useLicense } from '@app/contexts/LicenseContext'; +import { useLoginRequired } from '@app/hooks/useLoginRequired'; + +interface LicenseKeySectionProps { + currentLicenseInfo?: LicenseInfo; +} + +const LicenseKeySection: React.FC = ({ currentLicenseInfo }) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const { loginEnabled, validateLoginEnabled } = useLoginRequired(); + const [showLicenseKey, setShowLicenseKey] = useState(false); + const [licenseKeyInput, setLicenseKeyInput] = useState(''); + const [savingLicense, setSavingLicense] = useState(false); + const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); + const [licenseFile, setLicenseFile] = useState(null); + + const handleSaveLicense = async () => { + // Block save if login is disabled + if (!validateLoginEnabled()) { + return; + } + + try { + setSavingLicense(true); + + let response; + + if (inputMethod === 'file' && licenseFile) { + // Upload file + response = await licenseService.saveLicenseFile(licenseFile); + } else if (inputMethod === 'text' && licenseKeyInput.trim()) { + // Save key string + response = await licenseService.saveLicenseKey(licenseKeyInput.trim()); + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.premium.noInput', 'Please provide a license key or file'), + }); + return; + } + + if (response.success) { + // Refresh license context to update all components + await refetchLicense(); + + const successMessage = + inputMethod === 'file' + ? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully') + : t('admin.settings.premium.key.successMessage', 'License key activated successfully'); + + alert({ + alertType: 'success', + title: t('success', 'Success'), + body: successMessage, + }); + + // Clear inputs + setLicenseKeyInput(''); + setLicenseFile(null); + setInputMethod('text'); // Reset to default + } else { + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: response.error || t('admin.settings.saveError', 'Failed to save license'), + }); + } + } catch (error) { + console.error('Failed to save license:', error); + alert({ + alertType: 'error', + title: t('admin.error', 'Error'), + body: t('admin.settings.saveError', 'Failed to save license'), + }); + } finally { + setSavingLicense(false); + } + }; + + return ( +
+ + + + + }> + + {t( + 'admin.settings.premium.licenseKey.info', + 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.' + )} + + + + {/* Severe warning if license already exists */} + {currentLicenseInfo?.licenseKey && ( + } + title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')} + > + + + {t( + 'admin.settings.premium.key.overwriteWarning.line1', + 'Overwriting your current license key cannot be undone.' + )} + + + {t( + 'admin.settings.premium.key.overwriteWarning.line2', + 'Your previous license will be permanently lost unless you have backed it up elsewhere.' + )} + + + {t( + 'admin.settings.premium.key.overwriteWarning.line3', + 'Important: Keep license keys private and secure. Never share them publicly.' + )} + + + + )} + + {/* Show current license source */} + {currentLicenseInfo?.licenseKey && ( + } + > + + + {t('admin.settings.premium.currentLicense.title', 'Active License')} + + + {currentLicenseInfo.licenseKey.startsWith('file:') + ? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', { + path: currentLicenseInfo.licenseKey.substring(5), + }) + : t('admin.settings.premium.currentLicense.key', 'Source: License key')} + + + {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { + type: currentLicenseInfo.licenseType, + })} + + + + )} + + {/* Input method selector */} + { + setInputMethod(value as 'text' | 'file'); + // Clear opposite input when switching + if (value === 'text') setLicenseFile(null); + if (value === 'file') setLicenseKeyInput(''); + }} + data={[ + { + label: t('admin.settings.premium.inputMethod.text', 'License Key'), + value: 'text', + }, + { + label: t('admin.settings.premium.inputMethod.file', 'Certificate File'), + value: 'file', + }, + ]} + disabled={!loginEnabled || savingLicense} + /> + + {/* Input area */} + + + {inputMethod === 'text' ? ( + /* Text input */ + setLicenseKeyInput(e.target.value)} + placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={!loginEnabled || savingLicense} + /> + ) : ( + /* File upload */ +
+ + {t('admin.settings.premium.file.label', 'License Certificate File')} + + + {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} + + + {(props) => ( + + )} + + {licenseFile && ( + + {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { + filename: licenseFile.name, + size: (licenseFile.size / 1024).toFixed(2) + ' KB', + })} + + )} +
+ )} + + + + +
+
+
+
+
+ ); +}; + +export default LicenseKeySection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx index ab56fdad3..b2dce9c65 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -6,6 +6,7 @@ import { PricingBadge } from '@app/components/shared/stripeCheckout/components/P import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay'; import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils'; import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles'; +import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils'; interface PlanCardProps { planGroup: PlanTierGroup; @@ -83,7 +84,7 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra const isEnterprise = planGroup.tier === 'enterprise'; // Block enterprise for free tier users (must have server first) - const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free'; + const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier); // Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing( @@ -174,7 +175,7 @@ const PlanCard: React.FC = ({ planGroup, isCurrentTier, isDowngra withArrow > + + + + )} + + {licenseActivated && ( + + + + )} + + ); + + default: + return null; + } + }; + + const canGoBack = stageHistory.length > 0 && stage !== 'license-activation'; + + return ( + + {canGoBack && ( + + + + )} + + {getModalTitle()} + + + } + size={isMobile ? '100%' : 600} + centered + radius="lg" + withCloseButton={true} + closeOnEscape={true} + closeOnClickOutside={false} + fullScreen={isMobile} + zIndex={Z_INDEX_OVER_CONFIG_MODAL} + > + {renderContent()} + + ); +}; + +export default StaticCheckoutModal; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx index ba937263a..2847ef94e 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx @@ -1,20 +1,16 @@ -import React, { useState, useEffect } from 'react'; -import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core'; +import React, { useState } from 'react'; +import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import LocalIcon from '@app/components/shared/LocalIcon'; -import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal'; -import { useRestartServer } from '@app/components/shared/config/useRestartServer'; -import { useAdminSettings } from '@app/hooks/useAdminSettings'; -import PendingBadge from '@app/components/shared/config/PendingBadge'; import { alert } from '@app/components/toast'; import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService'; import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants'; import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable'; - -interface PremiumSettingsData { - key?: string; - enabled?: boolean; -} +import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal'; +import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection'; +import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks'; +import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge'; +import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles'; +import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils'; interface StaticPlanSectionProps { currentLicenseInfo?: LicenseInfo; @@ -22,38 +18,45 @@ interface StaticPlanSectionProps { const StaticPlanSection: React.FC = ({ currentLicenseInfo }) => { const { t } = useTranslation(); - const [showLicenseKey, setShowLicenseKey] = useState(false); const [showComparison, setShowComparison] = useState(false); - // Premium/License key management - const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer(); - const { - settings: premiumSettings, - setSettings: setPremiumSettings, - loading: premiumLoading, - saving: premiumSaving, - fetchSettings: fetchPremiumSettings, - saveSettings: savePremiumSettings, - isFieldPending, - } = useAdminSettings({ - sectionName: 'premium', - }); + // Static checkout modal state + const [checkoutModalOpened, setCheckoutModalOpened] = useState(false); + const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server'); + const [isUpgrade, setIsUpgrade] = useState(false); - useEffect(() => { - fetchPremiumSettings(); - }, []); - - const handleSaveLicense = async () => { - try { - await savePremiumSettings(); - showRestartModal(); - } catch (_error) { + const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => { + // Prevent Free → Enterprise (must have Server first) + const currentTier = mapLicenseToTier(currentLicenseInfo || null); + if (currentTier === 'free' && plan === 'enterprise') { alert({ - alertType: 'error', - title: t('admin.error', 'Error'), - body: t('admin.settings.saveError', 'Failed to save settings'), + 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; } + + setSelectedPlan(plan); + setIsUpgrade(upgrade); + setCheckoutModalOpened(true); + }; + + const handleManageBilling = () => { + // Show warning about email verification + alert({ + alertType: 'warning', + title: t('plan.static.billingPortal.title', 'Email Verification Required'), + body: t( + 'plan.static.billingPortal.message', + 'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.' + ), + }); + + window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank'); }; const staticPlans = [ @@ -122,7 +125,7 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem', - paddingBottom: '1rem', + paddingBottom: '0.1rem', }} > {staticPlans.map((plan) => ( @@ -131,53 +134,27 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf padding="lg" radius="md" withBorder - style={{ - position: 'relative', - display: 'flex', - flexDirection: 'column', - borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined, - borderWidth: plan.id === currentPlan.id ? '2px' : undefined, - }} + style={getBaseCardStyle(plan.id === currentPlan.id)} + className="plan-card" > {plan.id === currentPlan.id && ( - - {t('plan.current', 'Current Plan')} - + )} {plan.popular && plan.id !== currentPlan.id && ( - - {t('plan.popular', 'Popular')} - + )}
- + {plan.name} - - - {plan.price === 0 && plan.id !== 'free' - ? t('plan.customPricing', 'Custom') - : plan.price === 0 - ? t('plan.free.name', 'Free') - : `${plan.currency}${plan.price}`} - - {plan.period && ( - - {plan.period} - - )} - {typeof plan.maxUsers === 'string' ? plan.maxUsers @@ -195,18 +172,123 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf
- + ); } - > - {plan.id === currentPlan.id - ? t('plan.current', 'Current Plan') - : t('plan.contact', 'Contact Us')} - + + // Server Plan + if (plan.id === 'server') { + if (currentTier === 'free') { + return ( + + ); + } + if (isCurrent) { + return ( + + ); + } + if (isDowngradePlan) { + return ( + + ); + } + } + + // Enterprise Plan + if (plan.id === 'enterprise') { + if (isEnterpriseBlockedForFree(currentTier, plan.id)) { + return ( + + + + ); + } + if (currentTier === 'server') { + // TODO: Re-enable checkout flow when account syncing is ready + // return ( + // + // ); + return ( + + ); + } + if (isCurrent) { + return ( + + ); + } + } + + return null; + })()} ))} @@ -230,66 +312,14 @@ const StaticPlanSection: React.FC = ({ currentLicenseInf {/* License Key Section */} -
- + - - - } - > - - {t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')} - - - - {premiumLoading ? ( - - - - ) : ( - - -
- - {t('admin.settings.premium.key.label', 'License Key')} - - - } - description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')} - value={premiumSettings.key || ''} - onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })} - placeholder="00000000-0000-0000-0000-000000000000" - /> -
- - - - -
-
- )} -
-
-
- - {/* Restart Confirmation Modal */} - setCheckoutModalOpened(false)} + planName={selectedPlan} + isUpgrade={isUpgrade} />
); diff --git a/frontend/src/proprietary/constants/staticStripeLinks.ts b/frontend/src/proprietary/constants/staticStripeLinks.ts new file mode 100644 index 000000000..3fb45b28c --- /dev/null +++ b/frontend/src/proprietary/constants/staticStripeLinks.ts @@ -0,0 +1,56 @@ +/** + * Static Stripe payment links for offline/self-hosted environments + * + * These links are used when Supabase is not configured, allowing users to + * purchase licenses directly through Stripe hosted checkout pages. + * + * NOTE: These are test environment URLs. Replace with production URLs before release. + */ + +export interface StaticStripeLinks { + server: { + monthly: string; + yearly: string; + }; + enterprise: { + monthly: string; + yearly: string; + }; + billingPortal: string; +} +// PRODCUTION LINKS FOR LIVE SERVER +export const STATIC_STRIPE_LINKS: StaticStripeLinks = { + server: { + monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03', + yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02', + }, + enterprise: { + monthly: '', + yearly: '', + }, + billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00', +}; + +// LINKS FOR TEST SERVER: +// export const STATIC_STRIPE_LINKS: StaticStripeLinks = { +// server: { +// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02', +// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03', +// }, +// enterprise: { +// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04', +// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05', +// }, +// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00', +// }; + +/** + * Builds a Stripe URL with a prefilled email parameter + * @param baseUrl - The base Stripe checkout URL + * @param email - The email address to prefill + * @returns The complete URL with encoded email parameter + */ +export function buildStripeUrlWithEmail(baseUrl: string, email: string): string { + const encodedEmail = encodeURIComponent(email); + return `${baseUrl}?locked_prefilled_email=${encodedEmail}`; +} diff --git a/frontend/src/proprietary/utils/planTierUtils.ts b/frontend/src/proprietary/utils/planTierUtils.ts new file mode 100644 index 000000000..e4fa12f62 --- /dev/null +++ b/frontend/src/proprietary/utils/planTierUtils.ts @@ -0,0 +1,40 @@ +/** + * Shared utilities for plan tier comparisons and button logic + */ + +export type PlanTier = 'free' | 'server' | 'enterprise'; + +const TIER_HIERARCHY: Record = { + 'free': 1, + 'server': 2, + 'enterprise': 3, +}; + +/** + * Get numeric level for a tier + */ +export function getTierLevel(tier: PlanTier | string | null | undefined): number { + if (!tier) return 1; + return TIER_HIERARCHY[tier as PlanTier] || 1; +} + +/** + * Check if target tier is the current tier + */ +export function isCurrentTier(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return getTierLevel(currentTier) === getTierLevel(targetTier); +} + +/** + * Check if target tier is a downgrade from current tier + */ +export function isDowngrade(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return getTierLevel(currentTier) > getTierLevel(targetTier); +} + +/** + * Check if enterprise is blocked for free tier users + */ +export function isEnterpriseBlockedForFree(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean { + return currentTier === 'free' && targetTier === 'enterprise'; +}