mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Working with context
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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!')}>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
186
frontend/src/proprietary/contexts/CheckoutContext.tsx
Normal file
186
frontend/src/proprietary/contexts/CheckoutContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user