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

@@ -0,0 +1,37 @@
import React, { useState } from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService from '@app/services/licenseService';
import { alert } from '@app/components/toast';
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
const response = await licenseService.createBillingPortalSession(returnUrl);
window.location.href = response.url;
} catch (error) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t('billing.manageBilling', 'Manage Billing')}
</Button>
);
};

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

@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import licenseService, {
PlanTier,
SubscriptionInfo,
PlansResponse,
} from '@app/services/licenseService';
export interface UsePlansReturn {
plans: PlanTier[];
currentSubscription: SubscriptionInfo | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [currentSubscription, setCurrentSubscription] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
setCurrentSubscription(data.currentSubscription);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
return {
plans,
currentSubscription,
loading,
error,
refetch: fetchPlans,
};
};

View File

@@ -0,0 +1,498 @@
import apiClient from './apiClient';
import { supabase } from './supabaseClient';
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: string[];
isContactOnly?: boolean;
seatPrice?: number; // Per-seat price for enterprise plans
requiresSeats?: boolean; // Flag indicating seat selection is needed
lookupKey: string; // Stripe lookup key for this plan
}
export interface PlanTierGroup {
tier: 'free' | 'server' | 'enterprise';
name: string;
monthly: PlanTier | null;
yearly: PlanTier | null;
features: PlanFeature[];
highlights: string[];
popular?: boolean;
}
export interface SubscriptionInfo {
plan: PlanTier;
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing' | 'none';
currentPeriodEnd?: string;
cancelAtPeriodEnd?: boolean;
stripeCustomerId?: string;
stripeSubscriptionId?: string;
}
export interface PlansResponse {
plans: PlanTier[];
currentSubscription: SubscriptionInfo | null;
}
export interface CheckoutSessionRequest {
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
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;
cancelUrl?: string;
}
export interface CheckoutSessionResponse {
clientSecret: string;
sessionId: string;
}
export interface BillingPortalResponse {
url: string;
}
export interface InstallationIdResponse {
installationId: string;
}
export interface LicenseKeyResponse {
status: 'ready' | 'pending';
license_key?: string;
email?: string;
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 } = {
'gbp': '£',
'usd': '$',
'eur': '€',
'cny': '¥',
'inr': '₹',
'brl': 'R$',
'idr': 'Rp'
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
// Self-hosted plan lookup keys
const SELF_HOSTED_LOOKUP_KEYS = [
'selfhosted:server:monthly',
'selfhosted:server:yearly',
'selfhosted:enterpriseseat:monthly',
'selfhosted:enterpriseseat:yearly',
];
const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
try {
// Fetch all self-hosted prices from Stripe
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, {
unit_amount: number;
currency: string;
lookup_key: string;
}>;
missing: string[];
}>('stripe-price-lookup', {
body: {
lookup_keys: SELF_HOSTED_LOOKUP_KEYS,
currency
},
});
if (error) {
throw new Error(`Failed to fetch plans: ${error.message}`);
}
if (!data || !data.prices) {
throw new Error('No pricing data returned');
}
// Log missing prices for debugging
if (data.missing && data.missing.length > 0) {
console.warn('Missing Stripe prices for lookup keys:', data.missing, 'in currency:', currency);
}
// Build price map for easy access
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
for (const [lookupKey, priceData] of Object.entries(data.prices)) {
priceMap.set(lookupKey, {
unit_amount: priceData.unit_amount,
currency: priceData.currency
});
}
const currencySymbol = getCurrencySymbol(currency);
// Helper to get price info
const getPriceInfo = (lookupKey: string, fallback: number = 0) => {
const priceData = priceMap.get(lookupKey);
return priceData ? priceData.unit_amount / 100 : fallback;
};
// Build plan tiers
const plans: PlanTier[] = [
{
id: 'selfhosted:server:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Server - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
features: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Unlimited users', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Priority support', included: false },
{ name: 'Custom integrations', included: false },
],
highlights: [
'Self-hosted on your infrastructure',
'All features included',
'Cancel anytime'
]
},
{
id: 'selfhosted:server:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Server - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
currency: currencySymbol,
period: '/year',
popular: true,
features: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Unlimited users', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Priority support', included: false },
{ name: 'Custom integrations', included: false },
],
highlights: [
'Self-hosted on your infrastructure',
'All features included',
'Save with annual billing'
]
},
{
id: 'selfhosted:enterprise:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Enterprise - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
requiresSeats: true,
features: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Per-seat licensing', included: true },
{ name: 'Priority support', included: true },
{ name: 'SLA guarantee', included: true },
{ name: 'Custom integrations', included: true },
{ name: 'Dedicated account manager', included: true },
],
highlights: [
'Enterprise-grade support',
'Custom integrations available',
'SLA guarantee included'
]
},
{
id: 'selfhosted:enterprise:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Enterprise - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:yearly'),
currency: currencySymbol,
period: '/year',
popular: false,
requiresSeats: true,
features: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Per-seat licensing', included: true },
{ name: 'Priority support', included: true },
{ name: 'SLA guarantee', included: true },
{ name: 'Custom integrations', included: true },
{ name: 'Dedicated account manager', included: true },
],
highlights: [
'Enterprise-grade support',
'Custom integrations available',
'Save with annual billing'
]
},
];
// Filter out plans with missing prices (price === 0 means Stripe price not found)
const validPlans = plans.filter(plan => plan.price > 0);
if (validPlans.length < plans.length) {
const missingPlans = plans.filter(plan => plan.price === 0).map(p => p.id);
console.warn('Filtered out plans with missing prices:', missingPlans);
}
// Add Free plan (static definition)
const freePlan: PlanTier = {
id: 'free',
lookupKey: 'free',
name: 'Free',
price: 0,
currency: currencySymbol,
period: '',
popular: false,
features: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Up to 5 users', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Priority support', included: false },
{ name: 'SLA guarantee', included: false },
{ name: 'Custom integrations', included: false },
{ name: 'Dedicated account manager', included: false },
],
highlights: [
'Up to 5 users',
'Self-hosted',
'All basic features'
]
};
const allPlans = [freePlan, ...validPlans];
return {
plans: allPlans,
currentSubscription: null // Will be implemented later
};
} catch (error) {
console.error('Error fetching plans:', error);
throw error;
}
},
/**
* Get current subscription details
* TODO: Implement with Supabase edge function when available
*/
async getCurrentSubscription(): Promise<SubscriptionInfo | null> {
// Placeholder - will be implemented later
return null;
},
/**
* Group plans by tier for display (Free, Server, Enterprise)
*/
groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] {
const groups: PlanTierGroup[] = [];
// Free tier
const freePlan = plans.find(p => p.id === 'free');
if (freePlan) {
groups.push({
tier: 'free',
name: 'Free',
monthly: freePlan,
yearly: null,
features: freePlan.features,
highlights: freePlan.highlights,
popular: false,
});
}
// Server tier
const serverMonthly = plans.find(p => p.lookupKey === 'selfhosted:server:monthly');
const serverYearly = plans.find(p => p.lookupKey === 'selfhosted:server:yearly');
if (serverMonthly || serverYearly) {
groups.push({
tier: 'server',
name: 'Server',
monthly: serverMonthly || null,
yearly: serverYearly || null,
features: (serverMonthly || serverYearly)!.features,
highlights: (serverMonthly || serverYearly)!.highlights,
popular: serverYearly?.popular || serverMonthly?.popular || false,
});
}
// Enterprise tier (uses server pricing + seats)
const enterpriseMonthly = plans.find(p => p.id === 'selfhosted:enterprise:monthly');
const enterpriseYearly = plans.find(p => p.id === 'selfhosted:enterprise:yearly');
if (enterpriseMonthly || enterpriseYearly) {
groups.push({
tier: 'enterprise',
name: 'Enterprise',
monthly: enterpriseMonthly || null,
yearly: enterpriseYearly || null,
features: (enterpriseMonthly || enterpriseYearly)!.features,
highlights: (enterpriseMonthly || enterpriseYearly)!.highlights,
popular: false,
});
}
return groups;
},
/**
* Create a Stripe checkout session for upgrading
*/
async createCheckoutSession(request: CheckoutSessionRequest): Promise<CheckoutSessionResponse> {
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
self_hosted: true,
lookup_key: request.lookup_key,
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,
},
});
if (error) {
throw new Error(`Failed to create checkout session: ${error.message}`);
}
return data as CheckoutSessionResponse;
},
/**
* Create a Stripe billing portal session for managing subscription
*/
async createBillingPortalSession(email: string, returnUrl: string): Promise<BillingPortalResponse> {
const { data, error} = await supabase.functions.invoke('manage-billing', {
body: {
email,
returnUrl
},
});
if (error) {
throw new Error(`Failed to create billing portal session: ${error.message}`);
}
return data as BillingPortalResponse;
},
/**
* Get the installation ID from the backend (MAC-based fingerprint)
*/
async getInstallationId(): Promise<string> {
try {
const response = await apiClient.get('/api/v1/admin/installation-id');
const data: InstallationIdResponse = await response.data;
return data.installationId;
} catch (error) {
console.error('Error fetching installation ID:', error);
throw error;
}
},
/**
* Check if license key is ready for the given installation ID
*/
async checkLicenseKey(installationId: string): Promise<LicenseKeyResponse> {
const { data, error } = await supabase.functions.invoke('get-license-key', {
body: {
installation_id: installationId,
},
});
if (error) {
throw new Error(`Failed to check license key: ${error.message}`);
}
return data as LicenseKeyResponse;
},
/**
* Save license key to backend
*/
async saveLicenseKey(licenseKey: string): Promise<{success: boolean; licenseType?: string; message?: string; error?: string}> {
try {
const response = await apiClient.post('/api/v1/admin/license-key', {
licenseKey: licenseKey,
});
return response.data;
} catch (error) {
console.error('Error saving license key:', error);
throw error;
}
},
/**
* Get current license information from backend
*/
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get('/api/v1/admin/license-info');
return response.data;
} catch (error) {
console.error('Error fetching license info:', error);
throw error;
}
},
};
/**
* 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;