mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Desktop connection SaaS: config, billing, team support (#5768)
Co-authored-by: James Brunton <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
James Brunton
parent
86072ec91a
commit
5c39acecd8
@@ -0,0 +1,75 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import { SaaSStripeCheckout } from '@app/components/shared/billing/SaaSStripeCheckout';
|
||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||
|
||||
interface SaaSCheckoutContextType {
|
||||
opened: boolean;
|
||||
selectedPlan: string | null;
|
||||
openCheckout: (planId: string) => void;
|
||||
closeCheckout: () => void;
|
||||
}
|
||||
|
||||
const SaaSCheckoutContext = createContext<SaaSCheckoutContextType | undefined>(undefined);
|
||||
|
||||
export const useSaaSCheckout = () => {
|
||||
const context = useContext(SaaSCheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useSaaSCheckout must be used within SaaSCheckoutProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
interface SaaSCheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const SaaSCheckoutProvider: React.FC<SaaSCheckoutProviderProps> = ({
|
||||
children
|
||||
}) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string | null>(null);
|
||||
|
||||
// Access billing context for auto-refresh after checkout
|
||||
const { refreshBilling } = useSaaSBilling();
|
||||
|
||||
const openCheckout = (planId: string) => {
|
||||
setSelectedPlan(planId);
|
||||
setOpened(true);
|
||||
};
|
||||
|
||||
const closeCheckout = () => {
|
||||
setOpened(false);
|
||||
// Don't reset selectedPlan immediately to allow for cleanup
|
||||
setTimeout(() => setSelectedPlan(null), 300);
|
||||
};
|
||||
|
||||
// Internal success handler - automatically refreshes billing context
|
||||
const handleCheckoutSuccess = useCallback(async () => {
|
||||
// Wait for webhooks to process (2 seconds)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
try {
|
||||
await refreshBilling();
|
||||
} catch (error) {
|
||||
console.error('[SaaSCheckoutContext] Failed to refresh billing after checkout:', error);
|
||||
}
|
||||
}, [refreshBilling]);
|
||||
|
||||
return (
|
||||
<SaaSCheckoutContext.Provider
|
||||
value={{
|
||||
opened,
|
||||
selectedPlan,
|
||||
openCheckout,
|
||||
closeCheckout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<SaaSStripeCheckout
|
||||
opened={opened}
|
||||
onClose={closeCheckout}
|
||||
planId={selectedPlan}
|
||||
onSuccess={handleCheckoutSuccess}
|
||||
/>
|
||||
</SaaSCheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,322 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Team Context
|
||||
* Provides team management for users connected to SaaS backend
|
||||
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
|
||||
*/
|
||||
|
||||
interface Team {
|
||||
teamId: number;
|
||||
name: string;
|
||||
teamType: string;
|
||||
isPersonal: boolean;
|
||||
memberCount: number;
|
||||
seatCount: number;
|
||||
seatsUsed: number;
|
||||
maxSeats: number;
|
||||
isLeader: boolean;
|
||||
}
|
||||
|
||||
interface TeamMember {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
interface TeamInvitation {
|
||||
invitationId: number;
|
||||
teamName: string;
|
||||
inviterEmail: string;
|
||||
inviteeEmail: string;
|
||||
invitationToken: string;
|
||||
status: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface SaaSTeamContextType {
|
||||
currentTeam: Team | null;
|
||||
teams: Team[];
|
||||
teamMembers: TeamMember[];
|
||||
teamInvitations: TeamInvitation[];
|
||||
receivedInvitations: TeamInvitation[];
|
||||
isTeamLeader: boolean;
|
||||
isPersonalTeam: boolean;
|
||||
loading: boolean;
|
||||
|
||||
inviteUser: (email: string) => Promise<void>;
|
||||
acceptInvitation: (token: string) => Promise<void>;
|
||||
rejectInvitation: (token: string) => Promise<void>;
|
||||
cancelInvitation: (invitationId: number) => Promise<void>;
|
||||
removeMember: (memberId: number) => Promise<void>;
|
||||
leaveTeam: () => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaaSTeamContext = createContext<SaaSTeamContextType>({
|
||||
currentTeam: null,
|
||||
teams: [],
|
||||
teamMembers: [],
|
||||
teamInvitations: [],
|
||||
receivedInvitations: [],
|
||||
isTeamLeader: false,
|
||||
isPersonalTeam: true,
|
||||
loading: true,
|
||||
inviteUser: async () => {},
|
||||
acceptInvitation: async () => {},
|
||||
rejectInvitation: async () => {},
|
||||
cancelInvitation: async () => {},
|
||||
removeMember: async () => {},
|
||||
leaveTeam: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
});
|
||||
|
||||
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
const [currentTeam, setCurrentTeam] = useState<Team | null>(null);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [teamMembers, setTeamMembers] = useState<TeamMember[]>([]);
|
||||
const [teamInvitations, setTeamInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [receivedInvitations, setReceivedInvitations] = useState<TeamInvitation[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Check if in SaaS mode and authenticated
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const fetchMyTeams = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping team fetch - not in SaaS mode or not authenticated');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<Team[]>('/api/v1/team/my');
|
||||
setTeams(response.data);
|
||||
|
||||
const activeTeam = response.data[0];
|
||||
console.log('[SaaSTeamContext] Current team set:', {
|
||||
teamId: activeTeam?.teamId,
|
||||
name: activeTeam?.name,
|
||||
isPersonal: activeTeam?.isPersonal,
|
||||
isLeader: activeTeam?.isLeader,
|
||||
});
|
||||
setCurrentTeam(activeTeam || null);
|
||||
return activeTeam || null;
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch teams:', error);
|
||||
return null;
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchTeamMembers = useCallback(async (teamId: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping members fetch - not in SaaS mode or not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`);
|
||||
setTeamMembers(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchTeamInvitations = useCallback(async (teamId?: number) => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated || !teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`);
|
||||
setTeamInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
const fetchReceivedInvitations = useCallback(async () => {
|
||||
// CRITICAL: Only fetch if in SaaS mode and authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[SaaSTeamContext] Fetching received team invitations');
|
||||
|
||||
try {
|
||||
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending');
|
||||
console.log('[SaaSTeamContext] Received invitations response:', response.data);
|
||||
setReceivedInvitations(response.data);
|
||||
} catch (error) {
|
||||
console.error('[SaaSTeamContext] Failed to fetch received invitations:', error);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaasMode && isAuthenticated) {
|
||||
fetchMyTeams();
|
||||
fetchReceivedInvitations();
|
||||
} else {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
setTeams([]);
|
||||
setCurrentTeam(null);
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
setReceivedInvitations([]);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTeam && !currentTeam.isPersonal && isSaasMode && isAuthenticated) {
|
||||
fetchTeamMembers(currentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (currentTeam.isLeader) {
|
||||
fetchTeamInvitations(currentTeam.teamId);
|
||||
} else {
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
} else {
|
||||
setTeamMembers([]);
|
||||
setTeamInvitations([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [currentTeam, isSaasMode, isAuthenticated, fetchTeamMembers, fetchTeamInvitations]);
|
||||
|
||||
const inviteUser = async (email: string) => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.post('/api/v1/team/invite', {
|
||||
teamId: currentTeam.teamId,
|
||||
email
|
||||
});
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const acceptInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/accept`);
|
||||
await fetchReceivedInvitations();
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const rejectInvitation = async (token: string) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.post(`/api/v1/team/invitations/${token}/reject`);
|
||||
await fetchReceivedInvitations();
|
||||
};
|
||||
|
||||
const cancelInvitation = async (invitationId: number) => {
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.delete(`/api/v1/team/invitations/${invitationId}`);
|
||||
if (currentTeam) {
|
||||
await fetchTeamInvitations(currentTeam.teamId);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: number) => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.delete(`/api/v1/team/${currentTeam.teamId}/members/${memberId}`);
|
||||
await refreshTeams();
|
||||
await fetchTeamMembers(currentTeam.teamId);
|
||||
};
|
||||
|
||||
const leaveTeam = async () => {
|
||||
if (!currentTeam) throw new Error('No current team');
|
||||
if (!isSaasMode) throw new Error('Not in SaaS mode');
|
||||
|
||||
await apiClient.post(`/api/v1/team/${currentTeam.teamId}/leave`);
|
||||
await refreshTeams();
|
||||
// Note: Desktop doesn't have refreshCredits/refreshSession like SaaS
|
||||
};
|
||||
|
||||
const refreshTeams = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
console.log('[SaaSTeamContext] Skipping refresh - not in SaaS mode or not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const newCurrentTeam = await fetchMyTeams();
|
||||
await fetchReceivedInvitations();
|
||||
if (newCurrentTeam && !newCurrentTeam.isPersonal) {
|
||||
await fetchTeamMembers(newCurrentTeam.teamId);
|
||||
// Only fetch invitations if user is team leader
|
||||
if (newCurrentTeam.isLeader) {
|
||||
await fetchTeamInvitations(newCurrentTeam.teamId);
|
||||
}
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, fetchMyTeams, fetchReceivedInvitations, fetchTeamMembers, fetchTeamInvitations]);
|
||||
|
||||
const isTeamLeader = currentTeam?.isLeader ?? false;
|
||||
const isPersonalTeam = currentTeam?.isPersonal ?? true;
|
||||
|
||||
return (
|
||||
<SaaSTeamContext.Provider value={{
|
||||
currentTeam,
|
||||
teams,
|
||||
teamMembers,
|
||||
teamInvitations,
|
||||
receivedInvitations,
|
||||
isTeamLeader,
|
||||
isPersonalTeam,
|
||||
loading,
|
||||
inviteUser,
|
||||
acceptInvitation,
|
||||
rejectInvitation,
|
||||
cancelInvitation,
|
||||
removeMember,
|
||||
leaveTeam,
|
||||
refreshTeams
|
||||
}}>
|
||||
{children}
|
||||
</SaaSTeamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSaaSTeam() {
|
||||
const context = useContext(SaaSTeamContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSaaSTeam must be used within a SaaSTeamProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SaaSTeamContext };
|
||||
export type { Team, TeamMember, TeamInvitation };
|
||||
@@ -0,0 +1,315 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
|
||||
import type { TierLevel } from '@app/types/billing';
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Billing Context
|
||||
* Provides billing and plan management for users connected to SaaS backend
|
||||
* CRITICAL: Only active when in SaaS mode - all API calls check connection mode first
|
||||
*
|
||||
* Features:
|
||||
* - Centralized billing state management
|
||||
* - Automatic caching (5-minute TTL)
|
||||
* - Lazy loading (fetches on first access)
|
||||
* - Auto-refresh after checkout/portal
|
||||
* - No flickering (preserves data during refresh)
|
||||
*/
|
||||
|
||||
interface SaasBillingContextType {
|
||||
// Billing Status
|
||||
billingStatus: BillingStatus | null;
|
||||
tier: TierLevel;
|
||||
subscription: BillingStatus['subscription'];
|
||||
usage: BillingStatus['meterUsage'];
|
||||
isTrialing: boolean;
|
||||
trialDaysRemaining?: number;
|
||||
price?: number;
|
||||
currency?: string;
|
||||
creditBalance: number; // Real-time remaining credits
|
||||
|
||||
// Available Plans
|
||||
plans: Map<string, PlanPrice>;
|
||||
plansLoading: boolean;
|
||||
plansError: string | null;
|
||||
|
||||
// Derived State
|
||||
isManagedTeamMember: boolean;
|
||||
|
||||
// State Flags
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
lastFetchTime: number | null;
|
||||
|
||||
// Actions
|
||||
refreshBilling: () => Promise<void>;
|
||||
refreshCredits: () => Promise<void>; // Alias for refreshBilling (for clarity)
|
||||
refreshPlans: () => Promise<void>;
|
||||
openBillingPortal: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaasBillingContext = createContext<SaasBillingContextType>({
|
||||
billingStatus: null,
|
||||
tier: 'free',
|
||||
subscription: null,
|
||||
usage: null,
|
||||
isTrialing: false,
|
||||
trialDaysRemaining: undefined,
|
||||
price: undefined,
|
||||
currency: undefined,
|
||||
creditBalance: 0,
|
||||
plans: new Map(),
|
||||
plansLoading: false,
|
||||
plansError: null,
|
||||
isManagedTeamMember: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
lastFetchTime: null,
|
||||
refreshBilling: async () => {},
|
||||
refreshCredits: async () => {},
|
||||
refreshPlans: async () => {},
|
||||
openBillingPortal: async () => {},
|
||||
});
|
||||
|
||||
export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
const [billingStatus, setBillingStatus] = useState<BillingStatus | null>(null);
|
||||
const [plans, setPlans] = useState<Map<string, PlanPrice>>(new Map());
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
const [plansError, setPlansError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Access team context for derived state
|
||||
const { currentTeam, isPersonalTeam, isTeamLeader, loading: teamLoading } = useSaaSTeam();
|
||||
|
||||
// Compute derived state: user is managed member if in non-personal team but not leader
|
||||
const isManagedTeamMember = currentTeam ? !isPersonalTeam && !isTeamLeader : false;
|
||||
|
||||
// Check if in SaaS mode and authenticated (same pattern as SaaSTeamContext)
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
const auth = await authService.isAuthenticated();
|
||||
setIsSaasMode(mode === 'saas');
|
||||
setIsAuthenticated(auth);
|
||||
};
|
||||
|
||||
checkAccess();
|
||||
|
||||
// Subscribe to connection mode changes
|
||||
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Subscribe to auth changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = authService.subscribeToAuth((status) => {
|
||||
setIsAuthenticated(status === 'authenticated');
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
// Fetch billing status with caching
|
||||
const fetchBillingData = useCallback(async () => {
|
||||
// Guard: Skip if not in SaaS mode or not authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: Wait for team context to load before determining managed status
|
||||
if (teamLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard: Skip if managed team member (billing managed by leader)
|
||||
if (isManagedTeamMember) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache check: Skip if fresh data exists (<5 min old)
|
||||
const now = Date.now();
|
||||
if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set loading if no existing data (prevents flicker on refresh)
|
||||
if (!billingStatus) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await saasBillingService.getBillingStatus();
|
||||
setBillingStatus(status); // Atomic update
|
||||
setLastFetchTime(now);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[SaasBillingContext] Failed to fetch billing:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch billing');
|
||||
// Don't clear billing status on error (preserve existing data)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, isManagedTeamMember, billingStatus, lastFetchTime, teamLoading]);
|
||||
|
||||
// Fetch available plans
|
||||
const fetchPlansData = useCallback(async () => {
|
||||
// Guard: Skip if not in SaaS mode or not authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPlansLoading(true);
|
||||
setPlansError(null);
|
||||
|
||||
try {
|
||||
const priceMap = await saasBillingService.getAvailablePlans('usd');
|
||||
setPlans(priceMap);
|
||||
} catch (err) {
|
||||
console.error('[SaasBillingContext] Failed to fetch plans:', err);
|
||||
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
||||
// Non-blocking: continue with empty plans
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
// Clear data when leaving SaaS mode or logging out
|
||||
useEffect(() => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
setBillingStatus(null);
|
||||
setPlans(new Map());
|
||||
setLastFetchTime(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setPlansError(null);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
|
||||
// Auto-fetch billing when team context finishes loading
|
||||
useEffect(() => {
|
||||
// Only fetch if: in SaaS mode, authenticated, team finished loading, and haven't fetched yet
|
||||
if (
|
||||
isSaasMode &&
|
||||
isAuthenticated &&
|
||||
!teamLoading &&
|
||||
!isManagedTeamMember &&
|
||||
billingStatus === null &&
|
||||
lastFetchTime === null
|
||||
) {
|
||||
fetchBillingData();
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
|
||||
|
||||
// Public refresh methods
|
||||
const refreshBilling = useCallback(async () => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force cache invalidation
|
||||
setLastFetchTime(null);
|
||||
await fetchBillingData();
|
||||
await fetchPlansData();
|
||||
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
|
||||
|
||||
const refreshPlans = useCallback(async () => {
|
||||
await fetchPlansData();
|
||||
}, [fetchPlansData]);
|
||||
|
||||
const openBillingPortal = useCallback(async () => {
|
||||
const returnUrl = window.location.href;
|
||||
await saasBillingService.openBillingPortal(returnUrl);
|
||||
|
||||
// Auto-refresh after portal (delayed for webhook processing)
|
||||
setTimeout(() => {
|
||||
refreshBilling();
|
||||
}, 3000);
|
||||
}, [refreshBilling]);
|
||||
|
||||
// Listen for credit updates from API response headers (immediate, no fetch)
|
||||
useEffect(() => {
|
||||
const handleCreditsUpdated = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ creditsRemaining: number }>;
|
||||
const newBalance = customEvent.detail?.creditsRemaining;
|
||||
|
||||
if (typeof newBalance === 'number' && billingStatus) {
|
||||
// Update credit balance in billing status without full refresh
|
||||
setBillingStatus({
|
||||
...billingStatus,
|
||||
creditBalance: newBalance,
|
||||
});
|
||||
|
||||
// Dispatch exhausted event if credits hit 0
|
||||
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
||||
window.dispatchEvent(new CustomEvent('credits:exhausted', {
|
||||
detail: { previousBalance: billingStatus.creditBalance ?? 0, currentBalance: newBalance }
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('credits:updated', handleCreditsUpdated);
|
||||
return () => {
|
||||
window.removeEventListener('credits:updated', handleCreditsUpdated);
|
||||
};
|
||||
}, [billingStatus]);
|
||||
|
||||
return (
|
||||
<SaasBillingContext.Provider value={{
|
||||
billingStatus,
|
||||
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
|
||||
subscription: billingStatus?.subscription ?? null,
|
||||
usage: billingStatus?.meterUsage ?? null,
|
||||
isTrialing: billingStatus?.isTrialing ?? false,
|
||||
trialDaysRemaining: billingStatus?.trialDaysRemaining,
|
||||
price: billingStatus?.price,
|
||||
currency: billingStatus?.currency,
|
||||
creditBalance: billingStatus?.creditBalance ?? 0,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
isManagedTeamMember,
|
||||
loading: loading || teamLoading,
|
||||
error,
|
||||
lastFetchTime,
|
||||
refreshBilling,
|
||||
refreshCredits: refreshBilling, // Alias for clarity
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
}}>
|
||||
{children}
|
||||
</SaasBillingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSaaSBilling() {
|
||||
const context = useContext(SaasBillingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSaaSBilling must be used within SaasBillingProvider');
|
||||
}
|
||||
|
||||
// Lazy fetch: Trigger fetch on first access (after team context loads)
|
||||
// Note: context.loading includes teamLoading, so this waits for team to load
|
||||
useEffect(() => {
|
||||
const needsFetch =
|
||||
context.billingStatus === null &&
|
||||
context.lastFetchTime === null &&
|
||||
!context.loading &&
|
||||
!context.isManagedTeamMember; // Managed members don't need billing data
|
||||
|
||||
if (needsFetch) {
|
||||
context.refreshBilling();
|
||||
}
|
||||
}, [context.billingStatus, context.lastFetchTime, context.loading, context.isManagedTeamMember, context.refreshBilling]);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SaasBillingContext };
|
||||
export type { SaasBillingContextType };
|
||||
Reference in New Issue
Block a user