mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
manage-billing
This commit is contained in:
parent
0e0bd656d9
commit
ade6a1c628
@ -1,6 +1,7 @@
|
||||
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||
|
||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||
return (
|
||||
@ -10,6 +11,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
>
|
||||
<AuthProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBanner />
|
||||
{children}
|
||||
</CheckoutProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@ -17,13 +17,29 @@ export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await licenseService.createBillingPortalSession(returnUrl);
|
||||
window.location.href = response.url;
|
||||
} catch (error) {
|
||||
|
||||
// Get current license key for authentication
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
|
||||
if (!licenseInfo.licenseKey) {
|
||||
throw new Error('No license key found. Please activate a license first.');
|
||||
}
|
||||
|
||||
// Create billing portal session with license key
|
||||
const response = await licenseService.createBillingPortalSession(
|
||||
returnUrl,
|
||||
licenseInfo.licenseKey
|
||||
);
|
||||
|
||||
// Open billing portal in new tab
|
||||
window.open(response.url, '_blank');
|
||||
setLoading(false);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to open billing portal:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('billing.portal.error', 'Failed to open billing portal'),
|
||||
body: error.message || 'Please try again or contact support.',
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
144
frontend/src/proprietary/components/shared/UpgradeBanner.tsx
Normal file
144
frontend/src/proprietary/components/shared/UpgradeBanner.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import licenseService, { mapLicenseToTier } from '@app/services/licenseService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
/**
|
||||
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
|
||||
*
|
||||
* This component demonstrates:
|
||||
* - How to check authentication status with useAuth()
|
||||
* - How to check license status with licenseService
|
||||
* - How to open checkout modal with useCheckout()
|
||||
* - How to persist dismissal state with localStorage
|
||||
*
|
||||
* To remove this banner:
|
||||
* 1. Remove the import and component from AppProviders.tsx
|
||||
* 2. Delete this file
|
||||
*/
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { openCheckout } = useCheckout();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check if user should see the banner
|
||||
useEffect(() => {
|
||||
const checkVisibility = async () => {
|
||||
try {
|
||||
// Don't show if not logged in
|
||||
if (!user) {
|
||||
setIsVisible(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if banner was dismissed
|
||||
const dismissed = localStorage.getItem('upgradeBannerDismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsVisible(false);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check license status
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
const tier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
// Show banner only for free tier users
|
||||
if (tier === 'free' || tier === null) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
setIsVisible(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking upgrade banner visibility:', error);
|
||||
setIsVisible(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkVisibility();
|
||||
}, [user]);
|
||||
|
||||
// Handle dismiss
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('upgradeBannerDismissed', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
// Handle upgrade button click
|
||||
const handleUpgrade = () => {
|
||||
openCheckout('server', {
|
||||
currency: 'gbp',
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
// Banner will auto-hide on next render when license is detected
|
||||
setIsVisible(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render anything if loading or not visible
|
||||
if (isLoading || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9}>
|
||||
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
size="sm"
|
||||
onClick={handleUpgrade}
|
||||
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="white"
|
||||
size="lg"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeBanner;
|
||||
@ -15,6 +15,7 @@ import { useRestartServer } from '@app/components/shared/config/useRestartServer
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
|
||||
|
||||
interface PremiumSettingsData {
|
||||
key?: string;
|
||||
@ -29,7 +30,7 @@ const AdminPlanSection: React.FC = () => {
|
||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<LicenseInfo | null>(null);
|
||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
|
||||
const { plans, loading, error, refetch } = usePlans(currency);
|
||||
|
||||
// Premium/License key management
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
@ -147,34 +148,38 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Currency Selection */}
|
||||
{/* Currency Selection & Manage Subscription */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<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}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Stack gap="md">
|
||||
<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}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{currentSubscription && (
|
||||
<>
|
||||
<ActivePlanSection subscription={currentSubscription} />
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{/* Manage Subscription Button - Only show if user has active license */}
|
||||
{currentLicenseInfo?.licenseKey && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
|
||||
</Text>
|
||||
<ManageBillingButton />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentPlanId={currentSubscription?.plan.id}
|
||||
currentLicenseInfo={currentLicenseInfo}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
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;
|
||||
@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Button, Card, Badge, Text, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||
import PlanCard from './PlanCard';
|
||||
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
@ -13,7 +13,6 @@ interface AvailablePlansSectionProps {
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentPlanId,
|
||||
currentLicenseInfo,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
@ -32,13 +31,6 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
|
||||
// Determine if the current tier matches (checks both Stripe subscription and license)
|
||||
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
||||
// Check Stripe subscription match
|
||||
if (currentPlanId && (
|
||||
tierGroup.monthly?.id === currentPlanId ||
|
||||
tierGroup.yearly?.id === currentPlanId
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
// Check license tier match
|
||||
if (currentTier && tierGroup.tier === currentTier) {
|
||||
return true;
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
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>;
|
||||
@ -15,7 +13,6 @@ export interface UsePlansReturn {
|
||||
|
||||
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);
|
||||
|
||||
@ -26,7 +23,6 @@ export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
|
||||
|
||||
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');
|
||||
@ -41,7 +37,6 @@ export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
|
||||
|
||||
return {
|
||||
plans,
|
||||
currentSubscription,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPlans,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import apiClient from './apiClient';
|
||||
import { supabase } from './supabaseClient';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { supabase } from '@app/services/supabaseClient';
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
@ -31,18 +31,8 @@ export interface PlanTierGroup {
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionInfo {
|
||||
plan: PlanTier;
|
||||
status: 'active' | 'past_due' | 'canceled' | 'incomplete' | 'trialing' | 'none';
|
||||
currentPeriodEnd?: string;
|
||||
cancelAtPeriodEnd?: boolean;
|
||||
stripeCustomerId?: string;
|
||||
stripeSubscriptionId?: string;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: PlanTier[];
|
||||
currentSubscription: SubscriptionInfo | null;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionRequest {
|
||||
@ -390,12 +380,14 @@ const licenseService = {
|
||||
|
||||
/**
|
||||
* Create a Stripe billing portal session for managing subscription
|
||||
* Uses license key for self-hosted authentication
|
||||
*/
|
||||
async createBillingPortalSession(email: string, returnUrl: string): Promise<BillingPortalResponse> {
|
||||
async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise<BillingPortalResponse> {
|
||||
const { data, error} = await supabase.functions.invoke('manage-billing', {
|
||||
body: {
|
||||
email,
|
||||
returnUrl
|
||||
return_url: returnUrl,
|
||||
license_key: licenseKey,
|
||||
self_hosted: true // Explicitly indicate self-hosted mode
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user