diff --git a/frontend/src/proprietary/components/AppProviders.tsx b/frontend/src/proprietary/components/AppProviders.tsx index b4839fbba..a02497179 100644 --- a/frontend/src/proprietary/components/AppProviders.tsx +++ b/frontend/src/proprietary/components/AppProviders.tsx @@ -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 > + {children} diff --git a/frontend/src/proprietary/components/shared/ManageBillingButton.tsx b/frontend/src/proprietary/components/shared/ManageBillingButton.tsx index 3cbc07f4c..fc523f62f 100644 --- a/frontend/src/proprietary/components/shared/ManageBillingButton.tsx +++ b/frontend/src/proprietary/components/shared/ManageBillingButton.tsx @@ -17,13 +17,29 @@ export const ManageBillingButton: React.FC = ({ 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); } diff --git a/frontend/src/proprietary/components/shared/UpgradeBanner.tsx b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx new file mode 100644 index 000000000..8a5355fe8 --- /dev/null +++ b/frontend/src/proprietary/components/shared/UpgradeBanner.tsx @@ -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 ( + + + + +
+ + {t('upgradeBanner.title', 'Upgrade to Server Plan')} + + + {t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')} + +
+
+ + + + + + + +
+
+ ); +}; + +export default UpgradeBanner; diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 51acef616..15eb01275 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -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(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 (
- {/* Currency Selection */} + {/* Currency Selection & Manage Subscription */} - - - {t('plan.currency', 'Currency')} - - setCurrency(value || 'gbp')} + data={currencyOptions} + searchable + clearable={false} + w={300} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + - {currentSubscription && ( - <> - - - - )} + {/* Manage Subscription Button - Only show if user has active license */} + {currentLicenseInfo?.licenseKey && ( + + + {t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')} + + + + )} + + diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/ActivePlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/ActivePlanSection.tsx deleted file mode 100644 index 0df71c3e3..000000000 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/ActivePlanSection.tsx +++ /dev/null @@ -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 = ({ 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 ( - - {config.label} - - ); - }; - - return ( -
-
-

- {t('plan.activePlan.title', 'Active Plan')} -

- {subscription.status !== 'none' && subscription.stripeCustomerId && ( - - )} -
-

- {t('plan.activePlan.subtitle', 'Your current subscription details')} -

- - - - - - - {subscription.plan.name} - - {getStatusBadge(subscription.status)} - - {subscription.currentPeriodEnd && subscription.status === 'active' && ( - - {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(), - })} - - )} - -
- - {subscription.plan.currency} - {subscription.plan.price} - /month - -
-
-
-
- ); -}; - -export default ActivePlanSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx index 22e87f2ad..f51e659a0 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -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 = ({ plans, - currentPlanId, currentLicenseInfo, onUpgradeClick, }) => { @@ -32,13 +31,6 @@ const AvailablePlansSection: React.FC = ({ // 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; diff --git a/frontend/src/proprietary/hooks/usePlans.ts b/frontend/src/proprietary/hooks/usePlans.ts index 445ea137c..33cb198de 100644 --- a/frontend/src/proprietary/hooks/usePlans.ts +++ b/frontend/src/proprietary/hooks/usePlans.ts @@ -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; @@ -15,7 +13,6 @@ export interface UsePlansReturn { export const usePlans = (currency: string = 'gbp'): UsePlansReturn => { const [plans, setPlans] = useState([]); - const [currentSubscription, setCurrentSubscription] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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, diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 4ee6a6d66..65e50bf87 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -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 { + async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise { 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 }, });