mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
Manage state of price-lookup calls (#5915)
Now calls stripe-price-lookup once when prices are required rather then bombarding on every rerender
This commit is contained in:
@@ -1,10 +1,14 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback, useRef, useMemo } 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';
|
||||
|
||||
// How long plan pricing is considered fresh. Lives at module level so both the
|
||||
// provider and usePlanPricing (the consumer hook) can reference it.
|
||||
const PLANS_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
||||
|
||||
/**
|
||||
* Desktop implementation of SaaS Billing Context
|
||||
* Provides billing and plan management for users connected to SaaS backend
|
||||
@@ -34,6 +38,7 @@ interface SaasBillingContextType {
|
||||
plans: Map<string, PlanPrice>;
|
||||
plansLoading: boolean;
|
||||
plansError: string | null;
|
||||
plansLastFetchTime: number | null;
|
||||
|
||||
// Derived State
|
||||
isManagedTeamMember: boolean;
|
||||
@@ -63,6 +68,7 @@ const SaasBillingContext = createContext<SaasBillingContextType>({
|
||||
plans: new Map(),
|
||||
plansLoading: false,
|
||||
plansError: null,
|
||||
plansLastFetchTime: null,
|
||||
isManagedTeamMember: false,
|
||||
loading: false,
|
||||
error: null,
|
||||
@@ -80,7 +86,19 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
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);
|
||||
// lastFetchTimeRef is the source of truth for cache logic (always current, no stale closure).
|
||||
// lastFetchTimeValue mirrors it as state purely to drive re-renders and expose through context.
|
||||
const lastFetchTimeRef = useRef<number | null>(null);
|
||||
const [lastFetchTimeValue, setLastFetchTimeValue] = useState<number | null>(null);
|
||||
// billingStatusRef mirrors billingStatus so fetchBillingData can read the current value
|
||||
// without needing billingStatus in its useCallback dep array.
|
||||
const billingStatusRef = useRef<BillingStatus | null>(null);
|
||||
// plansLastFetchTimeRef is the source of truth for timing; plansLastFetchTimeValue
|
||||
// is the state mirror exposed via context so consumers can react to it.
|
||||
const plansLastFetchTimeRef = useRef<number | null>(null);
|
||||
const [plansLastFetchTimeValue, setPlansLastFetchTimeValue] = useState<number | null>(null);
|
||||
// In-flight deduplication — prevents concurrent duplicate network requests.
|
||||
const plansFetchInProgressRef = useRef(false);
|
||||
const [isSaasMode, setIsSaasMode] = useState(false);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
@@ -133,19 +151,21 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Cache check: Skip if fresh data exists (<5 min old)
|
||||
const now = Date.now();
|
||||
if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
|
||||
if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only set loading if no existing data (prevents flicker on refresh)
|
||||
if (!billingStatus) {
|
||||
if (!billingStatusRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await saasBillingService.getBillingStatus();
|
||||
setBillingStatus(status); // Atomic update
|
||||
setLastFetchTime(now);
|
||||
billingStatusRef.current = status;
|
||||
setBillingStatus(status);
|
||||
lastFetchTimeRef.current = now;
|
||||
setLastFetchTimeValue(now);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('[SaasBillingContext] Failed to fetch billing:', err);
|
||||
@@ -154,26 +174,34 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, isManagedTeamMember, billingStatus, lastFetchTime, teamLoading]);
|
||||
}, [isSaasMode, isAuthenticated, isManagedTeamMember, teamLoading]);
|
||||
|
||||
// Fetch available plans
|
||||
// Raw plan fetch — auth guard + in-flight deduplication only.
|
||||
// TTL cache logic lives in usePlanPricing so the consumer hook controls staleness policy.
|
||||
const fetchPlansData = useCallback(async () => {
|
||||
// Guard: Skip if not in SaaS mode or not authenticated
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (plansFetchInProgressRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
plansFetchInProgressRef.current = true;
|
||||
setPlansLoading(true);
|
||||
setPlansError(null);
|
||||
|
||||
try {
|
||||
const priceMap = await saasBillingService.getAvailablePlans('usd');
|
||||
setPlans(priceMap);
|
||||
const now = Date.now();
|
||||
plansLastFetchTimeRef.current = now;
|
||||
setPlansLastFetchTimeValue(now);
|
||||
} 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 {
|
||||
plansFetchInProgressRef.current = false;
|
||||
setPlansLoading(false);
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated]);
|
||||
@@ -182,9 +210,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isSaasMode || !isAuthenticated) {
|
||||
// Clear state when not in SaaS mode or not authenticated
|
||||
billingStatusRef.current = null;
|
||||
setBillingStatus(null);
|
||||
setPlans(new Map());
|
||||
setLastFetchTime(null);
|
||||
lastFetchTimeRef.current = null;
|
||||
setLastFetchTimeValue(null);
|
||||
plansLastFetchTimeRef.current = null;
|
||||
setPlansLastFetchTimeValue(null);
|
||||
plansFetchInProgressRef.current = false;
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setPlansError(null);
|
||||
@@ -199,12 +232,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
isAuthenticated &&
|
||||
!teamLoading &&
|
||||
!isManagedTeamMember &&
|
||||
billingStatus === null &&
|
||||
lastFetchTime === null
|
||||
billingStatusRef.current === null &&
|
||||
lastFetchTimeRef.current === null
|
||||
) {
|
||||
fetchBillingData();
|
||||
}
|
||||
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
|
||||
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, fetchBillingData]);
|
||||
|
||||
// Public refresh methods
|
||||
const refreshBilling = useCallback(async () => {
|
||||
@@ -212,11 +245,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force cache invalidation
|
||||
setLastFetchTime(null);
|
||||
// Force cache invalidation — write to ref synchronously so fetchBillingData
|
||||
// reads null immediately (not a stale closure value from the previous render).
|
||||
lastFetchTimeRef.current = null;
|
||||
setLastFetchTimeValue(null);
|
||||
await fetchBillingData();
|
||||
await fetchPlansData();
|
||||
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
|
||||
}, [isSaasMode, isAuthenticated, fetchBillingData]);
|
||||
|
||||
const refreshPlans = useCallback(async () => {
|
||||
await fetchPlansData();
|
||||
@@ -240,10 +274,9 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (typeof newBalance === 'number' && billingStatus) {
|
||||
// Update credit balance in billing status without full refresh
|
||||
setBillingStatus({
|
||||
...billingStatus,
|
||||
creditBalance: newBalance,
|
||||
});
|
||||
const updated = { ...billingStatus, creditBalance: newBalance };
|
||||
billingStatusRef.current = updated;
|
||||
setBillingStatus(updated);
|
||||
|
||||
// Dispatch exhausted event if credits hit 0
|
||||
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
||||
@@ -260,29 +293,36 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
||||
};
|
||||
}, [billingStatus]);
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
billingStatus,
|
||||
tier: isManagedTeamMember ? 'team' : (billingStatus?.tier ?? 'free'),
|
||||
subscription: billingStatus?.subscription ?? null,
|
||||
usage: billingStatus?.meterUsage ?? null,
|
||||
isTrialing: billingStatus?.isTrialing ?? false,
|
||||
trialDaysRemaining: billingStatus?.trialDaysRemaining,
|
||||
price: plans.get('team')?.price,
|
||||
currency: plans.get('team')?.currency,
|
||||
creditBalance: billingStatus?.creditBalance ?? 0,
|
||||
plans,
|
||||
plansLoading,
|
||||
plansError,
|
||||
plansLastFetchTime: plansLastFetchTimeValue,
|
||||
isManagedTeamMember,
|
||||
loading: loading || teamLoading,
|
||||
error,
|
||||
lastFetchTime: lastFetchTimeValue,
|
||||
refreshBilling,
|
||||
refreshCredits: refreshBilling,
|
||||
refreshPlans,
|
||||
openBillingPortal,
|
||||
}), [
|
||||
billingStatus, isManagedTeamMember, plans, plansLoading, plansError,
|
||||
plansLastFetchTimeValue, loading, teamLoading, error, lastFetchTimeValue,
|
||||
refreshBilling, refreshPlans, openBillingPortal,
|
||||
]);
|
||||
|
||||
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,
|
||||
}}>
|
||||
<SaasBillingContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</SaasBillingContext.Provider>
|
||||
);
|
||||
@@ -311,5 +351,26 @@ export function useSaaSBilling() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for components that display plan pricing data.
|
||||
* Fetches on first use; re-fetches only after PLANS_CACHE_TTL_MS (1 hour).
|
||||
* Safe to call from multiple components — in-flight deduplication is handled by fetchPlansData.
|
||||
*/
|
||||
export function usePlanPricing() {
|
||||
const { plans, plansLoading, plansError, plansLastFetchTime, refreshPlans } = useContext(SaasBillingContext);
|
||||
|
||||
useEffect(() => {
|
||||
const isFresh =
|
||||
plansLastFetchTime !== null &&
|
||||
Date.now() - plansLastFetchTime < PLANS_CACHE_TTL_MS;
|
||||
|
||||
if (!isFresh) {
|
||||
refreshPlans();
|
||||
}
|
||||
}, [plansLastFetchTime, refreshPlans]);
|
||||
|
||||
return { plans, plansLoading, plansError };
|
||||
}
|
||||
|
||||
export { SaasBillingContext };
|
||||
export type { SaasBillingContextType };
|
||||
|
||||
Reference in New Issue
Block a user