Working with context

This commit is contained in:
Connor Yoh
2025-11-17 15:30:04 +00:00
parent 37b70ba776
commit 0e0bd656d9
11 changed files with 397 additions and 182 deletions

View File

@@ -1,5 +1,6 @@
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
import { AuthProvider } from "@app/auth/UseSession";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -8,7 +9,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
{children}
<CheckoutProvider>
{children}
</CheckoutProvider>
</AuthProvider>
</CoreAppProviders>
);

View File

@@ -13,7 +13,7 @@ interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
email: string;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
@@ -30,7 +30,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
email,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
@@ -67,11 +67,24 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
let currentLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo && licenseInfo.licenseKey) {
currentLicenseKey = licenseInfo.licenseKey;
console.log('Found existing license for upgrade');
}
} catch (error) {
console.warn('Could not fetch license info, proceeding as new license:', error);
}
const response = await licenseService.createCheckoutSession({
lookup_key: selectedPlan.lookupKey,
email,
installation_id: fetchedInstallationId,
current_license_key: currentLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: minimumSeats,
successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`,
cancelUrl: `${window.location.origin}/settings/adminPlan`,
});
@@ -210,6 +223,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
);
case 'ready':
{
if (!state.clientSecret || !selectedPlan) return null;
// Build period selector data with prices
@@ -275,7 +289,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
</Grid.Col>
</Grid>
);
}
case 'success':
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>

View File

@@ -2,12 +2,11 @@ import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import licenseService, { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
import { useCheckout } from '@app/contexts/CheckoutContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import ActivePlanSection from '@app/components/shared/config/configSections/plan//ActivePlanSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan//StaticPlanSection';
import { userManagementService } from '@app/services/userManagementService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -15,6 +14,7 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface PremiumSettingsData {
key?: string;
@@ -24,15 +24,11 @@ interface PremiumSettingsData {
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [checkoutOpen, setCheckoutOpen] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const { openCheckout } = useCheckout();
const [currency, setCurrency] = useState<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<any>(null);
const [licenseInfoLoading, setLicenseInfoLoading] = useState(false);
const [licenseInfoError, setLicenseInfoError] = useState<string | null>(null);
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<LicenseInfo | null>(null);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [email, setEmail] = useState<string>('');
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
// Premium/License key management
@@ -53,40 +49,16 @@ const AdminPlanSection: React.FC = () => {
useEffect(() => {
const fetchLicenseInfo = async () => {
try {
console.log('Fetching user and license info for plan section');
const adminData = await userManagementService.getUsers();
// Determine plan name based on config flags
let planName = 'Free';
if (config?.runningEE) {
planName = 'Enterprise';
} else if (config?.runningProOrHigher || adminData.premiumEnabled) {
planName = 'Pro';
}
setCurrentLicenseInfo({
planName,
maxUsers: adminData.maxAllowedUsers,
grandfathered: adminData.grandfatheredUserCount > 0,
});
// Also fetch license info from new backend endpoint
// Fetch license info from backend endpoint
try {
setLicenseInfoLoading(true);
setLicenseInfoError(null);
const backendLicenseInfo = await licenseService.getLicenseInfo();
setCurrentLicenseInfo(backendLicenseInfo);
setLicenseInfoLoading(false);
} catch (licenseErr: any) {
console.error('Failed to fetch backend license info:', licenseErr);
setLicenseInfoLoading(false);
setLicenseInfoError(licenseErr?.response?.data?.error || licenseErr?.message || 'Unknown error');
// Don't overwrite existing info if backend call fails
}
} catch (err) {
console.error('Failed to fetch license info:', err);
}
};
// Check if Stripe is configured
@@ -125,62 +97,27 @@ const AdminPlanSection: React.FC = () => {
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Validate email is provided
if (!email || !email.trim()) {
alert({
alertType: 'warning',
title: t('admin.plan.emailRequired.title', 'Email Required'),
body: t('admin.plan.emailRequired.message', 'Please enter your email address before proceeding'),
});
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
alert({
alertType: 'warning',
title: t('admin.plan.invalidEmail.title', 'Invalid Email'),
body: t('admin.plan.invalidEmail.message', 'Please enter a valid email address'),
});
return;
}
setSelectedPlanGroup(planGroup);
setCheckoutOpen(true);
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
onSuccess: () => {
// Refetch plans and license info after successful payment
refetch();
const fetchLicenseInfo = async () => {
try {
const backendLicenseInfo = await licenseService.getLicenseInfo();
setCurrentLicenseInfo(backendLicenseInfo);
} catch (err) {
console.error('Failed to refetch license info:', err);
}
};
fetchLicenseInfo();
},
});
},
[email, t]
[openCheckout, currency, refetch]
);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
// Don't refetch here - will refetch when modal closes to avoid re-renders
// Don't close modal - let user view license key and close manually
// Modal will show "You can now close this window" when ready
},
[]
);
const handlePaymentError = useCallback((error: string) => {
console.error('Payment error:', error);
// Error is already displayed in the StripeCheckout component
}, []);
const handleLicenseActivated = useCallback((licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => {
console.log('License activated:', licenseInfo);
setCurrentLicenseInfo(licenseInfo);
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlanGroup(null);
// Refetch plans after modal closes to update subscription display
refetch();
}, [refetch]);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
@@ -210,66 +147,22 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* License Information Display - Always visible */}
<Alert
color={licenseInfoLoading ? "gray" : licenseInfoError ? "red" : currentLicenseInfo?.enabled ? "green" : "blue"}
title={t('admin.plan.licenseInfo', 'License Information')}
>
{licenseInfoLoading ? (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm">{t('admin.plan.loadingLicense', 'Loading license information...')}</Text>
</Group>
) : licenseInfoError ? (
<Text size="sm" c="red">{t('admin.plan.licenseError', 'Failed to load license info')}: {licenseInfoError}</Text>
) : currentLicenseInfo ? (
<Stack gap="xs">
<Text size="sm">
<strong>{t('admin.plan.licenseType', 'License Type')}:</strong> {currentLicenseInfo.licenseType}
</Text>
<Text size="sm">
<strong>{t('admin.plan.status', 'Status')}:</strong> {currentLicenseInfo.enabled ? t('admin.plan.active', 'Active') : t('admin.plan.inactive', 'Inactive')}
</Text>
{currentLicenseInfo.licenseType === 'ENTERPRISE' && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm">
<strong>{t('admin.plan.maxUsers', 'Max Users')}:</strong> {currentLicenseInfo.maxUsers}
</Text>
)}
</Stack>
) : (
<Text size="sm">{t('admin.plan.noLicenseInfo', 'No license information available')}</Text>
)}
</Alert>
{/* Customer Information Section */}
{/* Currency Selection */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('admin.plan.customerInfo', 'Customer Information')}
{t('plan.currency', 'Currency')}
</Text>
<TextInput
label={t('admin.plan.email.label', 'Email Address')}
description={t('admin.plan.email.description', 'This email will be used to manage your subscription and billing')}
placeholder="admin@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
type="email"
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
/>
</Group>
</Stack>
</Group>
</Paper>
{currentSubscription && (
@@ -282,22 +175,10 @@ const AdminPlanSection: React.FC = () => {
<AvailablePlansSection
plans={plans}
currentPlanId={currentSubscription?.plan.id}
currentLicenseInfo={currentLicenseInfo}
onUpgradeClick={handleUpgradeClick}
/>
{/* Stripe Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={checkoutOpen}
onClose={handleCheckoutClose}
planGroup={selectedPlanGroup}
email={email}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
/>
)}
<Divider />
{/* License Key Section */}

View File

@@ -1,18 +1,20 @@
import React, { useState, useMemo } from 'react';
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup } from '@app/services/licenseService';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from './PlanCard';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
onUpgradeClick: (plan: PlanTier) => void;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentPlanId,
currentLicenseInfo,
onUpgradeClick,
}) => {
const { t } = useTranslation();
@@ -23,13 +25,42 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
return licenseService.groupPlansByTier(plans);
}, [plans]);
// Determine if the current tier matches
// Calculate current tier from license info
const currentTier = useMemo(() => {
return mapLicenseToTier(currentLicenseInfo || null);
}, [currentLicenseInfo]);
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
if (!currentPlanId) return false;
return (
// Check Stripe subscription match
if (currentPlanId && (
tierGroup.monthly?.id === currentPlanId ||
tierGroup.yearly?.id === currentPlanId
);
)) {
return true;
}
// Check license tier match
if (currentTier && tierGroup.tier === currentTier) {
return true;
}
return false;
};
// 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 (
@@ -60,6 +91,8 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
key={group.tier}
planGroup={group}
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
onUpgradeClick={onUpgradeClick}
/>
))}

View File

@@ -1,15 +1,17 @@
import React from 'react';
import { Button, Card, Badge, Text, Group, Stack, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup } from '@app/services/licenseService';
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
}
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgradeClick }) => {
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
const { t } = useTranslation();
// Render Free plan
@@ -24,8 +26,20 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgrade
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="xl" fw={700} mb="xs">
@@ -86,9 +100,20 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgrade
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{planGroup.popular && (
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
) : planGroup.popular ? (
<Badge
variant="filled"
size="sm"
@@ -96,7 +121,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgrade
>
{t('plan.popular', 'Popular')}
</Badge>
)}
) : null}
<Stack gap="md" style={{ height: '100%' }}>
{/* Tier Name */}
@@ -137,7 +162,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgrade
</Group>
)}
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} mt="xs">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
</div>
<Divider />
@@ -155,16 +185,18 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgrade
{/* Single Upgrade Button */}
<Button
variant={isCurrentTier ? 'light' : 'filled'}
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
fullWidth
onClick={() => onUpgradeClick(planGroup)}
disabled={isCurrentTier}
disabled={isCurrentTier || isDowngrade}
>
{isCurrentTier
? t('plan.current', 'Current Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
: isDowngrade
? t('plan.includedInCurrent', 'Included in Your Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Stack>
</Card>

View File

@@ -0,0 +1,186 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (defaults to 'gbp')
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
interface CheckoutContextValue {
openCheckout: (
tier: 'server' | 'enterprise',
options?: CheckoutOptions
) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | undefined>(undefined);
interface CheckoutProviderProps {
children: ReactNode;
defaultCurrency?: string;
}
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency = 'gbp'
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
// Load plans with current currency
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
const openCheckout = useCallback(
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
try {
setIsLoading(true);
// Update currency if provided
const currency = options.currency || currentCurrency;
if (currency !== currentCurrency) {
setCurrentCurrency(currency);
// Plans will reload automatically via usePlans
}
// Fetch license info and user data for seat calculations
let licenseInfo: LicenseInfo | null = null;
let totalUsers = 0;
try {
const [licenseData, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers()
]);
licenseInfo = licenseData;
totalUsers = userData.totalUsers || 0;
} catch (err) {
console.warn('Could not fetch license/user info, proceeding with defaults:', err);
}
// Calculate minimum seats for enterprise upgrades
let calculatedMinSeats = options.minimumSeats || 1;
if (tier === 'enterprise' && !options.minimumSeats) {
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'server' || currentTier === 'free') {
// Upgrading from Server (unlimited) to Enterprise (per-seat)
// Use current total user count as minimum
calculatedMinSeats = Math.max(totalUsers, 1);
console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`);
} else if (currentTier === 'enterprise') {
// Upgrading within Enterprise (e.g., monthly to yearly)
// Use current licensed seat count as minimum
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
console.log(`Setting minimum seats from current license: ${calculatedMinSeats}`);
}
}
// Find the plan group for the requested tier
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (!planGroup) {
throw new Error(`No ${tier} plan available`);
}
// Store options for callbacks
setCurrentOptions(options);
setMinimumSeats(calculatedMinSeats);
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout';
console.error('Error opening checkout:', errorMessage);
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[currentCurrency, plans]
);
const closeCheckout = useCallback(() => {
setIsOpen(false);
setSelectedPlanGroup(null);
setCurrentOptions({});
// Refetch plans after modal closes to update subscription display
refetchPlans();
}, [refetchPlans]);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
currentOptions.onSuccess?.(sessionId);
// Don't close modal - let user view license key and close manually
},
[currentOptions]
);
const handlePaymentError = useCallback(
(error: string) => {
console.error('Payment error:', error);
currentOptions.onError?.(error);
},
[currentOptions]
);
const handleLicenseActivated = useCallback((licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log('License activated:', licenseInfo);
// Could expose this via context if needed
}, []);
const contextValue: CheckoutContextValue = {
openCheckout,
closeCheckout,
isOpen,
isLoading,
};
return (
<CheckoutContext.Provider value={contextValue}>
{children}
{/* Global Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
/>
)}
</CheckoutContext.Provider>
);
};
export const useCheckout = (): CheckoutContextValue => {
const context = useContext(CheckoutContext);
if (!context) {
throw new Error('useCheckout must be used within CheckoutProvider');
}
return context;
};

View File

@@ -47,8 +47,8 @@ export interface PlansResponse {
export interface CheckoutSessionRequest {
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
email: string; // Customer email (required for self-hosted)
installation_id?: string; // Installation ID from backend (MAC-based fingerprint)
current_license_key?: string; // Current license key for upgrades
requires_seats?: boolean; // Whether to add adjustable seat pricing
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
successUrl?: string;
@@ -75,6 +75,14 @@ export interface LicenseKeyResponse {
plan?: string;
}
export interface LicenseInfo {
licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE';
enabled: boolean;
maxUsers: number;
hasKey: boolean;
licenseKey?: string; // The actual license key (for upgrades)
}
// Currency symbol mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
@@ -365,8 +373,8 @@ const licenseService = {
body: {
self_hosted: true,
lookup_key: request.lookup_key,
email: request.email,
installation_id: request.installation_id,
current_license_key: request.current_license_key,
requires_seats: request.requires_seats,
seat_count: request.seat_count || 1,
callback_base_url: window.location.origin,
@@ -449,7 +457,7 @@ const licenseService = {
/**
* Get current license information from backend
*/
async getLicenseInfo(): Promise<{licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}> {
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get('/api/v1/admin/license-info');
return response.data;
@@ -460,4 +468,31 @@ const licenseService = {
},
};
/**
* Map license type to plan tier
* @param licenseInfo - Current license information
* @returns Plan tier: 'free' | 'server' | 'enterprise'
*/
export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'server' | 'enterprise' | null => {
if (!licenseInfo) return null;
// No license or NORMAL type = Free tier
if (licenseInfo.licenseType === 'NORMAL' || !licenseInfo.enabled) {
return 'free';
}
// PRO type (no seats) = Server tier
if (licenseInfo.licenseType === 'PRO') {
return 'server';
}
// ENTERPRISE type (with seats) = Enterprise tier
if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) {
return 'enterprise';
}
// Default fallback
return 'free';
};
export default licenseService;