manage-billing

This commit is contained in:
Connor Yoh 2025-11-18 14:21:48 +00:00
parent 0e0bd656d9
commit ade6a1c628
8 changed files with 202 additions and 145 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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