mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-12-18 20:04:17 +01:00
billingInit
This commit is contained in:
parent
cf2c7517eb
commit
126fc0923e
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
@ -38,6 +38,8 @@
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@ -3045,6 +3047,29 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
|
||||
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
|
||||
"react": ">=16.8.0 <20.0.0",
|
||||
"react-dom": ">=16.8.0 <20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz",
|
||||
|
||||
@ -31,6 +31,8 @@
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
|
||||
37
frontend/src/core/components/shared/ManageBillingButton.tsx
Normal file
37
frontend/src/core/components/shared/ManageBillingButton.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import licenseService from '@app/services/licenseService';
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
interface ManageBillingButtonProps {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
|
||||
returnUrl = window.location.href,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await licenseService.createBillingPortalSession(returnUrl);
|
||||
window.location.href = response.url;
|
||||
} catch (error) {
|
||||
console.error('Failed to open billing portal:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('billing.portal.error', 'Failed to open billing portal'),
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick} loading={loading}>
|
||||
{t('billing.manageBilling', 'Manage Billing')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
178
frontend/src/core/components/shared/StripeCheckout.tsx
Normal file
178
frontend/src/core/components/shared/StripeCheckout.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Text, Alert, Loader, Stack } 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 { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
// Initialize Stripe - this should come from environment variables
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '');
|
||||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planId: string;
|
||||
planName: string;
|
||||
planPrice: number;
|
||||
currency: string;
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
type CheckoutState = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planId,
|
||||
planName,
|
||||
planPrice,
|
||||
currency,
|
||||
onSuccess,
|
||||
onError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const response = await licenseService.createCheckoutSession({
|
||||
planId,
|
||||
currency,
|
||||
successUrl: `${window.location.origin}/settings/adminPlan?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${window.location.origin}/settings/adminPlan`,
|
||||
});
|
||||
|
||||
setState({
|
||||
status: 'ready',
|
||||
clientSecret: response.clientSecret,
|
||||
sessionId: response.sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||
setState({
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaymentComplete = () => {
|
||||
setState({ status: 'success' });
|
||||
onSuccess?.(state.sessionId || '');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setState({ status: 'idle' });
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Initialize checkout when modal opens
|
||||
useEffect(() => {
|
||||
if (opened && state.status === 'idle') {
|
||||
createCheckoutSession();
|
||||
} else if (!opened) {
|
||||
setState({ status: 'idle' });
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const renderContent = () => {
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.preparing', 'Preparing your checkout...')}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'ready':
|
||||
if (!state.clientSecret) return null;
|
||||
|
||||
return (
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
);
|
||||
|
||||
case 'success':
|
||||
return (
|
||||
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.successMessage',
|
||||
'Your subscription has been activated successfully. You will receive a confirmation email shortly.'
|
||||
)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.autoClose', 'This window will close automatically...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{state.error}</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
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>
|
||||
}
|
||||
size="xl"
|
||||
centered
|
||||
withCloseButton={state.status !== 'ready'}
|
||||
closeOnEscape={state.status !== 'ready'}
|
||||
closeOnClickOutside={state.status !== 'ready'}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
@ -0,0 +1,180 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Divider, Loader, Alert, Select, Group, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
import StripeCheckout from '@app/components/shared/StripeCheckout';
|
||||
import AvailablePlansSection from './plan/AvailablePlansSection';
|
||||
import ActivePlanSection from './plan/ActivePlanSection';
|
||||
import StaticPlanSection from './plan/StaticPlanSection';
|
||||
import { userManagementService } from '@app/services/userManagementService';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
const AdminPlanSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanTier | null>(null);
|
||||
const [currency, setCurrency] = useState<string>('gbp');
|
||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<any>(null);
|
||||
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
|
||||
|
||||
// Check if we should use static version and fetch license info
|
||||
useEffect(() => {
|
||||
const fetchLicenseInfo = async () => {
|
||||
try {
|
||||
const adminData = await userManagementService.getUsers();
|
||||
|
||||
// Determine plan name based on config flags
|
||||
let planName = 'Free';
|
||||
if (config?.runningEE) {
|
||||
planName = 'Enterprise';
|
||||
} else if (config?.runningProOrHigher || adminData.premiumEnabled) {
|
||||
planName = 'Pro';
|
||||
}
|
||||
|
||||
setCurrentLicenseInfo({
|
||||
planName,
|
||||
maxUsers: adminData.maxAllowedUsers,
|
||||
grandfathered: adminData.grandfatheredUserCount > 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch license info:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if Stripe is configured
|
||||
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey || error) {
|
||||
setUseStaticVersion(true);
|
||||
fetchLicenseInfo();
|
||||
}
|
||||
}, [error, config]);
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'gbp', label: 'British pound (GBP, £)' },
|
||||
{ value: 'usd', label: 'US dollar (USD, $)' },
|
||||
{ value: 'eur', label: 'Euro (EUR, €)' },
|
||||
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
|
||||
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
|
||||
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
|
||||
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(plan: PlanTier) => {
|
||||
if (plan.isContactOnly) {
|
||||
// Open contact form or redirect to contact page
|
||||
window.open('mailto:sales@stirlingpdf.com?subject=Enterprise Plan Inquiry', '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSubscription || plan.id !== currentSubscription.plan.id) {
|
||||
setSelectedPlan(plan);
|
||||
setCheckoutOpen(true);
|
||||
}
|
||||
},
|
||||
[currentSubscription]
|
||||
);
|
||||
|
||||
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);
|
||||
},
|
||||
[refetch]
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback((error: string) => {
|
||||
console.error('Payment error:', error);
|
||||
// Error is already displayed in the StripeCheckout component
|
||||
}, []);
|
||||
|
||||
const handleCheckoutClose = useCallback(() => {
|
||||
setCheckoutOpen(false);
|
||||
setSelectedPlan(null);
|
||||
}, []);
|
||||
|
||||
// Show static version if Stripe is not configured or there's an error
|
||||
if (useStaticVersion) {
|
||||
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
|
||||
}
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Fallback to static version on error
|
||||
return <StaticPlanSection currentLicenseInfo={currentLicenseInfo} />;
|
||||
}
|
||||
|
||||
if (!plans || !currentSubscription) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Currency Selector */}
|
||||
<div>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<ActivePlanSection subscription={currentSubscription} />
|
||||
|
||||
<Divider />
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentPlanId={currentSubscription.plan.id}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
{/* Stripe Checkout Modal */}
|
||||
{selectedPlan && (
|
||||
<StripeCheckout
|
||||
opened={checkoutOpen}
|
||||
onClose={handleCheckoutClose}
|
||||
planId={selectedPlan.id}
|
||||
planName={selectedPlan.name}
|
||||
planPrice={selectedPlan.price}
|
||||
currency={selectedPlan.currency}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlanSection;
|
||||
@ -0,0 +1,89 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Group, Stack, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SubscriptionInfo } from '@app/services/licenseService';
|
||||
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
|
||||
|
||||
interface ActivePlanSectionProps {
|
||||
subscription: SubscriptionInfo;
|
||||
}
|
||||
|
||||
const ActivePlanSection: React.FC<ActivePlanSectionProps> = ({ subscription }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const statusConfig: Record<
|
||||
string,
|
||||
{ color: string; label: string }
|
||||
> = {
|
||||
active: { color: 'green', label: t('subscription.status.active', 'Active') },
|
||||
past_due: { color: 'yellow', label: t('subscription.status.pastDue', 'Past Due') },
|
||||
canceled: { color: 'red', label: t('subscription.status.canceled', 'Canceled') },
|
||||
incomplete: { color: 'orange', label: t('subscription.status.incomplete', 'Incomplete') },
|
||||
trialing: { color: 'blue', label: t('subscription.status.trialing', 'Trial') },
|
||||
none: { color: 'gray', label: t('subscription.status.none', 'No Subscription') },
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || statusConfig.none;
|
||||
return (
|
||||
<Badge color={config.color} variant="light">
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.activePlan.title', 'Active Plan')}
|
||||
</h3>
|
||||
{subscription.status !== 'none' && subscription.stripeCustomerId && (
|
||||
<ManageBillingButton returnUrl={`${window.location.origin}/settings/adminPlan`} />
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.activePlan.subtitle', 'Your current subscription details')}
|
||||
</p>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<Text size="lg" fw={600}>
|
||||
{subscription.plan.name}
|
||||
</Text>
|
||||
{getStatusBadge(subscription.status)}
|
||||
</Group>
|
||||
{subscription.currentPeriodEnd && subscription.status === 'active' && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{subscription.cancelAtPeriodEnd
|
||||
? t('subscription.cancelsOn', 'Cancels on {{date}}', {
|
||||
date: new Date(subscription.currentPeriodEnd).toLocaleDateString(),
|
||||
})
|
||||
: t('subscription.renewsOn', 'Renews on {{date}}', {
|
||||
date: new Date(subscription.currentPeriodEnd).toLocaleDateString(),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{subscription.plan.currency}
|
||||
{subscription.plan.price}
|
||||
/month
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivePlanSection;
|
||||
@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
import PlanCard from './PlanCard';
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlanId: string;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlanId,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '1rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{plans.map((plan) => (
|
||||
<PlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
isCurrentPlan={plan.id === currentPlanId}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
|
||||
{showComparison
|
||||
? t('plan.hideComparison', 'Hide Feature Comparison')
|
||||
: t('plan.showComparison', 'Compare All Features')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.featureComparison', 'Feature Comparison')}
|
||||
</Text>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.5rem' }}>
|
||||
{t('plan.feature.title', 'Feature')}
|
||||
</th>
|
||||
{plans.map((plan) => (
|
||||
<th
|
||||
key={plan.id}
|
||||
style={{ textAlign: 'center', padding: '0.5rem', minWidth: '6rem', position: 'relative' }}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0rem',
|
||||
right: '-2rem',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: '500',
|
||||
height: '1rem',
|
||||
padding: '0 0.25rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[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}>
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray">-</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
@ -0,0 +1,83 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Badge, Text, Group, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTier } from '@app/services/licenseService';
|
||||
|
||||
interface PlanCardProps {
|
||||
plan: PlanTier;
|
||||
isCurrentPlan: boolean;
|
||||
onUpgradeClick: (plan: PlanTier) => void;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ plan, isCurrentPlan, onUpgradeClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.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>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={isCurrentPlan ? 'filled' : plan.isContactOnly ? 'outline' : 'filled'}
|
||||
disabled={isCurrentPlan}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick(plan)}
|
||||
>
|
||||
{isCurrentPlan
|
||||
? t('plan.current', 'Current Plan')
|
||||
: plan.isContactOnly
|
||||
? t('plan.contact', 'Contact Us')
|
||||
: t('plan.upgrade', 'Upgrade')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import { Card, Text, Group, Stack, Badge, Button, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface StaticPlanSectionProps {
|
||||
currentLicenseInfo?: {
|
||||
planName: string;
|
||||
maxUsers: number;
|
||||
grandfathered: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const staticPlans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('plan.free.name', 'Free'),
|
||||
price: 0,
|
||||
currency: '£',
|
||||
period: t('plan.period.month', '/month'),
|
||||
highlights: [
|
||||
t('plan.free.highlight1', 'Limited Tool Usage Per week'),
|
||||
t('plan.free.highlight2', 'Access to all tools'),
|
||||
t('plan.free.highlight3', 'Community support'),
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: false },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: false },
|
||||
{ name: t('plan.feature.api', 'API Access'), included: false },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
],
|
||||
maxUsers: 5,
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: t('plan.pro.name', 'Pro'),
|
||||
price: 8,
|
||||
currency: '£',
|
||||
period: t('plan.period.month', '/month'),
|
||||
popular: true,
|
||||
highlights: [
|
||||
t('plan.pro.highlight1', 'Unlimited Tool Usage'),
|
||||
t('plan.pro.highlight2', 'Advanced PDF tools'),
|
||||
t('plan.pro.highlight3', 'No watermarks'),
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: false },
|
||||
],
|
||||
maxUsers: 'Unlimited',
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: t('plan.enterprise.name', 'Enterprise'),
|
||||
price: 0,
|
||||
currency: '',
|
||||
period: '',
|
||||
highlights: [
|
||||
t('plan.enterprise.highlight1', 'Custom pricing'),
|
||||
t('plan.enterprise.highlight2', 'Dedicated support'),
|
||||
t('plan.enterprise.highlight3', 'Latest features'),
|
||||
],
|
||||
features: [
|
||||
{ name: t('plan.feature.pdfTools', 'Basic PDF Tools'), included: true },
|
||||
{ name: t('plan.feature.fileSize', 'File Size Limit'), included: true },
|
||||
{ name: t('plan.feature.automation', 'Automate tool workflows'), included: true },
|
||||
{ name: t('plan.feature.api', 'Weekly API Credits'), included: true },
|
||||
{ name: t('plan.feature.priority', 'Priority Support'), included: true },
|
||||
],
|
||||
maxUsers: 'Custom',
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentPlan = () => {
|
||||
if (!currentLicenseInfo) return staticPlans[0];
|
||||
if (currentLicenseInfo.planName === 'Enterprise') return staticPlans[2];
|
||||
if (currentLicenseInfo.maxUsers > 5) return staticPlans[1];
|
||||
return staticPlans[0];
|
||||
};
|
||||
|
||||
const currentPlan = getCurrentPlan();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Stripe Not Configured Alert */}
|
||||
<Alert color="blue" title={t('plan.static.title', 'Billing Information')}>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'plan.static.message',
|
||||
'Online billing is not currently configured. To upgrade your plan or manage subscriptions, please contact us directly.'
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="email" width="1rem" height="1rem" />}
|
||||
onClick={() =>
|
||||
window.open('mailto:sales@stirlingpdf.com?subject=License Upgrade Inquiry', '_blank')
|
||||
}
|
||||
style={{ width: 'fit-content' }}
|
||||
>
|
||||
{t('plan.static.contactSales', 'Contact Sales')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
|
||||
{/* Current Plan Section */}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.activePlan.title', 'Active Plan')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.activePlan.subtitle', 'Your current subscription details')}
|
||||
</p>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
<Badge color="green" variant="light">
|
||||
{t('subscription.status.active', 'Active')}
|
||||
</Badge>
|
||||
</Group>
|
||||
{currentLicenseInfo && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
|
||||
{currentLicenseInfo.grandfathered &&
|
||||
` (${t('workspace.people.license.grandfathered', 'Grandfathered')})`}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{staticPlans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" style={{ alignItems: 'baseline' }}>
|
||||
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
|
||||
{plan.price === 0 && plan.id !== 'free'
|
||||
? t('plan.customPricing', 'Custom')
|
||||
: plan.price === 0
|
||||
? t('plan.free.name', 'Free')
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{plan.period && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{typeof plan.maxUsers === 'string'
|
||||
? plan.maxUsers
|
||||
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={plan.id === currentPlan.id ? 'filled' : 'outline'}
|
||||
disabled={plan.id === currentPlan.id}
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`mailto:sales@stirlingpdf.com?subject=Upgrade to ${plan.name} Plan`,
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
>
|
||||
{plan.id === currentPlan.id
|
||||
? t('plan.current', 'Current Plan')
|
||||
: t('plan.contact', 'Contact Us')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaticPlanSection;
|
||||
49
frontend/src/core/hooks/usePlans.ts
Normal file
49
frontend/src/core/hooks/usePlans.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import licenseService, {
|
||||
PlanTier,
|
||||
SubscriptionInfo,
|
||||
PlansResponse,
|
||||
} from '@app/services/licenseService';
|
||||
|
||||
export interface UsePlansReturn {
|
||||
plans: PlanTier[];
|
||||
currentSubscription: SubscriptionInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
|
||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||
const [currentSubscription, setCurrentSubscription] = useState<SubscriptionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: PlansResponse = await licenseService.getPlans(currency);
|
||||
setPlans(data.plans);
|
||||
setCurrentSubscription(data.currentSubscription);
|
||||
} catch (err) {
|
||||
console.error('Error fetching plans:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [currency]);
|
||||
|
||||
return {
|
||||
plans,
|
||||
currentSubscription,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPlans,
|
||||
};
|
||||
};
|
||||
91
frontend/src/core/services/licenseService.ts
Normal file
91
frontend/src/core/services/licenseService.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: PlanFeature[];
|
||||
highlights: string[];
|
||||
isContactOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: PlanTier;
|
||||
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing' | 'none';
|
||||
currentPeriodEnd?: string;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
stripeCustomerId?: string;
|
||||
stripeSubscriptionId?: string;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: PlanTier[];
|
||||
currentSubscription: SubscriptionInfo;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionRequest {
|
||||
planId: string;
|
||||
currency: string;
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResponse {
|
||||
clientSecret: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface BillingPortalResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current subscription details
|
||||
*/
|
||||
async getCurrentSubscription(): Promise<SubscriptionInfo> {
|
||||
const response = await apiClient.get<SubscriptionInfo>('/api/v1/license/subscription');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default licenseService;
|
||||
Loading…
Reference in New Issue
Block a user