mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02: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"
|
contactToUpgrade = "Contact us to upgrade or customize your plan"
|
||||||
maxUsers = "Max Users"
|
maxUsers = "Max Users"
|
||||||
upTo = "Up to"
|
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]
|
[plan.period]
|
||||||
month = "month"
|
month = "month"
|
||||||
|
|||||||
+5
-235
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { usePlans } from '@app/hooks/usePlans';
|
import { usePlans } from '@app/hooks/usePlans';
|
||||||
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
|
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 { useLicense } from '@app/contexts/LicenseContext';
|
||||||
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
||||||
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
||||||
|
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
|
||||||
import { alert } from '@app/components/toast';
|
import { alert } from '@app/components/toast';
|
||||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
|
||||||
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
import { InfoBanner } from '@app/components/shared/InfoBanner';
|
||||||
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
|
||||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
|
||||||
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
|
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
|
||||||
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
import { useLoginRequired } from '@app/hooks/useLoginRequired';
|
||||||
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
|
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
|
||||||
|
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||||
|
|
||||||
const AdminPlanSection: React.FC = () => {
|
const AdminPlanSection: React.FC = () => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
|
||||||
const { openCheckout } = useCheckout();
|
const { openCheckout } = useCheckout();
|
||||||
const { licenseInfo, refetchLicense } = useLicense();
|
const { licenseInfo } = useLicense();
|
||||||
const [currency, setCurrency] = useState<string>(() => {
|
const [currency, setCurrency] = useState<string>(() => {
|
||||||
// Initialize with auto-detected currency on first render
|
// Initialize with auto-detected currency on first render
|
||||||
return getPreferredCurrency(i18n.language);
|
return getPreferredCurrency(i18n.language);
|
||||||
});
|
});
|
||||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
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 { plans, loading, error, refetch } = usePlans(currency);
|
||||||
const licenseAlert = useLicenseAlert();
|
const licenseAlert = useLicenseAlert();
|
||||||
|
|
||||||
@@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [error]);
|
}, [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 = [
|
const currencyOptions = [
|
||||||
{ value: 'gbp', label: 'British pound (GBP, £)' },
|
{ value: 'gbp', label: 'British pound (GBP, £)' },
|
||||||
{ value: 'usd', label: 'US dollar (USD, $)' },
|
{ value: 'usd', label: 'US dollar (USD, $)' },
|
||||||
@@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => {
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* License Key Section */}
|
{/* License Key Section */}
|
||||||
<div>
|
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-19
@@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier
|
|||||||
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
||||||
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
||||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||||
|
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils';
|
||||||
|
|
||||||
interface AvailablePlansSectionProps {
|
interface AvailablePlansSectionProps {
|
||||||
plans: PlanTier[];
|
plans: PlanTier[];
|
||||||
@@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
|||||||
|
|
||||||
// Determine if the current tier matches (checks both Stripe subscription and license)
|
// Determine if the current tier matches (checks both Stripe subscription and license)
|
||||||
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
||||||
// Check license tier match
|
return checkIsCurrentTier(currentTier, tierGroup.tier);
|
||||||
if (currentTier && tierGroup.tier === currentTier) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine if selecting this plan would be a downgrade
|
// Determine if selecting this plan would be a downgrade
|
||||||
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
|
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
|
||||||
if (!currentTier) return false;
|
return checkIsDowngrade(currentTier, tierGroup.tier);
|
||||||
|
|
||||||
// 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 (
|
return (
|
||||||
@@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{groupedPlans.map((group) => (
|
{groupedPlans.map((group) => (
|
||||||
|
|||||||
+273
@@ -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 { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
|
||||||
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
|
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
|
||||||
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
|
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
|
||||||
|
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
|
||||||
|
|
||||||
interface PlanCardProps {
|
interface PlanCardProps {
|
||||||
planGroup: PlanTierGroup;
|
planGroup: PlanTierGroup;
|
||||||
@@ -83,7 +84,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
const isEnterprise = planGroup.tier === 'enterprise';
|
const isEnterprise = planGroup.tier === 'enterprise';
|
||||||
|
|
||||||
// Block enterprise for free tier users (must have server first)
|
// 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
|
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
||||||
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
|
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
|
||||||
@@ -174,7 +175,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
|
|||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
|
variant="filled"
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
|
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
|
||||||
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
|
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
|
||||||
|
|||||||
+338
@@ -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;
|
||||||
+172
-142
@@ -1,20 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
|
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { alert } from '@app/components/toast';
|
||||||
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
|
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
|
||||||
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
||||||
|
import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal';
|
||||||
interface PremiumSettingsData {
|
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
|
||||||
key?: string;
|
import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks';
|
||||||
enabled?: boolean;
|
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 {
|
interface StaticPlanSectionProps {
|
||||||
currentLicenseInfo?: LicenseInfo;
|
currentLicenseInfo?: LicenseInfo;
|
||||||
@@ -22,38 +18,45 @@ interface StaticPlanSectionProps {
|
|||||||
|
|
||||||
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
|
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
|
||||||
const [showComparison, setShowComparison] = useState(false);
|
const [showComparison, setShowComparison] = useState(false);
|
||||||
|
|
||||||
// Premium/License key management
|
// Static checkout modal state
|
||||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
|
||||||
const {
|
const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server');
|
||||||
settings: premiumSettings,
|
const [isUpgrade, setIsUpgrade] = useState(false);
|
||||||
setSettings: setPremiumSettings,
|
|
||||||
loading: premiumLoading,
|
|
||||||
saving: premiumSaving,
|
|
||||||
fetchSettings: fetchPremiumSettings,
|
|
||||||
saveSettings: savePremiumSettings,
|
|
||||||
isFieldPending,
|
|
||||||
} = useAdminSettings<PremiumSettingsData>({
|
|
||||||
sectionName: 'premium',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => {
|
||||||
fetchPremiumSettings();
|
// Prevent Free → Enterprise (must have Server first)
|
||||||
}, []);
|
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
|
||||||
|
if (currentTier === 'free' && plan === 'enterprise') {
|
||||||
const handleSaveLicense = async () => {
|
|
||||||
try {
|
|
||||||
await savePremiumSettings();
|
|
||||||
showRestartModal();
|
|
||||||
} catch (_error) {
|
|
||||||
alert({
|
alert({
|
||||||
alertType: 'error',
|
alertType: 'warning',
|
||||||
title: t('admin.error', 'Error'),
|
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
|
||||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
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 = [
|
const staticPlans = [
|
||||||
@@ -122,7 +125,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
paddingBottom: '1rem',
|
paddingBottom: '0.1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{staticPlans.map((plan) => (
|
{staticPlans.map((plan) => (
|
||||||
@@ -131,53 +134,27 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
|
|||||||
padding="lg"
|
padding="lg"
|
||||||
radius="md"
|
radius="md"
|
||||||
withBorder
|
withBorder
|
||||||
style={{
|
style={getBaseCardStyle(plan.id === currentPlan.id)}
|
||||||
position: 'relative',
|
className="plan-card"
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
|
|
||||||
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{plan.id === currentPlan.id && (
|
{plan.id === currentPlan.id && (
|
||||||
<Badge
|
<PricingBadge
|
||||||
color="green"
|
type="current"
|
||||||
variant="filled"
|
label={t('plan.current', 'Current Plan')}
|
||||||
size="sm"
|
/>
|
||||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
|
||||||
>
|
|
||||||
{t('plan.current', 'Current Plan')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
{plan.popular && plan.id !== currentPlan.id && (
|
{plan.popular && plan.id !== currentPlan.id && (
|
||||||
<Badge
|
<PricingBadge
|
||||||
variant="filled"
|
type="popular"
|
||||||
size="xs"
|
label={t('plan.popular', 'Popular')}
|
||||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
/>
|
||||||
>
|
|
||||||
{t('plan.popular', 'Popular')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="md" style={{ height: '100%' }}>
|
<Stack gap="md" style={{ height: '100%' }}>
|
||||||
<div>
|
<div>
|
||||||
<Text size="lg" fw={600}>
|
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
|
||||||
{plan.name}
|
{plan.name}
|
||||||
</Text>
|
</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">
|
<Text size="xs" c="dimmed" mt="xs">
|
||||||
{typeof plan.maxUsers === 'string'
|
{typeof plan.maxUsers === 'string'
|
||||||
? plan.maxUsers
|
? plan.maxUsers
|
||||||
@@ -195,18 +172,123 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
|
|||||||
|
|
||||||
<div style={{ flexGrow: 1 }} />
|
<div style={{ flexGrow: 1 }} />
|
||||||
|
|
||||||
|
{/* 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
|
<Button
|
||||||
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
|
variant="filled"
|
||||||
disabled={plan.id === currentPlan.id}
|
disabled
|
||||||
fullWidth
|
fullWidth
|
||||||
onClick={() =>
|
className="plan-button"
|
||||||
window.open('https://www.stirling.com/contact', '_blank')
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{plan.id === currentPlan.id
|
{isCurrent
|
||||||
? t('plan.current', 'Current Plan')
|
? t('plan.current', 'Current Plan')
|
||||||
: t('plan.contact', 'Contact Us')}
|
: t('plan.free.included', 'Included')}
|
||||||
</Button>
|
</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>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -230,66 +312,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{/* License Key Section */}
|
{/* License Key Section */}
|
||||||
<div>
|
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
|
||||||
<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">
|
{/* Static Checkout Modal */}
|
||||||
<Stack gap="md">
|
<StaticCheckoutModal
|
||||||
<Alert
|
opened={checkoutModalOpened}
|
||||||
variant="light"
|
onClose={() => setCheckoutModalOpened(false)}
|
||||||
color="blue"
|
planName={selectedPlan}
|
||||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
isUpgrade={isUpgrade}
|
||||||
>
|
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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