mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +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,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Box, Text, Stack } from '@mantine/core';
|
import { Box, Text, Stack } from '@mantine/core';
|
||||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||||
|
import { BILLING_CONFIG } from '@app/config/billing';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
import { authService } from '@app/services/authService';
|
import { authService } from '@app/services/authService';
|
||||||
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
|
import { CREDIT_EVENTS } from '@app/constants/creditEvents';
|
||||||
@@ -49,7 +50,7 @@ export function QuickAccessBarFooterExtensions({ className }: QuickAccessBarFoot
|
|||||||
// - Still loading billing data
|
// - Still loading billing data
|
||||||
// - User is a managed team member (unlimited credits)
|
// - User is a managed team member (unlimited credits)
|
||||||
// - Credits >= 20 (only show when low)
|
// - Credits >= 20 (only show when low)
|
||||||
if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= 20) {
|
if (!isSaasMode || !isAuthenticated || loading || isManagedTeamMember || creditBalance >= BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SaasPlanSection() {
|
|||||||
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
|
const { currentTeam, isTeamLeader, isPersonalTeam } = useSaaSTeam();
|
||||||
|
|
||||||
// Plans data
|
// Plans data
|
||||||
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans('usd');
|
const { plans, loading: plansLoading, error: plansError } = useSaaSPlans();
|
||||||
|
|
||||||
// Check connection mode on mount
|
// Check connection mode on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
import { useSaaSBilling } from '@app/contexts/SaasBillingContext';
|
||||||
|
import { BILLING_CONFIG } from '@app/config/billing';
|
||||||
import { CreditExhaustedModal } from '@app/components/shared/modals/CreditExhaustedModal';
|
import { CreditExhaustedModal } from '@app/components/shared/modals/CreditExhaustedModal';
|
||||||
import { InsufficientCreditsModal } from '@app/components/shared/modals/InsufficientCreditsModal';
|
import { InsufficientCreditsModal } from '@app/components/shared/modals/InsufficientCreditsModal';
|
||||||
import { useCreditEvents } from '@app/hooks/useCreditEvents';
|
import { useCreditEvents } from '@app/hooks/useCreditEvents';
|
||||||
@@ -18,7 +19,16 @@ export function CreditModalBootstrap() {
|
|||||||
requiredCredits?: number;
|
requiredCredits?: number;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
const { creditBalance, isManagedTeamMember } = useSaaSBilling();
|
const { creditBalance, isManagedTeamMember, lastFetchTime, plansLastFetchTime, refreshPlans } = useSaaSBilling();
|
||||||
|
|
||||||
|
// Preload plan pricing when billing confirms credits are low.
|
||||||
|
// Fires once: only when billing has loaded (lastFetchTime set) and plans haven't been
|
||||||
|
// fetched yet (plansLastFetchTime null). This way the modal shows real prices instantly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastFetchTime !== null && plansLastFetchTime === null && creditBalance < BILLING_CONFIG.PLAN_PRICING_PRELOAD_THRESHOLD && !isManagedTeamMember) {
|
||||||
|
refreshPlans();
|
||||||
|
}
|
||||||
|
}, [lastFetchTime, plansLastFetchTime, creditBalance, isManagedTeamMember, refreshPlans]);
|
||||||
|
|
||||||
// Monitor credit balance and dispatch events
|
// Monitor credit balance and dispatch events
|
||||||
useCreditEvents();
|
useCreditEvents();
|
||||||
|
|||||||
@@ -13,9 +13,13 @@ export const BILLING_CONFIG = {
|
|||||||
// Overage pricing (per credit) - also fetched dynamically from Stripe
|
// Overage pricing (per credit) - also fetched dynamically from Stripe
|
||||||
OVERAGE_PRICE_PER_CREDIT: 0.05,
|
OVERAGE_PRICE_PER_CREDIT: 0.05,
|
||||||
|
|
||||||
// Credit warning threshold
|
// Credit warning threshold (shows low-balance indicator)
|
||||||
LOW_CREDIT_THRESHOLD: 10,
|
LOW_CREDIT_THRESHOLD: 10,
|
||||||
|
|
||||||
|
// Threshold at which plan pricing is preloaded (higher than LOW_CREDIT_THRESHOLD
|
||||||
|
// so prices are ready before the warning UI appears)
|
||||||
|
PLAN_PRICING_PRELOAD_THRESHOLD: 20,
|
||||||
|
|
||||||
// Stripe lookup keys
|
// Stripe lookup keys
|
||||||
PRO_PLAN_LOOKUP_KEY: 'plan:pro',
|
PRO_PLAN_LOOKUP_KEY: 'plan:pro',
|
||||||
METER_LOOKUP_KEY: 'meter:overage',
|
METER_LOOKUP_KEY: 'meter:overage',
|
||||||
|
|||||||
@@ -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 { saasBillingService, BillingStatus, PlanPrice } from '@app/services/saasBillingService';
|
||||||
import { authService } from '@app/services/authService';
|
import { authService } from '@app/services/authService';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
|
import { useSaaSTeam } from '@app/contexts/SaaSTeamContext';
|
||||||
import type { TierLevel } from '@app/types/billing';
|
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
|
* Desktop implementation of SaaS Billing Context
|
||||||
* Provides billing and plan management for users connected to SaaS backend
|
* Provides billing and plan management for users connected to SaaS backend
|
||||||
@@ -34,6 +38,7 @@ interface SaasBillingContextType {
|
|||||||
plans: Map<string, PlanPrice>;
|
plans: Map<string, PlanPrice>;
|
||||||
plansLoading: boolean;
|
plansLoading: boolean;
|
||||||
plansError: string | null;
|
plansError: string | null;
|
||||||
|
plansLastFetchTime: number | null;
|
||||||
|
|
||||||
// Derived State
|
// Derived State
|
||||||
isManagedTeamMember: boolean;
|
isManagedTeamMember: boolean;
|
||||||
@@ -63,6 +68,7 @@ const SaasBillingContext = createContext<SaasBillingContextType>({
|
|||||||
plans: new Map(),
|
plans: new Map(),
|
||||||
plansLoading: false,
|
plansLoading: false,
|
||||||
plansError: null,
|
plansError: null,
|
||||||
|
plansLastFetchTime: null,
|
||||||
isManagedTeamMember: false,
|
isManagedTeamMember: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -80,7 +86,19 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
const [plansError, setPlansError] = useState<string | null>(null);
|
const [plansError, setPlansError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
const [loading, setLoading] = useState(false); // Start false (lazy load)
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [isSaasMode, setIsSaasMode] = useState(false);
|
||||||
const [isAuthenticated, setIsAuthenticated] = 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)
|
// Cache check: Skip if fresh data exists (<5 min old)
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (billingStatus && lastFetchTime && (now - lastFetchTime) < 300000) {
|
if (billingStatusRef.current && lastFetchTimeRef.current && (now - lastFetchTimeRef.current) < 300000) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only set loading if no existing data (prevents flicker on refresh)
|
// Only set loading if no existing data (prevents flicker on refresh)
|
||||||
if (!billingStatus) {
|
if (!billingStatusRef.current) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await saasBillingService.getBillingStatus();
|
const status = await saasBillingService.getBillingStatus();
|
||||||
setBillingStatus(status); // Atomic update
|
billingStatusRef.current = status;
|
||||||
setLastFetchTime(now);
|
setBillingStatus(status);
|
||||||
|
lastFetchTimeRef.current = now;
|
||||||
|
setLastFetchTimeValue(now);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SaasBillingContext] Failed to fetch billing:', err);
|
console.error('[SaasBillingContext] Failed to fetch billing:', err);
|
||||||
@@ -154,26 +174,34 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 () => {
|
const fetchPlansData = useCallback(async () => {
|
||||||
// Guard: Skip if not in SaaS mode or not authenticated
|
|
||||||
if (!isSaasMode || !isAuthenticated) {
|
if (!isSaasMode || !isAuthenticated) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plansFetchInProgressRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
plansFetchInProgressRef.current = true;
|
||||||
setPlansLoading(true);
|
setPlansLoading(true);
|
||||||
setPlansError(null);
|
setPlansError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const priceMap = await saasBillingService.getAvailablePlans('usd');
|
const priceMap = await saasBillingService.getAvailablePlans('usd');
|
||||||
setPlans(priceMap);
|
setPlans(priceMap);
|
||||||
|
const now = Date.now();
|
||||||
|
plansLastFetchTimeRef.current = now;
|
||||||
|
setPlansLastFetchTimeValue(now);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[SaasBillingContext] Failed to fetch plans:', err);
|
console.error('[SaasBillingContext] Failed to fetch plans:', err);
|
||||||
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
setPlansError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
||||||
// Non-blocking: continue with empty plans
|
|
||||||
} finally {
|
} finally {
|
||||||
|
plansFetchInProgressRef.current = false;
|
||||||
setPlansLoading(false);
|
setPlansLoading(false);
|
||||||
}
|
}
|
||||||
}, [isSaasMode, isAuthenticated]);
|
}, [isSaasMode, isAuthenticated]);
|
||||||
@@ -182,9 +210,14 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSaasMode || !isAuthenticated) {
|
if (!isSaasMode || !isAuthenticated) {
|
||||||
// Clear state when not in SaaS mode or not authenticated
|
// Clear state when not in SaaS mode or not authenticated
|
||||||
|
billingStatusRef.current = null;
|
||||||
setBillingStatus(null);
|
setBillingStatus(null);
|
||||||
setPlans(new Map());
|
setPlans(new Map());
|
||||||
setLastFetchTime(null);
|
lastFetchTimeRef.current = null;
|
||||||
|
setLastFetchTimeValue(null);
|
||||||
|
plansLastFetchTimeRef.current = null;
|
||||||
|
setPlansLastFetchTimeValue(null);
|
||||||
|
plansFetchInProgressRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setPlansError(null);
|
setPlansError(null);
|
||||||
@@ -199,12 +232,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
isAuthenticated &&
|
isAuthenticated &&
|
||||||
!teamLoading &&
|
!teamLoading &&
|
||||||
!isManagedTeamMember &&
|
!isManagedTeamMember &&
|
||||||
billingStatus === null &&
|
billingStatusRef.current === null &&
|
||||||
lastFetchTime === null
|
lastFetchTimeRef.current === null
|
||||||
) {
|
) {
|
||||||
fetchBillingData();
|
fetchBillingData();
|
||||||
}
|
}
|
||||||
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, billingStatus, lastFetchTime, fetchBillingData]);
|
}, [isSaasMode, isAuthenticated, teamLoading, isManagedTeamMember, fetchBillingData]);
|
||||||
|
|
||||||
// Public refresh methods
|
// Public refresh methods
|
||||||
const refreshBilling = useCallback(async () => {
|
const refreshBilling = useCallback(async () => {
|
||||||
@@ -212,11 +245,12 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force cache invalidation
|
// Force cache invalidation — write to ref synchronously so fetchBillingData
|
||||||
setLastFetchTime(null);
|
// reads null immediately (not a stale closure value from the previous render).
|
||||||
|
lastFetchTimeRef.current = null;
|
||||||
|
setLastFetchTimeValue(null);
|
||||||
await fetchBillingData();
|
await fetchBillingData();
|
||||||
await fetchPlansData();
|
}, [isSaasMode, isAuthenticated, fetchBillingData]);
|
||||||
}, [isSaasMode, isAuthenticated, fetchBillingData, fetchPlansData]);
|
|
||||||
|
|
||||||
const refreshPlans = useCallback(async () => {
|
const refreshPlans = useCallback(async () => {
|
||||||
await fetchPlansData();
|
await fetchPlansData();
|
||||||
@@ -240,10 +274,9 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
if (typeof newBalance === 'number' && billingStatus) {
|
if (typeof newBalance === 'number' && billingStatus) {
|
||||||
// Update credit balance in billing status without full refresh
|
// Update credit balance in billing status without full refresh
|
||||||
setBillingStatus({
|
const updated = { ...billingStatus, creditBalance: newBalance };
|
||||||
...billingStatus,
|
billingStatusRef.current = updated;
|
||||||
creditBalance: newBalance,
|
setBillingStatus(updated);
|
||||||
});
|
|
||||||
|
|
||||||
// Dispatch exhausted event if credits hit 0
|
// Dispatch exhausted event if credits hit 0
|
||||||
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
if (newBalance <= 0 && (billingStatus.creditBalance ?? 0) > 0) {
|
||||||
@@ -260,29 +293,36 @@ export function SaasBillingProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
}, [billingStatus]);
|
}, [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 (
|
return (
|
||||||
<SaasBillingContext.Provider value={{
|
<SaasBillingContext.Provider value={contextValue}>
|
||||||
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}
|
{children}
|
||||||
</SaasBillingContext.Provider>
|
</SaasBillingContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -311,5 +351,26 @@ export function useSaaSBilling() {
|
|||||||
return context;
|
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 { SaasBillingContext };
|
||||||
export type { SaasBillingContextType };
|
export type { SaasBillingContextType };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { saasBillingService } from '@app/services/saasBillingService';
|
import { useSaaSBilling, usePlanPricing } from '@app/contexts/SaasBillingContext';
|
||||||
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
|
import { FREE_PLAN_FEATURES, TEAM_PLAN_FEATURES, ENTERPRISE_PLAN_FEATURES } from '@app/config/planFeatures';
|
||||||
import type { TierLevel } from '@app/types/billing';
|
import type { TierLevel } from '@app/types/billing';
|
||||||
|
|
||||||
@@ -22,34 +22,13 @@ export interface PlanTier {
|
|||||||
overagePrice?: number;
|
overagePrice?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSaaSPlans = (currency: string = 'usd') => {
|
export const useSaaSPlans = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [loading, setLoading] = useState(false);
|
const { refreshPlans } = useSaaSBilling();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const { plans, plansLoading, plansError } = usePlanPricing();
|
||||||
const [dynamicPrices, setDynamicPrices] = useState<Map<string, { price: number; currency: string; overagePrice?: number }>>(new Map());
|
|
||||||
|
|
||||||
const fetchPlans = async () => {
|
const computedPlans = useMemo<PlanTier[]>(() => {
|
||||||
try {
|
const teamPlan = plans.get('team');
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const priceMap = await saasBillingService.getAvailablePlans(currency);
|
|
||||||
setDynamicPrices(priceMap);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching plans:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
|
||||||
// Continue with default prices if fetch fails
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchPlans();
|
|
||||||
}, [currency]);
|
|
||||||
|
|
||||||
const plans = useMemo<PlanTier[]>(() => {
|
|
||||||
const teamPlan = dynamicPrices.get('team');
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -104,12 +83,12 @@ export const useSaaSPlans = (currency: string = 'usd') => {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}, [t, dynamicPrices]);
|
}, [t, plans]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
plans,
|
plans: computedPlans,
|
||||||
loading,
|
loading: plansLoading,
|
||||||
error,
|
error: plansError,
|
||||||
refetch: fetchPlans,
|
refetch: refreshPlans,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ export interface BillingStatus {
|
|||||||
tier: TierLevel;
|
tier: TierLevel;
|
||||||
isTrialing: boolean;
|
isTrialing: boolean;
|
||||||
trialDaysRemaining?: number;
|
trialDaysRemaining?: number;
|
||||||
price?: number; // Monthly price (in dollars)
|
|
||||||
currency?: string; // Currency symbol (e.g., '$', '£')
|
|
||||||
creditBalance?: number; // Real-time remaining credits
|
creditBalance?: number; // Real-time remaining credits
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,51 +71,6 @@ export class SaasBillingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch Pro plan price from Stripe
|
|
||||||
* Calls stripe-price-lookup edge function to get current pricing
|
|
||||||
*/
|
|
||||||
private async fetchPlanPrice(
|
|
||||||
token: string,
|
|
||||||
currencyCode: string = 'usd'
|
|
||||||
): Promise<{ price: number; currency: string }> {
|
|
||||||
try {
|
|
||||||
const { data, error } = await supabase.functions.invoke<{
|
|
||||||
prices: Record<string, { unit_amount: number; currency: string }>;
|
|
||||||
missing: string[];
|
|
||||||
}>('stripe-price-lookup', {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
lookup_keys: ['plan:pro'],
|
|
||||||
currency: currencyCode,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw new Error(error.message || 'Failed to fetch plan price');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data || !data.prices) {
|
|
||||||
throw new Error('No pricing data returned');
|
|
||||||
}
|
|
||||||
|
|
||||||
const proPrice = data.prices['plan:pro'];
|
|
||||||
if (proPrice) {
|
|
||||||
const price = proPrice.unit_amount / 100; // Convert cents to dollars
|
|
||||||
const currency = getCurrencySymbol(proPrice.currency);
|
|
||||||
return { price, currency };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if price not found
|
|
||||||
throw new Error('Pro plan price not found');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Desktop Billing] Error fetching plan price:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch billing status from Supabase edge function
|
* Fetch billing status from Supabase edge function
|
||||||
* Calls get-usage-billing which returns subscription + meter usage data
|
* Calls get-usage-billing which returns subscription + meter usage data
|
||||||
@@ -173,8 +126,6 @@ export class SaasBillingService {
|
|||||||
let meterUsage: BillingStatus['meterUsage'] = null;
|
let meterUsage: BillingStatus['meterUsage'] = null;
|
||||||
let isTrialing = false;
|
let isTrialing = false;
|
||||||
let trialDaysRemaining: number | undefined;
|
let trialDaysRemaining: number | undefined;
|
||||||
let price: number | undefined;
|
|
||||||
let currency: string | undefined;
|
|
||||||
|
|
||||||
if (isPro) {
|
if (isPro) {
|
||||||
// Fetch usage details
|
// Fetch usage details
|
||||||
@@ -204,17 +155,6 @@ export class SaasBillingService {
|
|||||||
console.warn('[Desktop Billing] Failed to fetch usage data:', usageError);
|
console.warn('[Desktop Billing] Failed to fetch usage data:', usageError);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the current Pro plan price from Stripe
|
|
||||||
try {
|
|
||||||
const priceData = await this.fetchPlanPrice(token, 'usd');
|
|
||||||
price = priceData.price;
|
|
||||||
currency = priceData.currency;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[Desktop Billing] Failed to fetch plan price, using default:', error);
|
|
||||||
// Fallback to default pricing
|
|
||||||
price = 10;
|
|
||||||
currency = '$';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch credit balance for all authenticated users (both Pro and Free)
|
// Fetch credit balance for all authenticated users (both Pro and Free)
|
||||||
@@ -250,8 +190,6 @@ export class SaasBillingService {
|
|||||||
tier,
|
tier,
|
||||||
isTrialing,
|
isTrialing,
|
||||||
trialDaysRemaining,
|
trialDaysRemaining,
|
||||||
price,
|
|
||||||
currency,
|
|
||||||
creditBalance,
|
creditBalance,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user