Get and pay for products from supabase

This commit is contained in:
Connor Yoh 2025-11-14 21:29:45 +00:00
parent 70222aa110
commit 41a974eb0b
10 changed files with 1044 additions and 175 deletions

View File

@ -0,0 +1,52 @@
package stirling.software.proprietary.controller.api;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.util.GeneralUtils;
/**
* Admin controller for license management. Provides installation ID for Stripe checkout metadata.
*/
@RestController
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
/**
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
* as metadata in Stripe checkout to link licenses to specific installations.
*
* @return Map containing the installation ID
*/
@GetMapping("/installation-id")
@Operation(
summary = "Get installation ID",
description =
"Returns the unique installation ID (MAC-based fingerprint) for this"
+ " self-hosted instance")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<Map<String, String>> getInstallationId() {
try {
String installationId = GeneralUtils.generateMachineFingerprint();
log.info("Admin requested installation ID: {}", installationId);
return ResponseEntity.ok(Map.of("installationId", installationId));
} catch (Exception e) {
log.error("Failed to generate installation ID", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to generate installation ID"));
}
}
}

View File

@ -40,6 +40,7 @@
"@reactour/tour": "^3.8.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
@ -3087,6 +3088,115 @@
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/auth-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/functions-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/realtime-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/storage-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/supabase-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.81.1",
"@supabase/functions-js": "2.81.1",
"@supabase/postgrest-js": "2.81.1",
"@supabase/realtime-js": "2.81.1",
"@supabase/storage-js": "2.81.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
@ -4160,7 +4270,6 @@
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@ -4172,6 +4281,12 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@ -4206,6 +4321,15 @@
"@types/react": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@ -13919,7 +14043,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
@ -14790,7 +14913,6 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -35,6 +35,7 @@
"@mantine/hooks": "^8.3.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",

View File

@ -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>

View File

@ -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}
/>

View File

@ -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>
))}

View File

@ -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>

View File

@ -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;
},
};

View 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);

View File

@ -3,6 +3,8 @@
interface ImportMetaEnv {
readonly VITE_PUBLIC_POSTHOG_KEY: string;
readonly VITE_PUBLIC_POSTHOG_HOST: string;
readonly VITE_SUPABASE_URL: string;
readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string;
}
interface ImportMeta {