Improved static upgrade flow (#5214)

<img width="996" height="621" alt="image"
src="https://github.com/user-attachments/assets/1ac87414-09ed-4307-8f7c-25984e0c89d1"
/>
<img width="608" height="351" alt="image"
src="https://github.com/user-attachments/assets/c271f75e-4844-4034-8905-007cc7ab1265"
/>
<img width="660" height="355" alt="image"
src="https://github.com/user-attachments/assets/34913b74-d4fa-418a-b098-fda48b41f0dd"
/>
<img width="1371" height="906" alt="image"
src="https://github.com/user-attachments/assets/35b61389-fd67-41b3-9969-e5409e53b362"
/>
<img width="639" height="450" alt="image"
src="https://github.com/user-attachments/assets/ae018bf3-0fcf-4221-892f-440d7325540a"
/>
<img width="963" height="599" alt="image"
src="https://github.com/user-attachments/assets/f6f67682-f43c-46f3-8632-16b209780b15"
/>
<img width="982" height="628" alt="image"
src="https://github.com/user-attachments/assets/45a7c171-3eb4-4271-a299-f3a6e78c1a52"
/>
This commit is contained in:
ConnorYoh
2025-12-11 11:13:20 +00:00
committed by GitHub
parent 43eaa84a8f
commit e474cc76ad
9 changed files with 917 additions and 402 deletions

View File

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

View File

@@ -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<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(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 = () => {
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{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.')}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
</Text>
<Text size="sm">
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
</Text>
<Text size="sm" fw={500}>
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{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')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: licenseInfo.licenseType
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
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 */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Existing text input */
<TextInput
label={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={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB'
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};

View File

@@ -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<AvailablePlansSectionProps> = ({
// 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<string, number> = {
'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<AvailablePlansSectionProps> = ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.5rem',
marginBottom: '0.1rem',
}}
>
{groupedPlans.map((group) => (

View File

@@ -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<LicenseKeySectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(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 (
<div>
<Button
variant="subtle"
leftSection={
<LocalIcon
icon={showLicenseKey ? 'expand-less-rounded' : 'expand-more-rounded'}
width="1.25rem"
height="1.25rem"
/>
}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert variant="light" color="blue" icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}>
<Text size="sm">
{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.'
)}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t(
'admin.settings.premium.key.overwriteWarning.line1',
'Overwriting your current license key cannot be undone.'
)}
</Text>
<Text size="sm">
{t(
'admin.settings.premium.key.overwriteWarning.line2',
'Your previous license will be permanently lost unless you have backed it up elsewhere.'
)}
</Text>
<Text size="sm" fw={500}>
{t(
'admin.settings.premium.key.overwriteWarning.line3',
'Important: Keep license keys private and secure. Never share them publicly.'
)}
</Text>
</Stack>
</Alert>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{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')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: currentLicenseInfo.licenseType,
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
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 */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Text input */
<TextInput
label={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={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;

View File

@@ -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<PlanCardProps> = ({ 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<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
withArrow
>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
variant="filled"
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}

View File

@@ -0,0 +1,338 @@
import React, { useState } from 'react';
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { validateEmail } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from '@app/constants/staticStripeLinks';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useIsMobile } from '@app/hooks/useIsMobile';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: 'server' | 'enterprise';
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>('email');
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState('');
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError('');
setStageHistory([...stageHistory, 'email']);
setStage('period-selection');
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, '_blank');
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key'),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
});
} 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 handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStageHistory([]);
setLicenseKey('');
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
return planName === 'server'
? t('plan.static.getLicense', 'Get Server License')
: t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
if (stage === 'period-selection') {
return t('plan.static.selectPeriod', 'Select Billing Period');
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
}
return '';
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case 'period-selection':
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case 'license-activation':
return (
<Stack gap="lg" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('plan.static.licenseActivation.checkoutOpened', 'Checkout Opened in New Tab')}
</Text>
<Text size="sm">
{t(
'plan.static.licenseActivation.instructions',
'Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.'
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
>
<Text size="sm">
{t(
'plan.static.licenseActivation.successMessage',
'Your license has been successfully activated. You can now close this window.'
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
'plan.static.licenseActivation.enterKey',
'Enter your license key below to activate your plan:'
)}
</Text>
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'plan.static.licenseActivation.keyDescription',
'Paste the license key from your email'
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t('plan.static.licenseActivation.activate', 'Activate License')}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t('common.back', 'Back')}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;

View File

@@ -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<StaticPlanSectionProps> = ({ 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<PremiumSettingsData>({
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<StaticPlanSectionProps> = ({ 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<StaticPlanSectionProps> = ({ 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 && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
{plan.popular && plan.id !== currentPlan.id && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
@@ -195,18 +172,123 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
);
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
// Server Plan
if (plan.id === 'server') {
if (currentTier === 'free') {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.free.included', 'Included')}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip label={t('plan.enterprise.requiresServer', 'Requires Server plan')} position="top" withArrow>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.enterprise.requiresServer', 'Requires Server')}
</Button>
</Tooltip>
);
}
if (currentTier === 'server') {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
@@ -230,66 +312,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{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.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
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"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);

View File

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

View File

@@ -0,0 +1,40 @@
/**
* Shared utilities for plan tier comparisons and button logic
*/
export type PlanTier = 'free' | 'server' | 'enterprise';
const TIER_HIERARCHY: Record<PlanTier, number> = {
'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';
}