Subscription implementation complete, Renewal system implemented
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -485,4 +485,27 @@ class UsageTrackingService:
|
||||
user_id=user_id,
|
||||
provider=provider,
|
||||
tokens_requested=tokens_requested
|
||||
)
|
||||
)
|
||||
|
||||
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)}
|
||||
@@ -9,7 +9,7 @@
|
||||
name="description"
|
||||
content="Alwrity - AI Content Creation Platform"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>Alwrity - AI Content Creation Platform</title>
|
||||
</head>
|
||||
|
||||
@@ -71,7 +71,7 @@ const InitialRouteHandler: React.FC = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [checkSubscription]);
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// Initialize onboarding only after subscription is confirmed
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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<string | null>) | 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);
|
||||
}
|
||||
|
||||
@@ -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<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
const [dataLoading, setDataLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
|
||||
apiKeys: {}
|
||||
});
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
|
||||
const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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<FinalStepProps> = ({ 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<FinalStepProps> = ({ 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<FinalStepProps> = ({ 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<FinalStepProps> = ({ 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<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
</Zoom>
|
||||
)}
|
||||
|
||||
|
||||
{/* Alerts */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
{error && (
|
||||
@@ -240,13 +409,15 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
severity="error"
|
||||
sx={{ mb: 2, borderRadius: 2 }}
|
||||
action={
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={() => setError(null)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
@@ -260,18 +431,59 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
{/* Validation Status */}
|
||||
{validationStatus && !validationStatus.isValid && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Alert severity="warning" sx={{ borderRadius: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Setup Incomplete
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
The following steps need to be completed before launching:
|
||||
</Typography>
|
||||
<Box component="ul" sx={{ pl: 2, m: 0 }}>
|
||||
{validationStatus.missingSteps.map((step, index) => (
|
||||
<li key={index}>
|
||||
<Typography variant="body2">{step}</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Launch Button */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||
<OnboardingButton
|
||||
variant="primary"
|
||||
onClick={handleLaunch}
|
||||
loading={loading}
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
icon={<Rocket />}
|
||||
disabled={Object.keys(onboardingData.apiKeys).length === 0}
|
||||
disabled={loading || dataLoading}
|
||||
onClick={handleLaunch}
|
||||
startIcon={<Rocket />}
|
||||
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
|
||||
</OnboardingButton>
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Help Text */}
|
||||
|
||||
@@ -74,12 +74,26 @@ export const SetupSummary: React.FC<SetupSummaryProps> = ({
|
||||
size="small"
|
||||
icon={<LockOpen />}
|
||||
/>
|
||||
<Chip
|
||||
label="1 Missing"
|
||||
color="warning"
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
{/* Only show missing chip if there are actually missing items */}
|
||||
{(() => {
|
||||
const missingCount = capabilities.length - unlockedCapabilities.length;
|
||||
return missingCount > 0 ? (
|
||||
<Chip
|
||||
label={`${missingCount} Missing`}
|
||||
color="warning"
|
||||
variant="filled"
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Chip
|
||||
label="All Complete"
|
||||
color="success"
|
||||
variant="filled"
|
||||
size="small"
|
||||
icon={<CheckCircle sx={{ fontSize: 16 }} />}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OnboardingData {
|
||||
personalizationSettings?: any;
|
||||
integrations?: any;
|
||||
styleAnalysis?: any;
|
||||
personaReadiness?: any;
|
||||
}
|
||||
|
||||
export interface Capability {
|
||||
|
||||
@@ -324,6 +324,7 @@ const Wizard: React.FC<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ 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<WizardProps> = ({ onComplete }) => {
|
||||
</Fade>
|
||||
</Box>
|
||||
|
||||
{/* Navigation */}
|
||||
<WizardNavigation
|
||||
activeStep={activeStep}
|
||||
totalSteps={steps.length}
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
isLastStep={activeStep === steps.length - 1}
|
||||
isCurrentStepValid={isCurrentStepValid}
|
||||
/>
|
||||
{/* Navigation - Hide on final step */}
|
||||
{activeStep !== steps.length - 1 && (
|
||||
<WizardNavigation
|
||||
activeStep={activeStep}
|
||||
totalSteps={steps.length}
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
isLastStep={activeStep === steps.length - 1}
|
||||
isCurrentStepValid={isCurrentStepValid}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -73,39 +73,41 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Tooltip
|
||||
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onNext}
|
||||
disabled={isLastStep || !isCurrentStepValid}
|
||||
endIcon={isLastStep ? <CheckCircle /> : <ArrowForward />}
|
||||
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'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{!isLastStep && (
|
||||
<Tooltip
|
||||
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onNext}
|
||||
disabled={!isCurrentStepValid}
|
||||
endIcon={<ArrowForward />}
|
||||
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
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
221
frontend/src/components/SubscriptionExpiredModal.tsx
Normal file
221
frontend/src/components/SubscriptionExpiredModal.tsx
Normal file
@@ -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<SubscriptionExpiredModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onRenewSubscription,
|
||||
subscriptionData,
|
||||
errorData
|
||||
}) => {
|
||||
const handleRenewClick = () => {
|
||||
onRenewSubscription();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<CreditCard sx={{ fontSize: 32, color: 'warning.main' }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
{errorData?.usage_info ? 'Usage Limit Reached' : 'Subscription Expired'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ textAlign: 'center', px: 4, py: 2 }}>
|
||||
<Alert
|
||||
severity="warning"
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 2,
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
border: '1px solid #f59e0b'
|
||||
}}
|
||||
icon={<Warning sx={{ color: '#d97706' }} />}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: '#92400e' }}>
|
||||
{errorData?.usage_info ? 'You\'ve reached your API usage limit' : 'Your subscription has expired'}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mb: 3,
|
||||
background: 'linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%)',
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||
{errorData?.message || (errorData?.usage_info
|
||||
? 'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.'
|
||||
: 'To continue using Alwrity and access all features, you need to renew your subscription.'
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{errorData?.usage_info && (
|
||||
<Box sx={{ mb: 2, p: 2, background: 'rgba(255,255,255,0.7)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: 'text.primary' }}>
|
||||
Usage Information:
|
||||
</Typography>
|
||||
{errorData.usage_info.call_usage_percentage && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
You've used {errorData.usage_info.call_usage_percentage.toFixed(1)}% of your monthly limit
|
||||
</Typography>
|
||||
)}
|
||||
{errorData.provider && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Provider: {errorData.provider}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{subscriptionData && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{subscriptionData.plan && (
|
||||
<Box sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
Current Plan: {subscriptionData.plan}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{subscriptionData.tier && subscriptionData.tier !== subscriptionData.plan && (
|
||||
<Box sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
Tier: {subscriptionData.tier}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1 }}>
|
||||
Renewing your subscription will restore access to:
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, textAlign: 'left', maxWidth: 300, mx: 'auto' }}>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
✓ AI Content Generation
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
✓ Website Analysis
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
✓ Research Tools
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
✓ All Premium Features
|
||||
</Typography>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 4, pb: 4, gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
borderColor: 'rgba(0,0,0,0.2)',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
borderColor: 'rgba(0,0,0,0.4)',
|
||||
background: 'rgba(0,0,0,0.04)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Maybe Later
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleRenewClick}
|
||||
startIcon={<CreditCard />}
|
||||
endIcon={<ArrowForward />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
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)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
Renew Subscription
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionExpiredModal;
|
||||
@@ -10,7 +10,7 @@ interface ProtectedRouteProps {
|
||||
}
|
||||
|
||||
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ 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<ProtectedRouteProps> = ({ 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 (
|
||||
<Box display="flex" alignItems="center" justifyContent="center" minHeight="100vh">
|
||||
<CircularProgress size={60} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state from context - show spinner unless local flag says complete
|
||||
if (loading && !localComplete) {
|
||||
console.log('ProtectedRoute: Loading onboarding state from context...');
|
||||
return (
|
||||
<Box
|
||||
@@ -41,8 +56,8 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ 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 (
|
||||
<Box
|
||||
@@ -84,19 +99,19 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
}
|
||||
|
||||
// Not signed in - redirect to landing
|
||||
if (!isSignedIn) {
|
||||
if (isLoaded && !isSignedIn) {
|
||||
console.log('ProtectedRoute: Not signed in, redirecting to landing');
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
// 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 <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// 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}</>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
refreshSubscription: () => Promise<void>;
|
||||
showExpiredModal: () => void;
|
||||
hideExpiredModal: () => void;
|
||||
}
|
||||
|
||||
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
|
||||
@@ -49,8 +52,26 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
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>("");
|
||||
|
||||
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<SubscriptionProviderProps> = ({ 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<SubscriptionProviderProps> = ({ 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<SubscriptionProviderProps> = ({ 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<SubscriptionProviderProps> = ({ chil
|
||||
error,
|
||||
checkSubscription,
|
||||
refreshSubscription,
|
||||
showExpiredModal,
|
||||
hideExpiredModal,
|
||||
};
|
||||
|
||||
return (
|
||||
<SubscriptionContext.Provider value={value}>
|
||||
{children}
|
||||
<SubscriptionExpiredModal
|
||||
open={showModal}
|
||||
onClose={hideExpiredModal}
|
||||
onRenewSubscription={handleRenewSubscription}
|
||||
subscriptionData={subscription}
|
||||
errorData={modalErrorData}
|
||||
/>
|
||||
</SubscriptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
39
frontend/src/hooks/useSubscriptionErrorHandler.ts
Normal file
39
frontend/src/hooks/useSubscriptionErrorHandler.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user