Files
ALwrity/frontend/src/contexts/SubscriptionContext.tsx
ajaysi 66faff9051 fix: Add podcast-only demo mode frontend integration
- Skip onboarding in demo mode, redirect to podcast-maker
- Demo mode checks localStorage and env vars
- Remove mock subscription - use real subscription flow
2026-03-31 06:48:24 +05:30

599 lines
24 KiB
TypeScript

import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback, useRef } from 'react';
import {
apiClient,
isBackendCooldownActive,
logBackendCooldownSkipOnce,
setGlobalSubscriptionErrorHandler,
} from '../api/client';
import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
import { saveNavigationState, getCurrentPhaseForTool } from '../utils/navigationState';
import { showSubscriptionExpiredToast, showUsageLimitToast, showSubscriptionToast } from '../utils/toastNotifications';
export interface SubscriptionLimits {
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
mistral_calls: number;
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
}
export interface SubscriptionStatus {
active: boolean;
plan: string;
tier: string;
can_use_api: boolean;
reason?: string;
limits: SubscriptionLimits;
}
interface SubscriptionContextType {
subscription: SubscriptionStatus | null;
loading: boolean;
error: string | null;
checkSubscription: () => Promise<void>;
refreshSubscription: () => Promise<void>;
showExpiredModal: () => void;
hideExpiredModal: () => void;
}
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
export const useSubscription = () => {
const context = useContext(SubscriptionContext);
if (!context) {
throw new Error('useSubscription must be used within a SubscriptionProvider');
}
return context;
};
interface SubscriptionProviderProps {
children: ReactNode;
}
export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ children }) => {
const [subscription, setSubscription] = useState<SubscriptionStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalErrorData, setModalErrorData] = useState<any>(null);
const [lastModalShowTime, setLastModalShowTime] = useState<number>(0);
const [deferredError, setDeferredError] = useState<any>(null);
const [lastCheckTime, setLastCheckTime] = useState<number>(0);
// New: Grace window after plan changes to avoid noisy UX
const [graceUntil, setGraceUntil] = useState<number>(0);
const [planSignature, setPlanSignature] = useState<string>("");
// Flag to track if current modal is a usage limit modal (should never be auto-closed)
const [isUsageLimitModal, setIsUsageLimitModal] = useState<boolean>(false);
// Use ref to access latest subscription value in callbacks (avoid closure issues)
const subscriptionRef = useRef<SubscriptionStatus | null>(null);
useEffect(() => {
subscriptionRef.current = subscription;
}, [subscription]);
const checkSubscription = useCallback(async () => {
// Throttle subscription checks to prevent excessive API calls
const now = Date.now();
const THROTTLE_MS = 5000; // 5 seconds minimum between checks
if (now - lastCheckTime < THROTTLE_MS) {
console.log('SubscriptionContext: Check throttled (5s)');
return;
}
if (isBackendCooldownActive()) {
logBackendCooldownSkipOnce('SubscriptionContext');
return;
}
setLastCheckTime(now);
setLoading(true);
setError(null);
try {
// Get user ID from localStorage or auth context
const userId = localStorage.getItem('user_id') || 'anonymous';
// Don't make API call if user is anonymous (not authenticated)
if (userId === 'anonymous') {
console.log('SubscriptionContext: User not authenticated, skipping subscription check');
setLoading(false);
return;
}
// Wait for authentication to be ready
// The apiClient interceptor needs authTokenGetter to be set by TokenInstaller
// Wait up to 2 seconds for token getter to be installed (TokenInstaller runs in App.tsx)
let authReady = false;
let attempts = 0;
const maxAttempts = 20; // 20 * 100ms = 2 seconds max wait
while (attempts < maxAttempts && !authReady) {
// Wait for TokenInstaller to set the authTokenGetter in api/client.ts
await new Promise(resolve => setTimeout(resolve, 100));
// Check if user_id exists (indicates user is authenticated)
const storedUserId = localStorage.getItem('user_id');
if (storedUserId && storedUserId !== 'anonymous') {
// After a few attempts, assume token getter should be ready
// The apiClient interceptor will add the token if authTokenGetter is set
if (attempts >= 5) { // After 500ms, proceed with the request
authReady = true;
break;
}
} else {
// No user_id means user is not authenticated, exit early
console.log('SubscriptionContext: No user_id found, user not authenticated');
setLoading(false);
return;
}
attempts++;
}
if (!authReady) {
console.warn('SubscriptionContext: Auth token getter may not be ready, but proceeding with request. apiClient will handle 401 gracefully.');
// Continue anyway - apiClient interceptor will handle missing token gracefully
}
console.log('SubscriptionContext: Checking subscription for user:', userId);
const response = await apiClient.get(`/api/subscription/status/${userId}`);
const subscriptionData = response.data.data;
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
setSubscription(subscriptionData);
// Update ref immediately so callbacks can access latest value
subscriptionRef.current = subscriptionData;
// Check if subscription is expired/inactive and show modal
// Show modal if subscription is inactive on initial load (when subscription was null before)
// This ensures the modal shows when an end user navigates to the app
const wasSubscriptionNull = subscription === null;
const subscriptionJustBecameInactive = subscription?.active === true && !subscriptionData.active;
if (subscriptionData && !subscriptionData.active) {
// Show modal on initial load (when subscription was null) or if subscription just became inactive
// This ensures the modal shows when an end user navigates to the app with an inactive subscription
if (wasSubscriptionNull || subscriptionJustBecameInactive) {
console.log('SubscriptionContext: Subscription is inactive, showing modal', {
wasSubscriptionNull,
subscriptionJustBecameInactive,
subscriptionActive: subscriptionData.active
});
setIsUsageLimitModal(false);
setModalErrorData({
message: 'To continue using Alwrity and access all features, you need to renew your subscription.'
});
setShowModal(true);
setLastModalShowTime(Date.now());
// Also show toast notification with message similar to modal
showSubscriptionExpiredToast();
}
}
// Detect plan/tier change and start a grace window (5 minutes)
try {
const newSignature = `${subscriptionData?.plan || ''}:${subscriptionData?.tier || ''}`;
if (newSignature && newSignature !== planSignature) {
console.log('SubscriptionContext: Plan change detected, starting grace window');
setPlanSignature(newSignature);
setGraceUntil(Date.now() + 5 * 60 * 1000);
// Close any existing modal as plan just changed
// BUT: Don't close usage limit modals - they're important even after plan changes
if (showModal && !isUsageLimitModal) {
console.log('SubscriptionContext: Plan changed, closing non-usage-limit modal');
setShowModal(false);
setModalErrorData(null);
} else if (showModal && isUsageLimitModal) {
console.log('SubscriptionContext: Plan changed but usage limit modal is open, keeping it open');
}
}
} catch (_e) {}
// If we have a valid subscription and the modal is open, close it
// BUT: NEVER close usage limit modals - user needs to see they hit a limit even with active subscription
if (subscriptionData && subscriptionData.active && showModal) {
// Check if this is a usage limit modal (using flag or checking error data)
const hasUsageInfo = modalErrorData?.usage_info ||
(modalErrorData?.current_tokens !== undefined) ||
(modalErrorData?.current_calls !== undefined) ||
(modalErrorData?.limit !== undefined) ||
(modalErrorData?.requested_tokens !== undefined);
const isUsageLimit = isUsageLimitModal || hasUsageInfo;
if (isUsageLimit) {
console.log('SubscriptionContext: Usage limit modal detected - KEEPING OPEN (never auto-close usage limit modals)', {
isUsageLimitModal,
hasUsageInfo,
modalErrorDataKeys: modalErrorData ? Object.keys(modalErrorData) : []
});
// Do NOT close - usage limit modals should stay open until user dismisses them
} else {
console.log('SubscriptionContext: Non-usage-limit modal detected, closing since subscription is active');
setShowModal(false);
setModalErrorData(null);
setIsUsageLimitModal(false);
setLastModalShowTime(0); // Reset the cooldown timer
}
}
// Check if we have a deferred error to process now that we have subscription data
if (subscriptionData && deferredError) {
console.log('SubscriptionContext: Processing deferred error now that subscription data is available');
const error = deferredError;
setDeferredError(null); // Clear the deferred error
// Re-run the error handling logic now that we have subscription data
const status = error.response?.status;
if (status === 429 || status === 402) {
const now = Date.now();
// If active, suppress modal for usage limits
if (subscriptionData.active) {
console.log('SubscriptionContext: Active subscription (deferred); suppressing usage-limit modal');
return;
}
// For inactive subscriptions, show modal immediately
console.log('SubscriptionContext: Showing deferred modal for inactive subscription');
const errorData = error.response?.data || {};
// If errorData is an array, extract the first element
let processedErrorData = errorData;
if (Array.isArray(errorData)) {
processedErrorData = errorData[0] || {};
}
// If errorData has a 'detail' field, extract it (FastAPI format)
if (processedErrorData.detail && typeof processedErrorData.detail === 'object') {
processedErrorData = processedErrorData.detail;
}
const modalMessage = processedErrorData.message || processedErrorData.error ||
'To continue using Alwrity and access all features, you need to renew your subscription.';
setModalErrorData({
provider: processedErrorData.provider,
usage_info: processedErrorData.usage_info,
message: modalMessage
});
setShowModal(true);
setLastModalShowTime(now);
// Also show toast notification
showSubscriptionExpiredToast();
}
}
} catch (err: any) {
console.error('Error checking subscription:', err);
// Check if it's a connection error that should be handled at the app level
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
// Re-throw connection errors to be handled by the app-level error boundary
throw err;
}
// Handle 401 errors gracefully during initialization - don't block routing
// 401 might happen if auth token getter isn't ready yet
if (err?.response?.status === 401) {
console.warn('Subscription check failed with 401 - auth may not be ready yet, will retry later');
setError(null); // Don't set error for 401 during init
setLoading(false);
// Don't throw - allow routing to proceed, subscription check will retry later
return;
}
setError(err instanceof Error ? err.message : 'Failed to check subscription');
// Don't default to free tier on error - preserve existing subscription or leave null
// This prevents overriding correct subscription data with 'free' on temporary errors
console.warn('Subscription check failed, preserving existing data');
} finally {
setLoading(false);
}
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil, isUsageLimitModal]);
const refreshSubscription = useCallback(async () => {
await checkSubscription();
}, [checkSubscription]);
const showExpiredModal = useCallback(() => {
setIsUsageLimitModal(false);
setShowModal(true);
}, []);
const hideExpiredModal = useCallback(() => {
console.log('SubscriptionExpiredModal: User manually closed modal');
setShowModal(false);
setIsUsageLimitModal(false); // Reset flag when user closes modal
setModalErrorData(null);
}, []);
const handleRenewSubscription = useCallback(() => {
// Save current location and phase so we can return after renewal
const currentPath = window.location.pathname;
// Detect tool from path
let tool: string | undefined;
if (currentPath.includes('/blog-writer') || currentPath.includes('/blogwriter')) {
tool = 'blog-writer';
}
// Get current phase for the tool if applicable
let phase: string | null = null;
if (tool) {
phase = getCurrentPhaseForTool(tool);
}
// Save navigation state (path, phase, tool)
saveNavigationState(currentPath, phase || undefined, tool);
console.log('SubscriptionContext: Navigating to pricing page, saved navigation state:', {
path: currentPath,
phase,
tool
});
window.location.href = '/pricing';
}, []);
// Global subscription error handler for API client
const globalSubscriptionErrorHandler = useCallback(async (error: any): Promise<boolean> => {
// Check if it's a subscription-related error
const status = error.response?.status;
console.log('SubscriptionContext: globalSubscriptionErrorHandler called', {
status,
hasResponse: !!error.response,
dataKeys: error.response?.data ? Object.keys(error.response.data) : null,
data: error.response?.data
});
if (status === 429 || status === 402) {
const now = Date.now();
// Check if this is a usage limit error (status 429) vs subscription expired (402)
let errorData = error.response?.data || {};
console.log('SubscriptionContext: Processing subscription error', {
originalErrorData: errorData,
isArray: Array.isArray(errorData),
hasDetail: errorData.detail !== undefined
});
// If errorData is an array, extract the first element (common FastAPI response format)
if (Array.isArray(errorData)) {
errorData = errorData[0] || {};
}
// CRITICAL: FastAPI wraps HTTPException detail in a 'detail' field
// If errorData has a 'detail' field, extract it (this is the actual error data)
// BUT: JSONResponse returns data directly, not wrapped in 'detail'
if (errorData.detail && typeof errorData.detail === 'object') {
errorData = errorData.detail;
}
console.log('SubscriptionContext: Processed errorData', {
errorData,
hasUsageInfo: !!errorData.usage_info,
provider: errorData.provider,
message: errorData.message
});
// Check for usage_info in various possible locations (now that we've unwrapped FastAPI detail)
const usageInfo = errorData.usage_info ||
(errorData.current_calls !== undefined ? errorData : null) ||
(errorData.requested_tokens !== undefined ? errorData : null) ||
(errorData.current_tokens !== undefined ? errorData : null) ||
null;
// Usage limit error: 429 status with usage info OR provider OR message indicating token/call limits
const hasUsageIndicators = usageInfo ||
errorData.provider ||
errorData.message?.includes('limit') ||
errorData.error?.includes('limit') ||
errorData.requested_tokens !== undefined ||
errorData.current_tokens !== undefined ||
errorData.current_calls !== undefined;
const isUsageLimitError = status === 429 && hasUsageIndicators;
const isSubscriptionExpired = status === 402 || (status === 429 && !isUsageLimitError);
// For usage limit errors (429 with usage_info), check subscription status first
// User may have just renewed, so we need fresh subscription data
if (isUsageLimitError) {
// CRITICAL: Check if subscription status is stale (older than 5 seconds)
// If stale or if we don't have subscription data, refresh it before deciding
const timeSinceLastCheck = now - lastCheckTime;
const shouldRefresh = !subscription || timeSinceLastCheck > 5000;
if (shouldRefresh) {
try {
await checkSubscription();
// Wait for state update (checkSubscription updates subscription state)
await new Promise(resolve => setTimeout(resolve, 150));
} catch (refreshError) {
console.warn('SubscriptionContext: Failed to refresh subscription status:', refreshError);
}
}
// Re-read subscription state after potential refresh using ref (to avoid closure issues)
const currentSubscription = subscriptionRef.current;
// If subscription is inactive, treat as expired and fall through to expired handling
if (!currentSubscription || !currentSubscription.active) {
// Fall through to subscription expired handling below
} else {
// Subscription is active but usage limit exceeded - show usage limit modal
// Build usage_info from various possible locations
const finalUsageInfo = usageInfo ||
(errorData.requested_tokens !== undefined ? {
provider: errorData.provider,
current_tokens: errorData.current_tokens,
requested_tokens: errorData.requested_tokens,
limit: errorData.limit,
type: 'tokens',
...errorData
} : null) ||
errorData;
const modalData = {
provider: errorData.provider || usageInfo?.provider || 'unknown',
usage_info: finalUsageInfo || errorData,
message: errorData.message || errorData.error || 'You have reached your usage limit.'
};
// Set flag to mark this as a usage limit modal (should never be auto-closed)
setIsUsageLimitModal(true);
setModalErrorData(modalData);
setShowModal(true);
setLastModalShowTime(now);
// Show toast notification with usage limit message
const toastMessage = modalData.message ||
'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.';
showUsageLimitToast(toastMessage);
// Emit custom event for billing page and other listeners
window.dispatchEvent(new CustomEvent('subscription-limit-exceeded', {
detail: {
provider: modalData.provider,
usage_info: modalData.usage_info,
message: toastMessage,
error: errorData
}
}));
console.log('SubscriptionContext: Showing usage limit modal', {
provider: modalData.provider,
message: modalData.message?.substring(0, 50)
});
return true;
}
}
// For subscription expired errors, handle based on subscription status
if (isSubscriptionExpired) {
// If we have subscription data and it's active, this shouldn't happen but suppress anyway
if (subscription && subscription.active) {
return true;
}
// If we don't have subscription data yet, defer the decision
if (!subscription) {
setDeferredError(error);
return true; // Handle the error but don't show modal yet
}
// If subscription is not active, show modal immediately
if (!subscription.active) {
console.log('SubscriptionContext: Showing subscription expired modal');
setIsUsageLimitModal(false);
const modalMessage = errorData.message || errorData.error ||
'To continue using Alwrity and access all features, you need to renew your subscription.';
setModalErrorData({
provider: errorData.provider,
usage_info: errorData.usage_info,
message: modalMessage
});
setShowModal(true);
setLastModalShowTime(now);
// Also show toast notification
showSubscriptionExpiredToast();
return true;
}
}
}
return false; // Not a subscription error
}, [subscription, lastCheckTime, checkSubscription]);
// Register the global error handler with the API client
// Use a ref to ensure the latest handler is always used
const handlerRef = useRef(globalSubscriptionErrorHandler);
useEffect(() => {
handlerRef.current = globalSubscriptionErrorHandler;
}, [globalSubscriptionErrorHandler]);
useEffect(() => {
console.log('SubscriptionContext: Registering global subscription error handler');
setGlobalSubscriptionErrorHandler((error: any) => {
// Always use the latest handler from ref
return handlerRef.current(error);
});
// Cleanup: Don't remove the handler on unmount - it should persist
// This ensures errors can still be caught even during component transitions
}, []); // Empty deps - only register once, but handler ref updates automatically
useEffect(() => {
const eventHandler = (event: Event) => {
const customEvent = event as CustomEvent;
console.log('SubscriptionContext: Received subscription-error event fallback', customEvent.detail);
handlerRef.current(customEvent.detail);
};
window.addEventListener('subscription-error', eventHandler as EventListener);
return () => {
window.removeEventListener('subscription-error', eventHandler as EventListener);
};
}, []);
useEffect(() => {
// Check subscription on mount
checkSubscription();
// Set up periodic refresh (every 5 minutes)
const interval = setInterval(checkSubscription, 5 * 60 * 1000);
// Listen for subscription updates
const handleSubscriptionUpdate = () => {
console.log('Subscription updated, refreshing...');
checkSubscription();
};
// Listen for user authentication changes
const handleUserAuth = () => {
console.log('User authenticated, checking subscription...');
checkSubscription();
};
window.addEventListener('subscription-updated', handleSubscriptionUpdate);
window.addEventListener('user-authenticated', handleUserAuth);
return () => {
clearInterval(interval);
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
window.removeEventListener('user-authenticated', handleUserAuth);
};
}, []); // Remove checkSubscription dependency to prevent loop
const value: SubscriptionContextType = {
subscription,
loading,
error,
checkSubscription,
refreshSubscription,
showExpiredModal,
hideExpiredModal,
};
return (
<SubscriptionContext.Provider value={value}>
{children}
<SubscriptionExpiredModal
open={showModal}
onClose={hideExpiredModal}
onRenewSubscription={handleRenewSubscription}
subscriptionData={subscription}
errorData={modalErrorData}
/>
</SubscriptionContext.Provider>
);
};