mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Get and pay for products from supabase
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } from '@mantine/core';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import licenseService from '@app/services/licenseService';
|
||||
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
// Initialize Stripe - this should come from environment variables
|
||||
@@ -12,10 +12,8 @@ const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planId: string;
|
||||
planName: string;
|
||||
planPrice: number;
|
||||
currency: string;
|
||||
planGroup: PlanTierGroup;
|
||||
email: string;
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
@@ -30,23 +28,48 @@ type CheckoutState = {
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
planName,
|
||||
planPrice,
|
||||
currency,
|
||||
planGroup,
|
||||
email,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
// Default to yearly if available (better value), otherwise monthly
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
|
||||
planGroup.yearly ? 'yearly' : 'monthly'
|
||||
);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [licenseKey, setLicenseKey] = useState<string | null>(null);
|
||||
const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle');
|
||||
|
||||
// Get the selected plan based on period
|
||||
const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly;
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
if (!selectedPlan) {
|
||||
setState({
|
||||
status: 'error',
|
||||
error: 'Selected plan period is not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
|
||||
// Fetch installation ID from backend
|
||||
let fetchedInstallationId = installationId;
|
||||
if (!fetchedInstallationId) {
|
||||
fetchedInstallationId = await licenseService.getInstallationId();
|
||||
setInstallationId(fetchedInstallationId);
|
||||
}
|
||||
|
||||
const response = await licenseService.createCheckoutSession({
|
||||
planId,
|
||||
currency,
|
||||
lookup_key: selectedPlan.lookupKey,
|
||||
email,
|
||||
installation_id: fetchedInstallationId,
|
||||
requires_seats: selectedPlan.requiresSeats,
|
||||
successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${window.location.origin}/settings/adminPlan`,
|
||||
});
|
||||
@@ -67,51 +90,167 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const pollForLicenseKey = useCallback(async (installId: string) => {
|
||||
const maxAttempts = 15; // 30 seconds (15 × 2s)
|
||||
let attempts = 0;
|
||||
|
||||
setPollingStatus('polling');
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await licenseService.checkLicenseKey(installId);
|
||||
|
||||
if (response.status === 'ready' && response.license_key) {
|
||||
setLicenseKey(response.license_key);
|
||||
setPollingStatus('ready');
|
||||
return;
|
||||
}
|
||||
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
setPollingStatus('timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue polling
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
return poll();
|
||||
|
||||
} catch (error) {
|
||||
console.error('License polling error:', error);
|
||||
attempts++;
|
||||
if (attempts >= maxAttempts) {
|
||||
setPollingStatus('timeout');
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
return poll();
|
||||
}
|
||||
};
|
||||
|
||||
await poll();
|
||||
}, []);
|
||||
|
||||
const handlePaymentComplete = () => {
|
||||
setState({ status: 'success' });
|
||||
onSuccess?.(state.sessionId || '');
|
||||
// Preserve state when changing status
|
||||
setState(prev => ({ ...prev, status: 'success' }));
|
||||
|
||||
// Start polling BEFORE notifying parent (so modal stays open)
|
||||
if (installationId) {
|
||||
pollForLicenseKey(installationId).finally(() => {
|
||||
// Only notify parent after polling completes or times out
|
||||
onSuccess?.(state.sessionId || '');
|
||||
});
|
||||
} else {
|
||||
// No installation ID, notify immediately
|
||||
onSuccess?.(state.sessionId || '');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState({ status: 'idle' });
|
||||
// Reset to default period on close
|
||||
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens
|
||||
const handlePeriodChange = (value: string) => {
|
||||
setSelectedPeriod(value as 'monthly' | 'yearly');
|
||||
// Reset state to trigger checkout reload
|
||||
setState({ status: 'idle' });
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens or period changes
|
||||
useEffect(() => {
|
||||
// Don't reset if we're showing success state (license key)
|
||||
if (state.status === 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opened && state.status === 'idle') {
|
||||
createCheckoutSession();
|
||||
} else if (!opened) {
|
||||
setState({ status: 'idle' });
|
||||
}
|
||||
}, [opened]);
|
||||
}, [opened, selectedPeriod, state.status]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.preparing', 'Preparing your checkout...')}
|
||||
</Text>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'ready':
|
||||
if (!state.clientSecret) return null;
|
||||
if (!state.clientSecret || !selectedPlan) return null;
|
||||
|
||||
// Build period selector data with prices
|
||||
const periodData = [];
|
||||
if (planGroup.monthly) {
|
||||
const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice
|
||||
? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat`
|
||||
: `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`;
|
||||
|
||||
periodData.push({
|
||||
value: 'monthly',
|
||||
label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`,
|
||||
});
|
||||
}
|
||||
if (planGroup.yearly) {
|
||||
const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice
|
||||
? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat`
|
||||
: `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`;
|
||||
|
||||
periodData.push({
|
||||
value: 'yearly',
|
||||
label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
<Grid gutter="md">
|
||||
{/* Left: Period Selector - only show if both periods available */}
|
||||
{periodData.length > 1 && (
|
||||
<Grid.Col span={3}>
|
||||
<Stack gap="sm" style={{ height: '100%' }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.billingPeriod', 'Billing Period')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={selectedPeriod}
|
||||
onChange={handlePeriodChange}
|
||||
data={periodData}
|
||||
orientation="vertical"
|
||||
fullWidth
|
||||
/>
|
||||
{selectedPlan.requiresSeats && selectedPlan.seatPrice && (
|
||||
<Text size="xs" c="dimmed" mt="md">
|
||||
{t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{/* Right: Stripe Checkout */}
|
||||
<Grid.Col span={periodData.length > 1 ? 9 : 12}>
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
@@ -121,12 +260,72 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.successMessage',
|
||||
'Your subscription has been activated successfully. You will receive a confirmation email shortly.'
|
||||
'Your subscription has been activated successfully.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.autoClose', 'This window will close automatically...')}
|
||||
</Text>
|
||||
|
||||
{/* Installation ID Display */}
|
||||
{installationId && (
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="xs" fw={600}>
|
||||
{t('payment.installationId', 'Installation ID')}
|
||||
</Text>
|
||||
<Code block>{installationId}</Code>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* License Key Polling Status */}
|
||||
{pollingStatus === 'polling' && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('payment.generatingLicense', 'Generating your license key...')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && licenseKey && (
|
||||
<Paper withBorder p="md" radius="md" bg="gray.1">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.licenseKey', 'Your License Key')}
|
||||
</Text>
|
||||
<Code block>{licenseKey}</Code>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(licenseKey)}
|
||||
>
|
||||
{t('common.copy', 'Copy to Clipboard')}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'payment.licenseInstructions',
|
||||
'Enter this key in Settings → Admin Plan → License Key section'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'timeout' && (
|
||||
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.licenseDelayedMessage',
|
||||
'Your license key is being generated. Please check your email shortly or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.canCloseWindow', 'You can now close this window.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
@@ -153,22 +352,24 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName })}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{currency}
|
||||
{planPrice}/{t('plan.period.month', 'month')}
|
||||
</Text>
|
||||
</div>
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })}
|
||||
</Text>
|
||||
}
|
||||
size="xl"
|
||||
size="90%"
|
||||
centered
|
||||
withCloseButton={state.status !== 'ready'}
|
||||
closeOnEscape={state.status !== 'ready'}
|
||||
closeOnClickOutside={state.status !== 'ready'}
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
minHeight: '85vh',
|
||||
},
|
||||
content: {
|
||||
maxHeight: '95vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { PlanTier } from '@app/services/licenseService';
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
import StripeCheckout from '@app/components/shared/StripeCheckout';
|
||||
import AvailablePlansSection from './plan/AvailablePlansSection';
|
||||
import ActivePlanSection from './plan/ActivePlanSection';
|
||||
@@ -25,11 +25,12 @@ const AdminPlanSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
|
||||
const [currency, setCurrency] = useState<string>('gbp');
|
||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<any>(null);
|
||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
|
||||
|
||||
// Premium/License key management
|
||||
@@ -105,35 +106,43 @@ const AdminPlanSection: React.FC = () => {
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(plan: PlanTier) => {
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open('https://www.stirling.com/contact', '_blank');
|
||||
(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;
|
||||
}
|
||||
|
||||
if (!currentSubscription || plan.id !== currentSubscription.plan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setCheckoutOpen(true);
|
||||
// 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);
|
||||
},
|
||||
[currentSubscription]
|
||||
[email, t]
|
||||
);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log('Payment successful, session:', sessionId);
|
||||
|
||||
// Refetch plans to update current subscription
|
||||
refetch();
|
||||
|
||||
// Close modal after brief delay to show success message
|
||||
setTimeout(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}, 2000);
|
||||
// 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
|
||||
},
|
||||
[refetch]
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
@@ -143,8 +152,11 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}, []);
|
||||
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) {
|
||||
@@ -165,7 +177,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
|
||||
}
|
||||
|
||||
if (!plans || !currentSubscription) {
|
||||
if (!plans || plans.length === 0) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
@@ -175,42 +187,57 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
{/* Customer Information Section */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('plan.currency', 'Currency')}
|
||||
{t('admin.plan.customerInfo', 'Customer Information')}
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || 'gbp')}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={false}
|
||||
w={300}
|
||||
<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"
|
||||
/>
|
||||
</Group>
|
||||
</div>
|
||||
<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>
|
||||
</Paper>
|
||||
|
||||
<ActivePlanSection subscription={currentSubscription} />
|
||||
|
||||
<Divider />
|
||||
{currentSubscription && (
|
||||
<>
|
||||
<ActivePlanSection subscription={currentSubscription} />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentPlanId={currentSubscription.plan.id}
|
||||
currentPlanId={currentSubscription?.plan.id}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
{selectedPlan && (
|
||||
{selectedPlanGroup && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={handleCheckoutClose}
|
||||
planId={selectedPlan.id}
|
||||
planName={selectedPlan.name}
|
||||
planPrice={selectedPlan.price}
|
||||
currency={selectedPlan.currency}
|
||||
planGroup={selectedPlanGroup}
|
||||
email={email}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
import licenseService, { PlanTier, PlanTierGroup } from '@app/services/licenseService';
|
||||
import PlanCard from './PlanCard';
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlanId: string;
|
||||
currentPlanId?: string;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
// Group plans by tier (Free, Server, Enterprise)
|
||||
const groupedPlans = useMemo(() => {
|
||||
return licenseService.groupPlansByTier(plans);
|
||||
}, [plans]);
|
||||
|
||||
// Determine if the current tier matches
|
||||
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
||||
if (!currentPlanId) return false;
|
||||
return (
|
||||
tierGroup.monthly?.id === currentPlanId ||
|
||||
tierGroup.yearly?.id === currentPlanId
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
@@ -41,11 +55,11 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
{groupedPlans.map((group) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlanId}
|
||||
key={group.tier}
|
||||
planGroup={group}
|
||||
isCurrentTier={isCurrentTier(group)}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
@@ -66,30 +80,32 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
</Text>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem' }}>
|
||||
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem' }}>
|
||||
{t('plan.feature.title', 'Feature')}
|
||||
</th>
|
||||
{plans.map((plan) => (
|
||||
{groupedPlans.map((group) => (
|
||||
<th
|
||||
key={plan.id}
|
||||
style={{ textAlign: 'center', padding: '0.5rem', minWidth: '6rem', position: 'relative' }}
|
||||
key={group.tier}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
minWidth: '8rem',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
{group.name}
|
||||
{group.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0rem',
|
||||
right: '-2rem',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: '500',
|
||||
height: '1rem',
|
||||
padding: '0 0.25rem',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
@@ -100,20 +116,24 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0].features.map((_, featureIndex) => (
|
||||
{groupedPlans[0]?.features.map((_, featureIndex) => (
|
||||
<tr
|
||||
key={featureIndex}
|
||||
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
|
||||
>
|
||||
<td style={{ padding: '0.5rem' }}>{plans[0].features[featureIndex].name}</td>
|
||||
{plans.map((plan) => (
|
||||
<td key={plan.id} style={{ textAlign: 'center', padding: '0.5rem' }}>
|
||||
{plan.features[featureIndex].included ? (
|
||||
<Text c="green" fw={600}>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
{groupedPlans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{groupedPlans.map((group) => (
|
||||
<td key={group.tier} style={{ textAlign: 'center', padding: '0.75rem' }}>
|
||||
{group.features[featureIndex]?.included ? (
|
||||
<Text c="green" fw={600} size="lg">
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
<Text c="gray" size="sm">
|
||||
−
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -1,20 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
|
||||
import { Button, Card, Badge, Text, Group, Stack, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
import { PlanTierGroup } from '@app/services/licenseService';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PlanTier;
|
||||
isCurrentPlan: boolean;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
planGroup: PlanTierGroup;
|
||||
isCurrentTier: boolean;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrentPlan, onUpgradeClick }) => {
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, onUpgradeClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Render Free plan
|
||||
if (planGroup.tier === 'free') {
|
||||
return (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<Text size="xl" fw={700} mb="xs">
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
||||
£0
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t('plan.free.forever', 'Forever free')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button variant="filled" disabled fullWidth>
|
||||
{isCurrentTier
|
||||
? t('plan.current', 'Current Plan')
|
||||
: t('plan.free.included', 'Included')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Render Server or Enterprise plans
|
||||
const { monthly, yearly } = planGroup;
|
||||
const isEnterprise = planGroup.tier === 'enterprise';
|
||||
|
||||
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
||||
let displayPrice = monthly?.price || 0;
|
||||
let displaySeatPrice = monthly?.seatPrice;
|
||||
let displayCurrency = monthly?.currency || '£';
|
||||
let isYearlyPrice = false;
|
||||
|
||||
if (yearly) {
|
||||
displayPrice = Math.round(yearly.price / 12);
|
||||
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
|
||||
displayCurrency = yearly.currency;
|
||||
isYearlyPrice = true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
@@ -22,39 +85,66 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrentPlan, onUpgradeClick
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '400px',
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
{planGroup.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
{/* Tier Name */}
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
<Text size="xl" fw={700}>
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
<Group gap="xs" style={{ alignItems: 'baseline' }}>
|
||||
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
|
||||
{plan.isContactOnly
|
||||
? t('plan.customPricing', 'Custom')
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{!plan.isContactOnly && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{/* "From" Pricing */}
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t('plan.from', 'From')}
|
||||
</Text>
|
||||
|
||||
{isEnterprise && displaySeatPrice !== undefined ? (
|
||||
<div>
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="xl" fw={700}>
|
||||
{displayCurrency}{displayPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.perMonth', '/month')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
+ {displayCurrency}{displaySeatPrice}/seat/month
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
<Group gap="xs" align="baseline">
|
||||
<Text size="xl" fw={700}>
|
||||
{displayCurrency}{displayPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.perMonth', '/month')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Highlights */}
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight, index) => (
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
@@ -63,16 +153,17 @@ const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrentPlan, onUpgradeClick
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Single Upgrade Button */}
|
||||
<Button
|
||||
variant={isCurrentPlan ? 'filled' : plan.isContactOnly ? 'outline' : 'filled'}
|
||||
disabled={isCurrentPlan}
|
||||
variant={isCurrentTier ? 'light' : 'filled'}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick(plan)}
|
||||
onClick={() => onUpgradeClick(planGroup)}
|
||||
disabled={isCurrentTier}
|
||||
>
|
||||
{isCurrentPlan
|
||||
{isCurrentTier
|
||||
? t('plan.current', 'Current Plan')
|
||||
: plan.isContactOnly
|
||||
? t('plan.contact', 'Contact Us')
|
||||
: isEnterprise
|
||||
? t('plan.selectPlan', 'Select Plan')
|
||||
: t('plan.upgrade', 'Upgrade')}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import apiClient from './apiClient';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
@@ -15,6 +16,19 @@ export interface PlanTier {
|
||||
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 {
|
||||
@@ -28,14 +42,17 @@ export interface SubscriptionInfo {
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: PlanTier[];
|
||||
currentSubscription: SubscriptionInfo;
|
||||
currentSubscription: SubscriptionInfo | null;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionRequest {
|
||||
planId: string;
|
||||
currency: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
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)
|
||||
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 {
|
||||
@@ -47,44 +64,370 @@ export interface BillingPortalResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface InstallationIdResponse {
|
||||
installationId: string;
|
||||
}
|
||||
|
||||
export interface LicenseKeyResponse {
|
||||
status: 'ready' | 'pending';
|
||||
license_key?: string;
|
||||
email?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
// 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> {
|
||||
const response = await apiClient.get<PlansResponse>(`/api/v1/license/plans`, {
|
||||
params: { currency },
|
||||
});
|
||||
return response.data;
|
||||
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> {
|
||||
const response = await apiClient.get<SubscriptionInfo>('/api/v1/license/subscription');
|
||||
return response.data;
|
||||
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 response = await apiClient.post<CheckoutSessionResponse>(
|
||||
'/api/v1/license/checkout',
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
const { data, error } = await supabase.functions.invoke('create-checkout', {
|
||||
body: {
|
||||
self_hosted: true,
|
||||
lookup_key: request.lookup_key,
|
||||
email: request.email,
|
||||
installation_id: request.installation_id,
|
||||
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(returnUrl: string): Promise<BillingPortalResponse> {
|
||||
const response = await apiClient.post<BillingPortalResponse>('/api/v1/license/billing-portal', {
|
||||
returnUrl,
|
||||
async createBillingPortalSession(email: string, returnUrl: string): Promise<BillingPortalResponse> {
|
||||
const { data, error} = await supabase.functions.invoke('manage-billing', {
|
||||
body: {
|
||||
email,
|
||||
returnUrl
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
10
frontend/src/core/services/supabaseClient.ts
Normal file
10
frontend/src/core/services/supabaseClient.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Missing Supabase environment variables');
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
|
||||
Reference in New Issue
Block a user