mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-01 20:10:35 +01:00
Get and pay for products from supabase
This commit is contained in:
parent
70222aa110
commit
41a974eb0b
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
128
frontend/package-lock.json
generated
128
frontend/package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
2
frontend/vite-env.d.ts
vendored
2
frontend/vite-env.d.ts
vendored
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user