mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
49
frontend/src/proprietary/hooks/usePlans.ts
Normal file
49
frontend/src/proprietary/hooks/usePlans.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
498
frontend/src/proprietary/services/licenseService.ts
Normal file
498
frontend/src/proprietary/services/licenseService.ts
Normal 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;
|
||||
Reference in New Issue
Block a user