diff --git a/backend/alwrity_utils/rate_limiter.py b/backend/alwrity_utils/rate_limiter.py
index 7082991e..4fcc92ee 100644
--- a/backend/alwrity_utils/rate_limiter.py
+++ b/backend/alwrity_utils/rate_limiter.py
@@ -30,12 +30,16 @@ class RateLimiter:
"/calendar-events",
"/calendar-generation/progress",
"/health",
- "/health/database"
+ "/health/database",
]
+ # Prefixes to exempt entire route families (keep empty; rely on specific exemptions only)
+ self.exempt_prefixes = []
def is_exempt_path(self, path: str) -> bool:
"""Check if a path is exempt from rate limiting."""
- return any(exempt_path in path for exempt_path in self.exempt_paths)
+ return any(exempt_path == path or exempt_path in path for exempt_path in self.exempt_paths) or any(
+ path.startswith(prefix) for prefix in self.exempt_prefixes
+ )
def clean_old_requests(self, client_ip: str, current_time: float) -> None:
"""Clean old requests from the tracking dictionary."""
@@ -77,7 +81,6 @@ class RateLimiter:
# Check if path is exempt from rate limiting
if self.is_exempt_path(path):
- # Allow streaming endpoints without rate limiting
response = await call_next(request)
return response
diff --git a/backend/api/onboarding_utils/onboarding_completion_service.py b/backend/api/onboarding_utils/onboarding_completion_service.py
index 52c577e9..4f1ee427 100644
--- a/backend/api/onboarding_utils/onboarding_completion_service.py
+++ b/backend/api/onboarding_utils/onboarding_completion_service.py
@@ -16,8 +16,8 @@ class OnboardingCompletionService:
"""Service for handling onboarding completion logic."""
def __init__(self):
- # Only pre-requisite steps; step 6 is the finalization itself
- self.required_steps = [1, 2, 3]
+ # Pre-requisite steps; step 6 is the finalization itself
+ self.required_steps = [1, 2, 3, 4, 5]
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
"""Complete the onboarding process with full validation."""
@@ -73,9 +73,15 @@ class OnboardingCompletionService:
db = None
db_service = None
+ logger.info(f"OnboardingCompletionService: Validating steps for user {user_id}")
+ logger.info(f"OnboardingCompletionService: Current step: {progress.current_step}")
+ logger.info(f"OnboardingCompletionService: Required steps: {self.required_steps}")
+
for step_num in self.required_steps:
step = progress.get_step_data(step_num)
+ logger.info(f"OnboardingCompletionService: Step {step_num} - status: {step.status if step else 'None'}")
if step and step.status in [StepStatus.COMPLETED, StepStatus.SKIPPED]:
+ logger.info(f"OnboardingCompletionService: Step {step_num} already completed/skipped")
continue
# DB-aware fallbacks for migration period
@@ -129,6 +135,30 @@ class OnboardingCompletionService:
except Exception:
pass
continue
+ if step_num == 4:
+ # Treat as completed if persona data exists in DB
+ persona = None
+ try:
+ persona = db_service.get_persona_data(user_id, db)
+ except Exception:
+ persona = None
+ if persona and persona.get('corePersona'):
+ try:
+ progress.mark_step_completed(4, {'source': 'db-fallback'})
+ except Exception:
+ pass
+ continue
+ if step_num == 5:
+ # Treat as completed if integrations data exists in DB
+ # For now, we'll consider step 5 completed if the user has reached the final step
+ # This is a simplified approach - in the future, we could check for specific integration data
+ try:
+ # Check if user has completed previous steps and is on final step
+ if progress.current_step >= 6: # FinalStep is step 6
+ progress.mark_step_completed(5, {'source': 'final-step-fallback'})
+ continue
+ except Exception:
+ pass
except Exception:
# If DB check fails, fall back to progress status only
pass
diff --git a/backend/api/onboarding_utils/step4_persona_routes.py b/backend/api/onboarding_utils/step4_persona_routes.py
index dec2871d..3e5c6a72 100644
--- a/backend/api/onboarding_utils/step4_persona_routes.py
+++ b/backend/api/onboarding_utils/step4_persona_routes.py
@@ -134,11 +134,11 @@ async def generate_writing_personas_async(
"request_data": (PersonaGenerationRequest(**(request if isinstance(request, dict) else request.dict())).dict()) if request else {}
}
logger.info(f"Cache hit for user {user_id} - returning completed task without regeneration: {task_id}")
- return {
- "task_id": task_id,
- "status": "completed",
- "message": "Persona loaded from cache"
- }
+ return {
+ "task_id": task_id,
+ "status": "completed",
+ "message": "Persona loaded from cache"
+ }
# Generate unique task ID
task_id = str(uuid.uuid4())
diff --git a/backend/api/subscription_api.py b/backend/api/subscription_api.py
index ac6a88c2..b296d5d9 100644
--- a/backend/api/subscription_api.py
+++ b/backend/api/subscription_api.py
@@ -380,6 +380,13 @@ async def subscribe_to_plan(
db.commit()
+ # Reset usage status for current billing period so new plan takes effect immediately
+ try:
+ usage_service = UsageTrackingService(db)
+ await usage_service.reset_current_billing_period(user_id)
+ except Exception as reset_err:
+ logger.error(f"Failed to reset usage after subscribe: {reset_err}")
+
return {
"success": True,
"message": f"Successfully subscribed to {plan.name}",
diff --git a/backend/middleware/monitoring_middleware.py b/backend/middleware/monitoring_middleware.py
index 717ad36c..e84851c9 100644
--- a/backend/middleware/monitoring_middleware.py
+++ b/backend/middleware/monitoring_middleware.py
@@ -35,25 +35,25 @@ class DatabaseAPIMonitor:
# API provider detection patterns - Updated to match actual endpoints
self.provider_patterns = {
APIProvider.GEMINI: [
- r'/api/blog-writer', r'/api/content-planning', r'/api/strategy-copilot',
- r'/api/brainstorm', r'/api/writing-assistant', r'/api/seo-dashboard',
- r'/api/onboarding', r'/api/user-data', r'/api/component-logic',
- r'gemini', r'google.*ai', r'blog.*writer', r'content.*planning'
+ r'gemini', r'google.*ai'
],
- APIProvider.OPENAI: [r'/openai', r'openai', r'gpt', r'chatgpt'],
- APIProvider.ANTHROPIC: [r'/anthropic', r'claude', r'anthropic'],
- APIProvider.MISTRAL: [r'/mistral', r'mistral'],
- APIProvider.TAVILY: [r'/tavily', r'tavily', r'research', r'search'],
- APIProvider.SERPER: [r'/serper', r'serper', r'google.*search', r'seo'],
- APIProvider.METAPHOR: [r'/metaphor', r'/exa', r'metaphor', r'exa'],
- APIProvider.FIRECRAWL: [r'/firecrawl', r'firecrawl', r'crawl'],
- APIProvider.STABILITY: [r'/stability', r'stable.*diffusion', r'stability', r'image.*generation']
+ APIProvider.OPENAI: [r'openai', r'gpt', r'chatgpt'],
+ APIProvider.ANTHROPIC: [r'anthropic', r'claude'],
+ APIProvider.MISTRAL: [r'mistral'],
+ APIProvider.TAVILY: [r'tavily'],
+ APIProvider.SERPER: [r'serper'],
+ APIProvider.METAPHOR: [r'metaphor', r'/exa'],
+ APIProvider.FIRECRAWL: [r'firecrawl']
}
def detect_api_provider(self, path: str, user_agent: str = None) -> Optional[APIProvider]:
"""Detect which API provider is being used based on request details."""
path_lower = path.lower()
user_agent_lower = (user_agent or '').lower()
+
+ # Permanently ignore internal route families that must not accrue or check provider usage
+ if path_lower.startswith('/api/onboarding/') or path_lower.startswith('/api/subscription/'):
+ return None
for provider, patterns in self.provider_patterns.items():
for pattern in patterns:
@@ -384,16 +384,26 @@ EXCLUDED_ENDPOINTS = [
"/api/content-planning/monitoring/cache-stats",
"/api/content-planning/monitoring/health"
]
+# Also exclude whole route families by prefix (e.g., subscription/billing must never be blocked)
+EXCLUDED_PREFIXES = [
+]
+
def should_monitor_endpoint(path: str) -> bool:
"""Check if an endpoint should be monitored."""
- return not any(path.endswith(excluded) for excluded in EXCLUDED_ENDPOINTS)
+ return not any(path.endswith(excluded) for excluded in EXCLUDED_ENDPOINTS) and not any(path.startswith(prefix) for prefix in EXCLUDED_PREFIXES)
async def check_usage_limits_middleware(request: Request, user_id: str, request_body: str = None) -> Optional[JSONResponse]:
"""Check usage limits before processing request."""
if not user_id:
return None
+ # No special whitelist; onboarding/subscription are ignored by provider detection
+ try:
+ path = request.url.path
+ except Exception:
+ pass
+
try:
db = next(get_db())
api_monitor = DatabaseAPIMonitor()
diff --git a/backend/services/blog_writer/seo/blog_seo_metadata_generator.py b/backend/services/blog_writer/seo/blog_seo_metadata_generator.py
index c180a1aa..cbbc2a06 100644
--- a/backend/services/blog_writer/seo/blog_seo_metadata_generator.py
+++ b/backend/services/blog_writer/seo/blog_seo_metadata_generator.py
@@ -157,8 +157,8 @@ class BlogSEOMetadataGenerator:
# Get structured response from Gemini
ai_response = self.gemini_provider(
- prompt=prompt,
- schema=schema,
+ prompt,
+ schema,
temperature=0.3,
max_tokens=2048
)
@@ -167,6 +167,8 @@ class BlogSEOMetadataGenerator:
if not ai_response or not isinstance(ai_response, dict):
logger.error("Core metadata generation failed: Invalid response from Gemini")
# Return fallback response
+ primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
+ word_count = len(blog_content.split())
return {
'seo_title': blog_title,
'meta_description': f'Learn about {primary_keywords.split(", ")[0] if primary_keywords else "this topic"}.',
@@ -246,8 +248,8 @@ class BlogSEOMetadataGenerator:
# Get structured response from Gemini
ai_response = self.gemini_provider(
- prompt=prompt,
- schema=schema,
+ prompt,
+ schema,
temperature=0.3,
max_tokens=2048
)
diff --git a/backend/services/llm_providers/gemini_provider.py b/backend/services/llm_providers/gemini_provider.py
index b20cb695..16bcc98b 100644
--- a/backend/services/llm_providers/gemini_provider.py
+++ b/backend/services/llm_providers/gemini_provider.py
@@ -348,6 +348,11 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9,
try:
# Get API key with proper error handling
api_key = get_gemini_api_key()
+ logger.info(f"🔑 Gemini API key loaded: {bool(api_key)} (length: {len(api_key) if api_key else 0})")
+
+ if not api_key:
+ raise Exception("GEMINI_API_KEY not found in environment variables")
+
client = genai.Client(api_key=api_key)
logger.info("✅ Gemini client initialized for structured JSON response")
@@ -383,11 +388,18 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9,
system_instruction=system_prompt,
)
- response = client.models.generate_content(
- model="gemini-2.5-flash",
- contents=prompt,
- config=generation_config,
- )
+ logger.info("🚀 Making Gemini API call...")
+ try:
+ response = client.models.generate_content(
+ model="gemini-2.5-flash",
+ contents=prompt,
+ config=generation_config,
+ )
+ logger.info("✅ Gemini API call completed successfully")
+ except Exception as api_error:
+ logger.error(f"❌ Gemini API call failed: {api_error}")
+ logger.error(f"❌ API Error type: {type(api_error).__name__}")
+ raise api_error
# Check for parsed content first (primary method for structured output)
if hasattr(response, 'parsed'):
diff --git a/backend/services/usage_tracking_service.py b/backend/services/usage_tracking_service.py
index 867dde88..503cd395 100644
--- a/backend/services/usage_tracking_service.py
+++ b/backend/services/usage_tracking_service.py
@@ -485,4 +485,27 @@ class UsageTrackingService:
user_id=user_id,
provider=provider,
tokens_requested=tokens_requested
- )
\ No newline at end of file
+ )
+
+ async def reset_current_billing_period(self, user_id: str) -> Dict[str, Any]:
+ """Reset usage status for the current billing period (after plan change)."""
+ try:
+ billing_period = datetime.now().strftime("%Y-%m")
+ summary = self.db.query(UsageSummary).filter(
+ UsageSummary.user_id == user_id,
+ UsageSummary.billing_period == billing_period
+ ).first()
+
+ if not summary:
+ # Nothing to reset
+ return {"reset": False, "reason": "no_summary"}
+
+ # Clear LIMIT_REACHED so the user can resume; keep counters intact
+ summary.usage_status = UsageStatus.ACTIVE
+ summary.updated_at = datetime.utcnow()
+ self.db.commit()
+ return {"reset": True}
+ except Exception as e:
+ self.db.rollback()
+ logger.error(f"Error resetting usage status: {e}")
+ return {"reset": False, "error": str(e)}
\ No newline at end of file
diff --git a/frontend/public/index.html b/frontend/public/index.html
index f7f6092b..1c67ccb0 100644
--- a/frontend/public/index.html
+++ b/frontend/public/index.html
@@ -9,7 +9,7 @@
name="description"
content="Alwrity - AI Content Creation Platform"
/>
-
+
Alwrity - AI Content Creation Platform
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index d14dfa08..45db7751 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -71,7 +71,7 @@ const InitialRouteHandler: React.FC = () => {
});
}
});
- }, [checkSubscription]);
+ }, []); // Remove checkSubscription dependency to prevent loop
// Initialize onboarding only after subscription is confirmed
useEffect(() => {
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index ee446695..bd130ce8 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -1,5 +1,12 @@
import axios from 'axios';
+// Global subscription error handler - will be set by the app
+let globalSubscriptionErrorHandler: ((error: any) => boolean) | null = null;
+
+export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boolean) => {
+ globalSubscriptionErrorHandler = handler;
+};
+
// Optional token getter installed from within the app after Clerk is available
let authTokenGetter: (() => Promise) | null = null;
@@ -141,6 +148,18 @@ apiClient.interceptors.response.use(
}
}
+ // Check if it's a subscription-related error and handle it globally
+ if (error.response?.status === 429 || error.response?.status === 402) {
+ console.log('API Client: Detected subscription error, triggering global handler');
+ if (globalSubscriptionErrorHandler) {
+ const wasHandled = globalSubscriptionErrorHandler(error);
+ if (wasHandled) {
+ console.log('API Client: Subscription error handled by global handler');
+ return Promise.reject(error);
+ }
+ }
+ }
+
console.error('API Error:', error.response?.status, error.response?.data);
return Promise.reject(error);
}
@@ -194,6 +213,18 @@ aiApiClient.interceptors.response.use(
}
}
+ // Check if it's a subscription-related error and handle it globally
+ if (error.response?.status === 429 || error.response?.status === 402) {
+ console.log('AI API Client: Detected subscription error, triggering global handler');
+ if (globalSubscriptionErrorHandler) {
+ const wasHandled = globalSubscriptionErrorHandler(error);
+ if (wasHandled) {
+ console.log('AI API Client: Subscription error handled by global handler');
+ return Promise.reject(error);
+ }
+ }
+ }
+
console.error('AI API Error:', error.response?.status, error.response?.data);
return Promise.reject(error);
}
@@ -232,6 +263,18 @@ longRunningApiClient.interceptors.response.use(
console.warn('401 Unauthorized during onboarding - token may need refresh');
}
}
+ // Check if it's a subscription-related error and handle it globally
+ if (error.response?.status === 429 || error.response?.status === 402) {
+ console.log('Long-running API Client: Detected subscription error, triggering global handler');
+ if (globalSubscriptionErrorHandler) {
+ const wasHandled = globalSubscriptionErrorHandler(error);
+ if (wasHandled) {
+ console.log('Long-running API Client: Subscription error handled by global handler');
+ return Promise.reject(error);
+ }
+ }
+ }
+
console.error('Long-running API Error:', error.response?.status, error.response?.data);
return Promise.reject(error);
}
@@ -270,6 +313,18 @@ pollingApiClient.interceptors.response.use(
console.warn('401 Unauthorized during onboarding - token may need refresh');
}
}
+ // Check if it's a subscription-related error and handle it globally
+ if (error.response?.status === 429 || error.response?.status === 402) {
+ console.log('Polling API Client: Detected subscription error, triggering global handler');
+ if (globalSubscriptionErrorHandler) {
+ const wasHandled = globalSubscriptionErrorHandler(error);
+ if (wasHandled) {
+ console.log('Polling API Client: Subscription error handled by global handler');
+ return Promise.reject(error);
+ }
+ }
+ }
+
console.error('Polling API Error:', error.response?.status, error.response?.data);
return Promise.reject(error);
}
diff --git a/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx
index 78fa8e1f..3071f711 100644
--- a/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx
+++ b/frontend/src/components/OnboardingWizard/FinalStep/FinalStep.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useRef } from 'react';
import {
Box,
Button,
@@ -12,7 +12,9 @@ import {
import {
Rocket,
Star,
- CheckCircle
+ CheckCircle,
+ CreditCard,
+ Warning
} from '@mui/icons-material';
import OnboardingButton from '../common/OnboardingButton';
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
@@ -21,23 +23,40 @@ import { FinalStepProps, OnboardingData, Capability } from './types';
const FinalStep: React.FC = ({ onContinue, updateHeaderContent }) => {
const [loading, setLoading] = useState(false);
- const [dataLoading, setDataLoading] = useState(true);
+ const [dataLoading, setDataLoading] = useState(false);
const [error, setError] = useState(null);
const [onboardingData, setOnboardingData] = useState({
apiKeys: {}
});
const [expandedSection, setExpandedSection] = useState('summary');
+ const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
+ const buttonRef = useRef(null);
useEffect(() => {
updateHeaderContent({
title: 'Review & Launch Alwrity 🚀',
description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.'
});
+ // Always attempt to load data once on mount
loadOnboardingData();
}, [updateHeaderContent]);
+ // Remove the DOM manipulation approach - we'll use React's built-in event handling
+
const loadOnboardingData = async () => {
+ // Prevent multiple simultaneous data loading calls
+ if (dataLoading) {
+ return;
+ }
+
setDataLoading(true);
+
+ // Set a timeout to prevent infinite loading
+ const loadingTimeout = setTimeout(() => {
+ console.log('FinalStep: Data loading timeout reached, proceeding with available data');
+ setDataLoading(false);
+ }, 4000); // 4s timeout
+
try {
// Load comprehensive onboarding summary
const summary = await getOnboardingSummary();
@@ -50,16 +69,26 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
const cachedAnalysisRaw = typeof window !== 'undefined' ? localStorage.getItem('website_analysis_data') : null;
const cachedAnalysis = cachedAnalysisRaw ? safeParseJSON(cachedAnalysisRaw) : undefined;
- setOnboardingData({
+ const newOnboardingData = {
apiKeys: summary.api_keys || {},
websiteUrl: websiteAnalysis?.website_url || summary.website_url || cachedUrl || undefined,
researchPreferences: researchPreferences || summary.research_preferences,
personalizationSettings: summary.personalization_settings,
integrations: summary.integrations || {},
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis || cachedAnalysis || undefined
- });
+ };
+
+ setOnboardingData(newOnboardingData);
+
+ // Validate completion status after data is loaded
+ console.log('FinalStep: Data loaded, running validation...');
+ const validation = await validateOnboardingCompletionWithData(newOnboardingData);
+ setValidationStatus(validation);
} catch (error) {
console.error('Error loading onboarding data:', error);
+
+ // Error handling is managed by global API client interceptors
+
// Fallback to just API keys if other endpoints fail
try {
const apiKeys = await getApiKeys();
@@ -73,9 +102,11 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
});
} catch (fallbackError) {
console.error('Error loading API keys as fallback:', fallbackError);
+ // Error handling is managed by global API client interceptors
}
} finally {
setDataLoading(false);
+ clearTimeout(loadingTimeout);
}
};
@@ -85,34 +116,168 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
try { return JSON.parse(raw); } catch { return undefined; }
};
+ const validateOnboardingCompletionWithData = async (data: OnboardingData): Promise<{isValid: boolean, missingSteps: string[]}> => {
+ console.log('FinalStep: Validating onboarding completion with data...');
+ console.log('FinalStep: Data to validate:', data);
+ const missingSteps: string[] = [];
+
+ try {
+ // Check API Keys (Step 1) - Since the user is on step 5 (FinalStep),
+ // they must have completed step 1 (API Keys) to get here
+ // The backend has EXA_API_KEY and GEMINI_API_KEY in .env and user completed step 1
+ const hasApiKeys = true; // User is on final step, so step 1 must be completed
+
+ console.log('FinalStep: API Keys check:', {
+ hasApiKeys,
+ reason: 'User is on final step, so step 1 (API Keys) must be completed',
+ note: 'Backend has EXA_API_KEY and GEMINI_API_KEY in .env'
+ });
+ if (!hasApiKeys) {
+ missingSteps.push('API Keys');
+ }
+
+ // Check Website Analysis (Step 2) - Check for website URL or analysis data
+ const hasWebsiteAnalysis = (data.websiteUrl && data.websiteUrl.trim() !== '') ||
+ (data.styleAnalysis && Object.keys(data.styleAnalysis).length > 0);
+ console.log('FinalStep: Website Analysis check:', {
+ websiteUrl: data.websiteUrl,
+ styleAnalysis: data.styleAnalysis,
+ hasWebsiteAnalysis
+ });
+ if (!hasWebsiteAnalysis) {
+ missingSteps.push('Website Analysis');
+ }
+
+ // Check Research Preferences (Step 3) - Check for research preferences data
+ const hasResearchPreferences = data.researchPreferences &&
+ (data.researchPreferences.research_depth ||
+ data.researchPreferences.content_characteristics ||
+ Object.keys(data.researchPreferences).length > 0);
+ console.log('FinalStep: Research Preferences check:', {
+ researchPreferences: data.researchPreferences,
+ hasResearchPreferences
+ });
+ if (!hasResearchPreferences) {
+ missingSteps.push('Research Preferences');
+ }
+
+ // Check Persona Generation (Step 4) - Check for persona readiness or data
+ const hasPersonaData = (data.personaReadiness && data.personaReadiness.isReady) ||
+ (data.personalizationSettings && Object.keys(data.personalizationSettings).length > 0);
+ console.log('FinalStep: Persona Generation check:', {
+ personaReadiness: data.personaReadiness,
+ personalizationSettings: data.personalizationSettings,
+ hasPersonaData
+ });
+ if (!hasPersonaData) {
+ missingSteps.push('Persona Generation');
+ }
+
+ // Check Integrations (Step 5) - For now, we'll consider this optional
+ // In the future, this could check for specific integration data
+
+ const isValid = missingSteps.length === 0;
+ console.log('FinalStep: Validation result:', {isValid, missingSteps});
+
+ return {isValid, missingSteps};
+ } catch (error) {
+ console.error('FinalStep: Error validating completion:', error);
+ return {isValid: false, missingSteps: ['Validation Error']};
+ }
+ };
+
+ const validateOnboardingCompletion = async (): Promise<{isValid: boolean, missingSteps: string[]}> => {
+ return validateOnboardingCompletionWithData(onboardingData);
+ };
+
const handleLaunch = async () => {
+ console.log('FinalStep: handleLaunch called - button clicked');
+ console.log('FinalStep: handleLaunch - starting execution');
+ console.log('FinalStep: handleLaunch - current state:', {loading, error, validationStatus, dataLoading});
+
+ if (loading) {
+ console.log('FinalStep: Already processing, ignoring click');
+ return;
+ }
+
+ // Wait for data to be fully loaded before proceeding
+ if (dataLoading) {
+ console.log('FinalStep: Data still loading, waiting...');
+ // Wait a bit and try again
+ setTimeout(() => {
+ if (!dataLoading) {
+ handleLaunch();
+ }
+ }, 100);
+ return;
+ }
+
setLoading(true);
setError(null);
try {
console.log('FinalStep: Starting onboarding completion...');
- // First, complete step 6 (Final Step) to mark it as completed
- console.log('FinalStep: Completing step 6...');
- await setCurrentStep(6);
- console.log('FinalStep: Step 6 completed successfully');
+ // First, validate that all required steps are completed
+ console.log('FinalStep: Validating all required steps...');
+ const validationResult = await validateOnboardingCompletion();
+ if (!validationResult.isValid) {
+ throw new Error(`Cannot complete onboarding. Missing steps: ${validationResult.missingSteps.join(', ')}`);
+ }
+ console.log('FinalStep: All required steps validated successfully');
- // Then complete the entire onboarding process
+ // Complete step 6 (Final Step) to mark it as completed
+ console.log('FinalStep: Completing step 6...');
+ console.log('FinalStep: Calling setCurrentStep(6)...');
+ const step6Result = await setCurrentStep(6);
+ console.log('FinalStep: Step 6 completed successfully:', step6Result);
+
+ // Complete the entire onboarding process
console.log('FinalStep: Completing onboarding...');
- await completeOnboarding();
- console.log('FinalStep: Onboarding completed successfully');
+ console.log('FinalStep: Calling completeOnboarding()...');
+ const completionResult = await completeOnboarding();
+ console.log('FinalStep: Onboarding completed successfully:', completionResult);
+
+ // Mark onboarding as complete locally to unblock immediate navigation
+ try {
+ localStorage.setItem('onboarding_complete', 'true');
+ localStorage.setItem('onboarding_active_step', String(stepsLengthFallback()));
+ } catch {}
// Navigate directly to dashboard without calling onContinue
// This bypasses the wizard flow and goes straight to the dashboard
console.log('FinalStep: Navigating to dashboard...');
- window.location.href = '/dashboard';
+ console.log('FinalStep: Setting window.location.href to /dashboard');
+
+ // Try multiple navigation methods to ensure redirect works
+ try {
+ window.location.href = '/dashboard';
+ console.log('FinalStep: window.location.href set successfully');
+ } catch (navError) {
+ console.error('FinalStep: window.location.href failed:', navError);
+ console.log('FinalStep: Trying alternative navigation method...');
+ window.location.assign('/dashboard');
+ }
+
+ console.log('FinalStep: Navigation initiated');
} catch (e: any) {
console.error('FinalStep: Error completing onboarding:', e);
+ console.error('FinalStep: Error details:', {
+ message: e.message,
+ status: e.response?.status,
+ statusText: e.response?.statusText,
+ data: e.response?.data,
+ stack: e.stack
+ });
+
+ // Error handling is managed by global API client interceptors
// Provide more specific error messages
let errorMessage = 'Failed to complete onboarding. Please try again.';
if (e.response?.data?.detail) {
errorMessage = e.response.data.detail;
+ } else if (e.response?.data?.message) {
+ errorMessage = e.response.data.message;
} else if (e.message) {
errorMessage = e.message;
}
@@ -122,6 +287,9 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
setLoading(false);
};
+ // Helper to compute steps length for storing active step (fallback value)
+ const stepsLengthFallback = () => 6;
+
const capabilities: Capability[] = [
{
id: 'ai-content',
@@ -232,6 +400,7 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
)}
+
{/* Alerts */}
{error && (
@@ -240,13 +409,15 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
severity="error"
sx={{ mb: 2, borderRadius: 2 }}
action={
-
+
+
+
}
>
@@ -260,18 +431,59 @@ const FinalStep: React.FC = ({ onContinue, updateHeaderContent }
)}
- {/* Action Button */}
+ {/* Validation Status */}
+ {validationStatus && !validationStatus.isValid && (
+
+
+
+ Setup Incomplete
+
+
+ The following steps need to be completed before launching:
+
+
+ {validationStatus.missingSteps.map((step, index) => (
+
+ {step}
+
+ ))}
+
+
+
+ )}
+
+ {/* Launch Button */}
- }
- disabled={Object.keys(onboardingData.apiKeys).length === 0}
+ disabled={loading || dataLoading}
+ onClick={handleLaunch}
+ startIcon={}
+ sx={{
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ fontSize: '1.125rem',
+ fontWeight: 600,
+ px: 4,
+ py: 2,
+ borderRadius: 2,
+ textTransform: 'none',
+ boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
+ transform: 'translateY(-1px)',
+ boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
+ },
+ '&:disabled': {
+ background: 'rgba(0,0,0,0.1)',
+ color: 'rgba(0,0,0,0.4)',
+ boxShadow: 'none',
+ transform: 'none',
+ }
+ }}
>
Launch Alwrity & Complete Setup
-
+
{/* Help Text */}
diff --git a/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx
index cd563b47..d977c59a 100644
--- a/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx
+++ b/frontend/src/components/OnboardingWizard/FinalStep/components/SetupSummary.tsx
@@ -74,12 +74,26 @@ export const SetupSummary: React.FC = ({
size="small"
icon={}
/>
-
+ {/* Only show missing chip if there are actually missing items */}
+ {(() => {
+ const missingCount = capabilities.length - unlockedCapabilities.length;
+ return missingCount > 0 ? (
+
+ ) : (
+ }
+ />
+ );
+ })()}
diff --git a/frontend/src/components/OnboardingWizard/FinalStep/types.ts b/frontend/src/components/OnboardingWizard/FinalStep/types.ts
index de7ea2dd..0fa84dd2 100644
--- a/frontend/src/components/OnboardingWizard/FinalStep/types.ts
+++ b/frontend/src/components/OnboardingWizard/FinalStep/types.ts
@@ -5,6 +5,7 @@ export interface OnboardingData {
personalizationSettings?: any;
integrations?: any;
styleAnalysis?: any;
+ personaReadiness?: any;
}
export interface Capability {
diff --git a/frontend/src/components/OnboardingWizard/Wizard.tsx b/frontend/src/components/OnboardingWizard/Wizard.tsx
index 6fc302af..b887c046 100644
--- a/frontend/src/components/OnboardingWizard/Wizard.tsx
+++ b/frontend/src/components/OnboardingWizard/Wizard.tsx
@@ -324,6 +324,7 @@ const Wizard: React.FC = ({ onComplete }) => {
});
} catch (error) {
console.error('Error initializing onboarding:', error);
+ // Error handling is managed by global API client interceptors
} finally {
setLoading(false);
}
@@ -335,6 +336,7 @@ const Wizard: React.FC = ({ onComplete }) => {
const handleNext = useCallback(async (rawStepData?: any) => {
console.log('Wizard: handleNext called');
console.log('Wizard: Current step:', activeStep);
+ console.log('Wizard: Raw step data:', rawStepData);
console.log('Wizard: Step data:', stepDataRef.current);
console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current);
console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current);
@@ -351,6 +353,8 @@ const Wizard: React.FC = ({ onComplete }) => {
let currentStepData = rawStepData && typeof rawStepData === 'object' && 'nativeEvent' in rawStepData
? undefined
: rawStepData;
+
+ console.log('Wizard: Processed currentStepData:', currentStepData);
// Special handling for CompetitorAnalysisStep (step 2)
if (activeStep === 2) {
@@ -416,6 +420,9 @@ const Wizard: React.FC = ({ onComplete }) => {
// Special handling for PersonaStep (step 3)
if (activeStep === 3) {
console.log('Wizard: Handling PersonaStep data...');
+ console.log('Wizard: currentStepData for PersonaStep:', currentStepData);
+ console.log('Wizard: currentStepData has corePersona:', !!currentStepData?.corePersona);
+ console.log('Wizard: currentStepData has qualityMetrics:', !!currentStepData?.qualityMetrics);
// If we have data from onContinue, use it
if (currentStepData && currentStepData.corePersona && currentStepData.qualityMetrics) {
@@ -442,6 +449,8 @@ const Wizard: React.FC = ({ onComplete }) => {
currentStepData = currentData;
} else {
console.warn('Wizard: No valid persona data available for PersonaStep - cannot complete step');
+ console.log('Wizard: Current step data:', currentStepData);
+ console.log('Wizard: Step data ref:', currentData);
// Don't try to complete the step if we don't have valid persona data
console.log('Wizard: Aborting step completion - missing valid persona data');
setLoading(false);
@@ -694,15 +703,17 @@ const Wizard: React.FC = ({ onComplete }) => {
- {/* Navigation */}
-
+ {/* Navigation - Hide on final step */}
+ {activeStep !== steps.length - 1 && (
+
+ )}
);
diff --git a/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx b/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx
index 88abcc4d..2f2f3c39 100644
--- a/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx
+++ b/frontend/src/components/OnboardingWizard/common/WizardNavigation.tsx
@@ -73,39 +73,41 @@ export const WizardNavigation: React.FC = ({
)}
-
-
- : }
- sx={{
- borderRadius: 2,
- textTransform: 'none',
- fontWeight: 600,
- background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
- boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
- '&:hover': {
- background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
- transform: 'translateY(-1px)',
- boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
- },
- '&:disabled': {
- background: 'rgba(0,0,0,0.1)',
- color: 'rgba(0,0,0,0.4)',
- boxShadow: 'none',
- transform: 'none',
- }
- }}
- >
- {isLastStep ? 'Complete Setup' : 'Continue'}
-
-
-
+ {!isLastStep && (
+
+
+ }
+ sx={{
+ borderRadius: 2,
+ textTransform: 'none',
+ fontWeight: 600,
+ background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
+ '&:hover': {
+ background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
+ transform: 'translateY(-1px)',
+ boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
+ },
+ '&:disabled': {
+ background: 'rgba(0,0,0,0.1)',
+ color: 'rgba(0,0,0,0.4)',
+ boxShadow: 'none',
+ transform: 'none',
+ }
+ }}
+ >
+ Continue
+
+
+
+ )}
);
};
diff --git a/frontend/src/components/SubscriptionExpiredModal.tsx b/frontend/src/components/SubscriptionExpiredModal.tsx
new file mode 100644
index 00000000..baf49031
--- /dev/null
+++ b/frontend/src/components/SubscriptionExpiredModal.tsx
@@ -0,0 +1,221 @@
+import React from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Typography,
+ Box,
+ Alert,
+ Paper
+} from '@mui/material';
+import {
+ CreditCard,
+ Warning,
+ ArrowForward
+} from '@mui/icons-material';
+
+interface SubscriptionExpiredModalProps {
+ open: boolean;
+ onClose: () => void;
+ onRenewSubscription: () => void;
+ subscriptionData?: {
+ plan?: string;
+ tier?: string;
+ limits?: any;
+ } | null;
+ errorData?: {
+ provider?: string;
+ usage_info?: any;
+ message?: string;
+ } | null;
+}
+
+const SubscriptionExpiredModal: React.FC = ({
+ open,
+ onClose,
+ onRenewSubscription,
+ subscriptionData,
+ errorData
+}) => {
+ const handleRenewClick = () => {
+ onRenewSubscription();
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+export default SubscriptionExpiredModal;
diff --git a/frontend/src/components/shared/ProtectedRoute.tsx b/frontend/src/components/shared/ProtectedRoute.tsx
index 3992467b..f560f1a3 100644
--- a/frontend/src/components/shared/ProtectedRoute.tsx
+++ b/frontend/src/components/shared/ProtectedRoute.tsx
@@ -10,7 +10,7 @@ interface ProtectedRouteProps {
}
const ProtectedRoute: React.FC = ({ children }) => {
- const { isSignedIn } = useAuth();
+ const { isLoaded, isSignedIn } = useAuth();
// Use onboarding context instead of making API calls
const {
@@ -21,8 +21,23 @@ const ProtectedRoute: React.FC = ({ children }) => {
clearError
} = useOnboarding();
- // Loading state - show spinner
- if (loading) {
+ // Local fallback (in case context hasn't refreshed yet right after completion)
+ const localComplete = (() => {
+ try { return localStorage.getItem('onboarding_complete') === 'true'; } catch { return false; }
+ })();
+ const allowAccess = isOnboardingComplete || localComplete;
+
+ // Wait for Clerk to load before any redirect decisions
+ if (!isLoaded) {
+ return (
+
+
+
+ );
+ }
+
+ // Loading state from context - show spinner unless local flag says complete
+ if (loading && !localComplete) {
console.log('ProtectedRoute: Loading onboarding state from context...');
return (
= ({ children }) => {
);
}
- // Error state - show error with retry
- if (error) {
+ // Error state - show error with retry (unless local flag allows pass-through)
+ if (error && !localComplete) {
console.error('ProtectedRoute: Error from context:', error);
return (
= ({ children }) => {
}
// Not signed in - redirect to landing
- if (!isSignedIn) {
+ if (isLoaded && !isSignedIn) {
console.log('ProtectedRoute: Not signed in, redirecting to landing');
return ;
}
// Onboarding not complete - redirect to onboarding
- if (!isOnboardingComplete) {
- console.log('ProtectedRoute: Onboarding not complete (from context), redirecting');
+ if (!allowAccess) {
+ console.log('ProtectedRoute: Onboarding not complete (context/local), redirecting');
return ;
}
// All checks passed - render protected component
- console.log('ProtectedRoute: Access granted (from context), rendering component');
+ console.log('ProtectedRoute: Access granted (context/local), rendering component');
return <>{children}>;
};
diff --git a/frontend/src/contexts/SubscriptionContext.tsx b/frontend/src/contexts/SubscriptionContext.tsx
index c77637c9..e376d63e 100644
--- a/frontend/src/contexts/SubscriptionContext.tsx
+++ b/frontend/src/contexts/SubscriptionContext.tsx
@@ -1,5 +1,6 @@
-import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
-import { apiClient } from '../api/client';
+import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
+import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client';
+import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
export interface SubscriptionLimits {
gemini_calls: number;
@@ -29,6 +30,8 @@ interface SubscriptionContextType {
error: string | null;
checkSubscription: () => Promise;
refreshSubscription: () => Promise;
+ showExpiredModal: () => void;
+ hideExpiredModal: () => void;
}
const SubscriptionContext = createContext(undefined);
@@ -49,8 +52,26 @@ export const SubscriptionProvider: React.FC = ({ chil
const [subscription, setSubscription] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
+ const [showModal, setShowModal] = useState(false);
+ const [modalErrorData, setModalErrorData] = useState(null);
+ const [lastModalShowTime, setLastModalShowTime] = useState(0);
+ const [deferredError, setDeferredError] = useState(null);
+ const [lastCheckTime, setLastCheckTime] = useState(0);
+ // New: Grace window after plan changes to avoid noisy UX
+ const [graceUntil, setGraceUntil] = useState(0);
+ const [planSignature, setPlanSignature] = useState("");
- const checkSubscription = async () => {
+ 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;
+ }
+
+ setLastCheckTime(now);
setLoading(true);
setError(null);
@@ -71,6 +92,70 @@ export const SubscriptionProvider: React.FC = ({ chil
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
setSubscription(subscriptionData);
+
+ // 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
+ if (showModal) {
+ setShowModal(false);
+ setModalErrorData(null);
+ }
+ }
+ } catch (_e) {}
+
+ // If we have a valid subscription and the modal is open, close it
+ if (subscriptionData && subscriptionData.active && showModal) {
+ console.log('SubscriptionContext: Valid subscription detected, closing modal');
+ setShowModal(false);
+ setModalErrorData(null);
+ setLastModalShowTime(0); // Reset the cooldown timer
+ }
+
+ // Also check if this is a usage limit error that should be suppressed
+ if (subscriptionData && subscriptionData.active && modalErrorData) {
+ const now = Date.now();
+ const timeSinceLastModal = now - lastModalShowTime;
+
+ // If it's been less than 10 minutes since modal was shown for usage limits, keep it closed
+ if (timeSinceLastModal < 600000 && modalErrorData.usage_info) {
+ console.log('SubscriptionContext: Recent usage limit modal, keeping it closed');
+ }
+ }
+
+ // 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 || {};
+ setModalErrorData({
+ provider: errorData.provider,
+ usage_info: errorData.usage_info,
+ message: errorData.message || errorData.error
+ });
+ setShowModal(true);
+ setLastModalShowTime(now);
+ }
+ }
} catch (err) {
console.error('Error checking subscription:', err);
@@ -84,15 +169,76 @@ export const SubscriptionProvider: React.FC = ({ chil
// 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:', subscription);
+ console.warn('Subscription check failed, preserving existing data');
} finally {
setLoading(false);
}
- };
+ }, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil]);
- const refreshSubscription = async () => {
+ const refreshSubscription = useCallback(async () => {
await checkSubscription();
- };
+ }, [checkSubscription]);
+
+ const showExpiredModal = useCallback(() => {
+ setShowModal(true);
+ }, []);
+
+ const hideExpiredModal = useCallback(() => {
+ setShowModal(false);
+ }, []);
+
+ const handleRenewSubscription = useCallback(() => {
+ window.location.href = '/pricing';
+ }, []);
+
+ // Global subscription error handler for API client
+ const globalSubscriptionErrorHandler = useCallback((error: any) => {
+ console.log('SubscriptionContext: Global error handler triggered', error);
+
+ // Check if it's a subscription-related error
+ const status = error.response?.status;
+
+ if (status === 429 || status === 402) {
+ console.log('SubscriptionContext: Subscription error detected');
+
+ const now = Date.now();
+
+ // If we have subscription data and it's active, always suppress modal for usage limits
+ if (subscription && subscription.active) {
+ console.log('SubscriptionContext: Active subscription; suppressing usage-limit modal');
+ return true; // Do not show modal for active plan usage limits
+ }
+
+ // If we don't have subscription data yet, defer the decision
+ if (!subscription) {
+ console.log('SubscriptionContext: No subscription data yet, deferring modal decision');
+ 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: Inactive subscription, showing modal immediately');
+ const errorData = error.response?.data || {};
+ setModalErrorData({
+ provider: errorData.provider,
+ usage_info: errorData.usage_info,
+ message: errorData.message || errorData.error
+ });
+ setShowModal(true);
+ setLastModalShowTime(now);
+ return true;
+ }
+ }
+
+ return false; // Not a subscription error
+ }, [subscription]);
+
+ // Register the global error handler with the API client
+ useEffect(() => {
+ console.log('SubscriptionContext: Registering global subscription error handler');
+ setGlobalSubscriptionErrorHandler(globalSubscriptionErrorHandler);
+ }, [globalSubscriptionErrorHandler]);
useEffect(() => {
// Check subscription on mount
@@ -121,7 +267,7 @@ export const SubscriptionProvider: React.FC = ({ chil
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
window.removeEventListener('user-authenticated', handleUserAuth);
};
- }, []);
+ }, []); // Remove checkSubscription dependency to prevent loop
const value: SubscriptionContextType = {
subscription,
@@ -129,11 +275,20 @@ export const SubscriptionProvider: React.FC = ({ chil
error,
checkSubscription,
refreshSubscription,
+ showExpiredModal,
+ hideExpiredModal,
};
return (
{children}
+
);
};
diff --git a/frontend/src/hooks/useSubscriptionErrorHandler.ts b/frontend/src/hooks/useSubscriptionErrorHandler.ts
new file mode 100644
index 00000000..6c8e276a
--- /dev/null
+++ b/frontend/src/hooks/useSubscriptionErrorHandler.ts
@@ -0,0 +1,39 @@
+import { useCallback } from 'react';
+import { useSubscription } from '../contexts/SubscriptionContext';
+
+interface ApiError {
+ response?: {
+ status?: number;
+ data?: any;
+ };
+ message?: string;
+}
+
+export const useSubscriptionErrorHandler = () => {
+ const { subscription, showExpiredModal } = useSubscription();
+
+ const handleApiError = useCallback((error: ApiError) => {
+ // Check if it's a subscription-related error
+ const status = error.response?.status;
+
+ if (status === 429 || status === 402) {
+ console.log('Subscription error detected, letting global handler manage modal');
+
+ // Don't show modal directly - let the global API client handler manage it
+ // This ensures proper subscription state checking and modal spam prevention
+ return true; // Indicates subscription error was detected
+ }
+
+ return false; // Not a subscription error
+ }, [subscription]);
+
+ const handleSubscriptionExpired = useCallback(() => {
+ console.log('Manually triggering subscription expired modal');
+ showExpiredModal();
+ }, [showExpiredModal]);
+
+ return {
+ handleApiError,
+ handleSubscriptionExpired
+ };
+};
diff --git a/frontend/src/services/billingService.ts b/frontend/src/services/billingService.ts
index c3ee5737..7d305bd6 100644
--- a/frontend/src/services/billingService.ts
+++ b/frontend/src/services/billingService.ts
@@ -124,29 +124,83 @@ const defaultLimits = {
features: [],
};
+// Helper to coerce alerts into fully-typed objects expected by Zod
+function coerceAlerts(rawAlerts: any[]): UsageAlert[] {
+ if (!Array.isArray(rawAlerts)) return [];
+ const nowIso = new Date().toISOString();
+ return rawAlerts.map((a: any, idx: number) => ({
+ id: typeof a?.id === 'number' ? a.id : idx,
+ type: typeof a?.type === 'string' ? a.type : 'usage',
+ threshold_percentage: typeof a?.threshold_percentage === 'number' ? a.threshold_percentage : 0,
+ provider: typeof a?.provider === 'string' ? a.provider : undefined,
+ title: typeof a?.title === 'string' ? a.title : 'Usage alert',
+ message: typeof a?.message === 'string' ? a.message : '',
+ severity: a?.severity === 'warning' || a?.severity === 'error' || a?.severity === 'info' ? a.severity : 'info',
+ is_sent: typeof a?.is_sent === 'boolean' ? a.is_sent : true,
+ sent_at: typeof a?.sent_at === 'string' ? a.sent_at : nowIso,
+ is_read: typeof a?.is_read === 'boolean' ? a.is_read : false,
+ read_at: typeof a?.read_at === 'string' ? a.read_at : undefined,
+ billing_period: typeof a?.billing_period === 'string' ? a.billing_period : (a?.period || ''),
+ created_at: typeof a?.created_at === 'string' ? a.created_at : nowIso,
+ }));
+}
+
function coerceUsageStats(raw: any): UsageStats {
+ const providerBreakdown = raw?.provider_breakdown || {};
+ const defaultLimits = {
+ plan_name: raw?.limits?.plan_name ?? 'free',
+ tier: raw?.limits?.tier ?? 'free',
+ limits: {
+ gemini_calls: raw?.limits?.limits?.gemini_calls ?? 0,
+ openai_calls: raw?.limits?.limits?.openai_calls ?? 0,
+ anthropic_calls: raw?.limits?.limits?.anthropic_calls ?? 0,
+ mistral_calls: raw?.limits?.limits?.mistral_calls ?? 0,
+ tavily_calls: raw?.limits?.limits?.tavily_calls ?? 0,
+ serper_calls: raw?.limits?.limits?.serper_calls ?? 0,
+ metaphor_calls: raw?.limits?.limits?.metaphor_calls ?? 0,
+ firecrawl_calls: raw?.limits?.limits?.firecrawl_calls ?? 0,
+ stability_calls: raw?.limits?.limits?.stability_calls ?? 0,
+ gemini_tokens: raw?.limits?.limits?.gemini_tokens ?? 0,
+ openai_tokens: raw?.limits?.limits?.openai_tokens ?? 0,
+ anthropic_tokens: raw?.limits?.limits?.anthropic_tokens ?? 0,
+ mistral_tokens: raw?.limits?.limits?.mistral_tokens ?? 0,
+ monthly_cost: raw?.limits?.limits?.monthly_cost ?? 0,
+ },
+ features: raw?.limits?.features ?? [],
+ };
+
const coerced: UsageStats = {
- billing_period: raw?.billing_period ?? 'unknown',
- usage_status: (raw?.usage_status ?? 'active') as UsageStats['usage_status'],
- total_calls: Number(raw?.total_calls ?? 0),
- total_tokens: Number(raw?.total_tokens ?? 0),
- total_cost: Number(raw?.total_cost ?? 0),
- avg_response_time: Number(raw?.avg_response_time ?? 0),
- error_rate: Number(raw?.error_rate ?? 0),
- limits: raw?.limits ?? defaultLimits,
- provider_breakdown: raw?.provider_breakdown ?? defaultProviderBreakdown,
- alerts: Array.isArray(raw?.alerts) ? raw.alerts : [],
- usage_percentages: raw?.usage_percentages ?? {
- gemini_calls: 0,
- openai_calls: 0,
- anthropic_calls: 0,
- mistral_calls: 0,
- tavily_calls: 0,
- serper_calls: 0,
- metaphor_calls: 0,
- firecrawl_calls: 0,
- stability_calls: 0,
- cost: 0,
+ billing_period: raw?.billing_period ?? new Date().toISOString().slice(0,7),
+ usage_status: raw?.usage_status ?? 'active',
+ total_calls: raw?.total_calls ?? 0,
+ total_tokens: raw?.total_tokens ?? 0,
+ total_cost: raw?.total_cost ?? 0,
+ avg_response_time: raw?.avg_response_time ?? 0,
+ error_rate: raw?.error_rate ?? 0,
+ limits: defaultLimits,
+ provider_breakdown: {
+ gemini: providerBreakdown.gemini ?? { calls: 0, tokens: 0, cost: 0 },
+ openai: providerBreakdown.openai ?? { calls: 0, tokens: 0, cost: 0 },
+ anthropic: providerBreakdown.anthropic ?? { calls: 0, tokens: 0, cost: 0 },
+ mistral: providerBreakdown.mistral ?? { calls: 0, tokens: 0, cost: 0 },
+ tavily: providerBreakdown.tavily ?? { calls: 0, tokens: 0, cost: 0 },
+ serper: providerBreakdown.serper ?? { calls: 0, tokens: 0, cost: 0 },
+ metaphor: providerBreakdown.metaphor ?? { calls: 0, tokens: 0, cost: 0 },
+ firecrawl: providerBreakdown.firecrawl ?? { calls: 0, tokens: 0, cost: 0 },
+ stability: providerBreakdown.stability ?? { calls: 0, tokens: 0, cost: 0 },
+ },
+ alerts: coerceAlerts(raw?.alerts),
+ usage_percentages: {
+ gemini_calls: raw?.usage_percentages?.gemini_calls ?? 0,
+ openai_calls: raw?.usage_percentages?.openai_calls ?? 0,
+ anthropic_calls: raw?.usage_percentages?.anthropic_calls ?? 0,
+ mistral_calls: raw?.usage_percentages?.mistral_calls ?? 0,
+ tavily_calls: raw?.usage_percentages?.tavily_calls ?? 0,
+ serper_calls: raw?.usage_percentages?.serper_calls ?? 0,
+ metaphor_calls: raw?.usage_percentages?.metaphor_calls ?? 0,
+ firecrawl_calls: raw?.usage_percentages?.firecrawl_calls ?? 0,
+ stability_calls: raw?.usage_percentages?.stability_calls ?? 0,
+ cost: raw?.usage_percentages?.cost ?? 0,
},
last_updated: raw?.last_updated ?? new Date().toISOString(),
};
@@ -185,7 +239,7 @@ export const billingService = {
provider_trends: {},
},
limits: raw?.limits ?? defaultLimits,
- alerts: Array.isArray(raw?.alerts) ? raw.alerts : [],
+ alerts: coerceAlerts(raw?.alerts),
projections: raw?.projections ?? {
projected_monthly_cost: 0,
cost_limit: 0,