mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
56
frontend/src/proprietary/constants/staticStripeLinks.ts
Normal file
56
frontend/src/proprietary/constants/staticStripeLinks.ts
Normal 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}`;
|
||||
}
|
||||
40
frontend/src/proprietary/utils/planTierUtils.ts
Normal file
40
frontend/src/proprietary/utils/planTierUtils.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user