Subscription implementation complete, Renewal system implemented
This commit is contained in:
@@ -30,12 +30,16 @@ class RateLimiter:
|
|||||||
"/calendar-events",
|
"/calendar-events",
|
||||||
"/calendar-generation/progress",
|
"/calendar-generation/progress",
|
||||||
"/health",
|
"/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:
|
def is_exempt_path(self, path: str) -> bool:
|
||||||
"""Check if a path is exempt from rate limiting."""
|
"""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:
|
def clean_old_requests(self, client_ip: str, current_time: float) -> None:
|
||||||
"""Clean old requests from the tracking dictionary."""
|
"""Clean old requests from the tracking dictionary."""
|
||||||
@@ -77,7 +81,6 @@ class RateLimiter:
|
|||||||
|
|
||||||
# Check if path is exempt from rate limiting
|
# Check if path is exempt from rate limiting
|
||||||
if self.is_exempt_path(path):
|
if self.is_exempt_path(path):
|
||||||
# Allow streaming endpoints without rate limiting
|
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class OnboardingCompletionService:
|
|||||||
"""Service for handling onboarding completion logic."""
|
"""Service for handling onboarding completion logic."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Only pre-requisite steps; step 6 is the finalization itself
|
# Pre-requisite steps; step 6 is the finalization itself
|
||||||
self.required_steps = [1, 2, 3]
|
self.required_steps = [1, 2, 3, 4, 5]
|
||||||
|
|
||||||
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
async def complete_onboarding(self, current_user: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Complete the onboarding process with full validation."""
|
"""Complete the onboarding process with full validation."""
|
||||||
@@ -73,9 +73,15 @@ class OnboardingCompletionService:
|
|||||||
db = None
|
db = None
|
||||||
db_service = 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:
|
for step_num in self.required_steps:
|
||||||
step = progress.get_step_data(step_num)
|
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]:
|
if step and step.status in [StepStatus.COMPLETED, StepStatus.SKIPPED]:
|
||||||
|
logger.info(f"OnboardingCompletionService: Step {step_num} already completed/skipped")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# DB-aware fallbacks for migration period
|
# DB-aware fallbacks for migration period
|
||||||
@@ -129,6 +135,30 @@ class OnboardingCompletionService:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
continue
|
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:
|
except Exception:
|
||||||
# If DB check fails, fall back to progress status only
|
# If DB check fails, fall back to progress status only
|
||||||
pass
|
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 {}
|
"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}")
|
logger.info(f"Cache hit for user {user_id} - returning completed task without regeneration: {task_id}")
|
||||||
return {
|
return {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"message": "Persona loaded from cache"
|
"message": "Persona loaded from cache"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Generate unique task ID
|
# Generate unique task ID
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
|||||||
@@ -380,6 +380,13 @@ async def subscribe_to_plan(
|
|||||||
|
|
||||||
db.commit()
|
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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"message": f"Successfully subscribed to {plan.name}",
|
"message": f"Successfully subscribed to {plan.name}",
|
||||||
|
|||||||
@@ -35,19 +35,15 @@ class DatabaseAPIMonitor:
|
|||||||
# API provider detection patterns - Updated to match actual endpoints
|
# API provider detection patterns - Updated to match actual endpoints
|
||||||
self.provider_patterns = {
|
self.provider_patterns = {
|
||||||
APIProvider.GEMINI: [
|
APIProvider.GEMINI: [
|
||||||
r'/api/blog-writer', r'/api/content-planning', r'/api/strategy-copilot',
|
r'gemini', r'google.*ai'
|
||||||
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'
|
|
||||||
],
|
],
|
||||||
APIProvider.OPENAI: [r'/openai', r'openai', r'gpt', r'chatgpt'],
|
APIProvider.OPENAI: [r'openai', r'gpt', r'chatgpt'],
|
||||||
APIProvider.ANTHROPIC: [r'/anthropic', r'claude', r'anthropic'],
|
APIProvider.ANTHROPIC: [r'anthropic', r'claude'],
|
||||||
APIProvider.MISTRAL: [r'/mistral', r'mistral'],
|
APIProvider.MISTRAL: [r'mistral'],
|
||||||
APIProvider.TAVILY: [r'/tavily', r'tavily', r'research', r'search'],
|
APIProvider.TAVILY: [r'tavily'],
|
||||||
APIProvider.SERPER: [r'/serper', r'serper', r'google.*search', r'seo'],
|
APIProvider.SERPER: [r'serper'],
|
||||||
APIProvider.METAPHOR: [r'/metaphor', r'/exa', r'metaphor', r'exa'],
|
APIProvider.METAPHOR: [r'metaphor', r'/exa'],
|
||||||
APIProvider.FIRECRAWL: [r'/firecrawl', r'firecrawl', r'crawl'],
|
APIProvider.FIRECRAWL: [r'firecrawl']
|
||||||
APIProvider.STABILITY: [r'/stability', r'stable.*diffusion', r'stability', r'image.*generation']
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def detect_api_provider(self, path: str, user_agent: str = None) -> Optional[APIProvider]:
|
def detect_api_provider(self, path: str, user_agent: str = None) -> Optional[APIProvider]:
|
||||||
@@ -55,6 +51,10 @@ class DatabaseAPIMonitor:
|
|||||||
path_lower = path.lower()
|
path_lower = path.lower()
|
||||||
user_agent_lower = (user_agent or '').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 provider, patterns in self.provider_patterns.items():
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
if re.search(pattern, path_lower) or re.search(pattern, user_agent_lower):
|
if re.search(pattern, path_lower) or re.search(pattern, user_agent_lower):
|
||||||
@@ -384,16 +384,26 @@ EXCLUDED_ENDPOINTS = [
|
|||||||
"/api/content-planning/monitoring/cache-stats",
|
"/api/content-planning/monitoring/cache-stats",
|
||||||
"/api/content-planning/monitoring/health"
|
"/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:
|
def should_monitor_endpoint(path: str) -> bool:
|
||||||
"""Check if an endpoint should be monitored."""
|
"""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]:
|
async def check_usage_limits_middleware(request: Request, user_id: str, request_body: str = None) -> Optional[JSONResponse]:
|
||||||
"""Check usage limits before processing request."""
|
"""Check usage limits before processing request."""
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# No special whitelist; onboarding/subscription are ignored by provider detection
|
||||||
|
try:
|
||||||
|
path = request.url.path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db = next(get_db())
|
db = next(get_db())
|
||||||
api_monitor = DatabaseAPIMonitor()
|
api_monitor = DatabaseAPIMonitor()
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ class BlogSEOMetadataGenerator:
|
|||||||
|
|
||||||
# Get structured response from Gemini
|
# Get structured response from Gemini
|
||||||
ai_response = self.gemini_provider(
|
ai_response = self.gemini_provider(
|
||||||
prompt=prompt,
|
prompt,
|
||||||
schema=schema,
|
schema,
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=2048
|
max_tokens=2048
|
||||||
)
|
)
|
||||||
@@ -167,6 +167,8 @@ class BlogSEOMetadataGenerator:
|
|||||||
if not ai_response or not isinstance(ai_response, dict):
|
if not ai_response or not isinstance(ai_response, dict):
|
||||||
logger.error("Core metadata generation failed: Invalid response from Gemini")
|
logger.error("Core metadata generation failed: Invalid response from Gemini")
|
||||||
# Return fallback response
|
# Return fallback response
|
||||||
|
primary_keywords = ', '.join(keywords_data.get('primary_keywords', ['content']))
|
||||||
|
word_count = len(blog_content.split())
|
||||||
return {
|
return {
|
||||||
'seo_title': blog_title,
|
'seo_title': blog_title,
|
||||||
'meta_description': f'Learn about {primary_keywords.split(", ")[0] if primary_keywords else "this topic"}.',
|
'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
|
# Get structured response from Gemini
|
||||||
ai_response = self.gemini_provider(
|
ai_response = self.gemini_provider(
|
||||||
prompt=prompt,
|
prompt,
|
||||||
schema=schema,
|
schema,
|
||||||
temperature=0.3,
|
temperature=0.3,
|
||||||
max_tokens=2048
|
max_tokens=2048
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -348,6 +348,11 @@ def gemini_structured_json_response(prompt, schema, temperature=0.7, top_p=0.9,
|
|||||||
try:
|
try:
|
||||||
# Get API key with proper error handling
|
# Get API key with proper error handling
|
||||||
api_key = get_gemini_api_key()
|
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)
|
client = genai.Client(api_key=api_key)
|
||||||
logger.info("✅ Gemini client initialized for structured JSON response")
|
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,
|
system_instruction=system_prompt,
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.models.generate_content(
|
logger.info("🚀 Making Gemini API call...")
|
||||||
model="gemini-2.5-flash",
|
try:
|
||||||
contents=prompt,
|
response = client.models.generate_content(
|
||||||
config=generation_config,
|
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)
|
# Check for parsed content first (primary method for structured output)
|
||||||
if hasattr(response, 'parsed'):
|
if hasattr(response, 'parsed'):
|
||||||
|
|||||||
@@ -486,3 +486,26 @@ class UsageTrackingService:
|
|||||||
provider=provider,
|
provider=provider,
|
||||||
tokens_requested=tokens_requested
|
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"
|
name="description"
|
||||||
content="Alwrity - AI Content Creation Platform"
|
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" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<title>Alwrity - AI Content Creation Platform</title>
|
<title>Alwrity - AI Content Creation Platform</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const InitialRouteHandler: React.FC = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [checkSubscription]);
|
}, []); // Remove checkSubscription dependency to prevent loop
|
||||||
|
|
||||||
// Initialize onboarding only after subscription is confirmed
|
// Initialize onboarding only after subscription is confirmed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import axios from 'axios';
|
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
|
// Optional token getter installed from within the app after Clerk is available
|
||||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
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);
|
console.error('API Error:', error.response?.status, error.response?.data);
|
||||||
return Promise.reject(error);
|
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);
|
console.error('AI API Error:', error.response?.status, error.response?.data);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -232,6 +263,18 @@ longRunningApiClient.interceptors.response.use(
|
|||||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
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);
|
console.error('Long-running API Error:', error.response?.status, error.response?.data);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
@@ -270,6 +313,18 @@ pollingApiClient.interceptors.response.use(
|
|||||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
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);
|
console.error('Polling API Error:', error.response?.status, error.response?.data);
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
@@ -12,7 +12,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Rocket,
|
Rocket,
|
||||||
Star,
|
Star,
|
||||||
CheckCircle
|
CheckCircle,
|
||||||
|
CreditCard,
|
||||||
|
Warning
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import OnboardingButton from '../common/OnboardingButton';
|
import OnboardingButton from '../common/OnboardingButton';
|
||||||
import { getApiKeys, completeOnboarding, getOnboardingSummary, getWebsiteAnalysisData, getResearchPreferencesData, setCurrentStep } from '../../../api/onboarding';
|
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 FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [dataLoading, setDataLoading] = useState(true);
|
const [dataLoading, setDataLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
|
const [onboardingData, setOnboardingData] = useState<OnboardingData>({
|
||||||
apiKeys: {}
|
apiKeys: {}
|
||||||
});
|
});
|
||||||
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
|
const [expandedSection, setExpandedSection] = useState<string | null>('summary');
|
||||||
|
const [validationStatus, setValidationStatus] = useState<{isValid: boolean, missingSteps: string[]} | null>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateHeaderContent({
|
updateHeaderContent({
|
||||||
title: 'Review & Launch Alwrity 🚀',
|
title: 'Review & Launch Alwrity 🚀',
|
||||||
description: 'Review your configuration and confirm all settings before launching your AI-powered content creation workspace.'
|
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();
|
loadOnboardingData();
|
||||||
}, [updateHeaderContent]);
|
}, [updateHeaderContent]);
|
||||||
|
|
||||||
|
// Remove the DOM manipulation approach - we'll use React's built-in event handling
|
||||||
|
|
||||||
const loadOnboardingData = async () => {
|
const loadOnboardingData = async () => {
|
||||||
|
// Prevent multiple simultaneous data loading calls
|
||||||
|
if (dataLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setDataLoading(true);
|
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 {
|
try {
|
||||||
// Load comprehensive onboarding summary
|
// Load comprehensive onboarding summary
|
||||||
const summary = await getOnboardingSummary();
|
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 cachedAnalysisRaw = typeof window !== 'undefined' ? localStorage.getItem('website_analysis_data') : null;
|
||||||
const cachedAnalysis = cachedAnalysisRaw ? safeParseJSON(cachedAnalysisRaw) : undefined;
|
const cachedAnalysis = cachedAnalysisRaw ? safeParseJSON(cachedAnalysisRaw) : undefined;
|
||||||
|
|
||||||
setOnboardingData({
|
const newOnboardingData = {
|
||||||
apiKeys: summary.api_keys || {},
|
apiKeys: summary.api_keys || {},
|
||||||
websiteUrl: websiteAnalysis?.website_url || summary.website_url || cachedUrl || undefined,
|
websiteUrl: websiteAnalysis?.website_url || summary.website_url || cachedUrl || undefined,
|
||||||
researchPreferences: researchPreferences || summary.research_preferences,
|
researchPreferences: researchPreferences || summary.research_preferences,
|
||||||
personalizationSettings: summary.personalization_settings,
|
personalizationSettings: summary.personalization_settings,
|
||||||
integrations: summary.integrations || {},
|
integrations: summary.integrations || {},
|
||||||
styleAnalysis: websiteAnalysis?.style_analysis || summary.style_analysis || cachedAnalysis || undefined
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading onboarding data:', 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
|
// Fallback to just API keys if other endpoints fail
|
||||||
try {
|
try {
|
||||||
const apiKeys = await getApiKeys();
|
const apiKeys = await getApiKeys();
|
||||||
@@ -73,9 +102,11 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
});
|
});
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('Error loading API keys as fallback:', fallbackError);
|
console.error('Error loading API keys as fallback:', fallbackError);
|
||||||
|
// Error handling is managed by global API client interceptors
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
|
clearTimeout(loadingTimeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,34 +116,168 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
try { return JSON.parse(raw); } catch { return undefined; }
|
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 () => {
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
console.log('FinalStep: Starting onboarding completion...');
|
console.log('FinalStep: Starting onboarding completion...');
|
||||||
|
|
||||||
// First, complete step 6 (Final Step) to mark it as completed
|
// First, validate that all required steps are completed
|
||||||
console.log('FinalStep: Completing step 6...');
|
console.log('FinalStep: Validating all required steps...');
|
||||||
await setCurrentStep(6);
|
const validationResult = await validateOnboardingCompletion();
|
||||||
console.log('FinalStep: Step 6 completed successfully');
|
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...');
|
console.log('FinalStep: Completing onboarding...');
|
||||||
await completeOnboarding();
|
console.log('FinalStep: Calling completeOnboarding()...');
|
||||||
console.log('FinalStep: Onboarding completed successfully');
|
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
|
// Navigate directly to dashboard without calling onContinue
|
||||||
// This bypasses the wizard flow and goes straight to the dashboard
|
// This bypasses the wizard flow and goes straight to the dashboard
|
||||||
console.log('FinalStep: Navigating to 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) {
|
} catch (e: any) {
|
||||||
console.error('FinalStep: Error completing onboarding:', e);
|
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
|
// Provide more specific error messages
|
||||||
let errorMessage = 'Failed to complete onboarding. Please try again.';
|
let errorMessage = 'Failed to complete onboarding. Please try again.';
|
||||||
|
|
||||||
if (e.response?.data?.detail) {
|
if (e.response?.data?.detail) {
|
||||||
errorMessage = e.response.data.detail;
|
errorMessage = e.response.data.detail;
|
||||||
|
} else if (e.response?.data?.message) {
|
||||||
|
errorMessage = e.response.data.message;
|
||||||
} else if (e.message) {
|
} else if (e.message) {
|
||||||
errorMessage = e.message;
|
errorMessage = e.message;
|
||||||
}
|
}
|
||||||
@@ -122,6 +287,9 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to compute steps length for storing active step (fallback value)
|
||||||
|
const stepsLengthFallback = () => 6;
|
||||||
|
|
||||||
const capabilities: Capability[] = [
|
const capabilities: Capability[] = [
|
||||||
{
|
{
|
||||||
id: 'ai-content',
|
id: 'ai-content',
|
||||||
@@ -232,6 +400,7 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
</Zoom>
|
</Zoom>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Alerts */}
|
{/* Alerts */}
|
||||||
<Box sx={{ mt: 3 }}>
|
<Box sx={{ mt: 3 }}>
|
||||||
{error && (
|
{error && (
|
||||||
@@ -240,13 +409,15 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
severity="error"
|
severity="error"
|
||||||
sx={{ mb: 2, borderRadius: 2 }}
|
sx={{ mb: 2, borderRadius: 2 }}
|
||||||
action={
|
action={
|
||||||
<Button
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
color="inherit"
|
<Button
|
||||||
size="small"
|
color="inherit"
|
||||||
onClick={() => setError(null)}
|
size="small"
|
||||||
>
|
onClick={() => setError(null)}
|
||||||
Dismiss
|
>
|
||||||
</Button>
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
@@ -260,18 +431,59 @@ const FinalStep: React.FC<FinalStepProps> = ({ onContinue, updateHeaderContent }
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</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 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 4 }}>
|
||||||
<OnboardingButton
|
<Button
|
||||||
variant="primary"
|
variant="contained"
|
||||||
onClick={handleLaunch}
|
|
||||||
loading={loading}
|
|
||||||
size="large"
|
size="large"
|
||||||
icon={<Rocket />}
|
disabled={loading || dataLoading}
|
||||||
disabled={Object.keys(onboardingData.apiKeys).length === 0}
|
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
|
Launch Alwrity & Complete Setup
|
||||||
</OnboardingButton>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Help Text */}
|
{/* Help Text */}
|
||||||
|
|||||||
@@ -74,12 +74,26 @@ export const SetupSummary: React.FC<SetupSummaryProps> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<LockOpen />}
|
icon={<LockOpen />}
|
||||||
/>
|
/>
|
||||||
<Chip
|
{/* Only show missing chip if there are actually missing items */}
|
||||||
label="1 Missing"
|
{(() => {
|
||||||
color="warning"
|
const missingCount = capabilities.length - unlockedCapabilities.length;
|
||||||
variant="filled"
|
return missingCount > 0 ? (
|
||||||
size="small"
|
<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>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface OnboardingData {
|
|||||||
personalizationSettings?: any;
|
personalizationSettings?: any;
|
||||||
integrations?: any;
|
integrations?: any;
|
||||||
styleAnalysis?: any;
|
styleAnalysis?: any;
|
||||||
|
personaReadiness?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Capability {
|
export interface Capability {
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error initializing onboarding:', error);
|
console.error('Error initializing onboarding:', error);
|
||||||
|
// Error handling is managed by global API client interceptors
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -335,6 +336,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
const handleNext = useCallback(async (rawStepData?: any) => {
|
const handleNext = useCallback(async (rawStepData?: any) => {
|
||||||
console.log('Wizard: handleNext called');
|
console.log('Wizard: handleNext called');
|
||||||
console.log('Wizard: Current step:', activeStep);
|
console.log('Wizard: Current step:', activeStep);
|
||||||
|
console.log('Wizard: Raw step data:', rawStepData);
|
||||||
console.log('Wizard: Step data:', stepDataRef.current);
|
console.log('Wizard: Step data:', stepDataRef.current);
|
||||||
console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current);
|
console.log('Wizard: competitorDataCollector:', competitorDataCollectorRef.current);
|
||||||
console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current);
|
console.log('Wizard: competitorDataCollector type:', typeof competitorDataCollectorRef.current);
|
||||||
@@ -352,6 +354,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
? undefined
|
? undefined
|
||||||
: rawStepData;
|
: rawStepData;
|
||||||
|
|
||||||
|
console.log('Wizard: Processed currentStepData:', currentStepData);
|
||||||
|
|
||||||
// Special handling for CompetitorAnalysisStep (step 2)
|
// Special handling for CompetitorAnalysisStep (step 2)
|
||||||
if (activeStep === 2) {
|
if (activeStep === 2) {
|
||||||
console.log('Wizard: Handling CompetitorAnalysisStep data...');
|
console.log('Wizard: Handling CompetitorAnalysisStep data...');
|
||||||
@@ -416,6 +420,9 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
// Special handling for PersonaStep (step 3)
|
// Special handling for PersonaStep (step 3)
|
||||||
if (activeStep === 3) {
|
if (activeStep === 3) {
|
||||||
console.log('Wizard: Handling PersonaStep data...');
|
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 we have data from onContinue, use it
|
||||||
if (currentStepData && currentStepData.corePersona && currentStepData.qualityMetrics) {
|
if (currentStepData && currentStepData.corePersona && currentStepData.qualityMetrics) {
|
||||||
@@ -442,6 +449,8 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
currentStepData = currentData;
|
currentStepData = currentData;
|
||||||
} else {
|
} else {
|
||||||
console.warn('Wizard: No valid persona data available for PersonaStep - cannot complete step');
|
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
|
// 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');
|
console.log('Wizard: Aborting step completion - missing valid persona data');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -694,15 +703,17 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
|
|||||||
</Fade>
|
</Fade>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation - Hide on final step */}
|
||||||
<WizardNavigation
|
{activeStep !== steps.length - 1 && (
|
||||||
activeStep={activeStep}
|
<WizardNavigation
|
||||||
totalSteps={steps.length}
|
activeStep={activeStep}
|
||||||
onBack={handleBack}
|
totalSteps={steps.length}
|
||||||
onNext={handleNext}
|
onBack={handleBack}
|
||||||
isLastStep={activeStep === steps.length - 1}
|
onNext={handleNext}
|
||||||
isCurrentStepValid={isCurrentStepValid}
|
isLastStep={activeStep === steps.length - 1}
|
||||||
/>
|
isCurrentStepValid={isCurrentStepValid}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,39 +73,41 @@ export const WizardNavigation: React.FC<WizardNavigationProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Tooltip
|
{!isLastStep && (
|
||||||
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
|
<Tooltip
|
||||||
placement="top"
|
title={!isCurrentStepValid ? "Complete the current step requirements to continue" : ""}
|
||||||
>
|
placement="top"
|
||||||
<span>
|
>
|
||||||
<Button
|
<span>
|
||||||
variant="contained"
|
<Button
|
||||||
onClick={onNext}
|
variant="contained"
|
||||||
disabled={isLastStep || !isCurrentStepValid}
|
onClick={onNext}
|
||||||
endIcon={isLastStep ? <CheckCircle /> : <ArrowForward />}
|
disabled={!isCurrentStepValid}
|
||||||
sx={{
|
endIcon={<ArrowForward />}
|
||||||
borderRadius: 2,
|
sx={{
|
||||||
textTransform: 'none',
|
borderRadius: 2,
|
||||||
fontWeight: 600,
|
textTransform: 'none',
|
||||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
fontWeight: 600,
|
||||||
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
'&:hover': {
|
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.3)',
|
||||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
'&:hover': {
|
||||||
transform: 'translateY(-1px)',
|
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||||
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
transform: 'translateY(-1px)',
|
||||||
},
|
boxShadow: '0 6px 16px rgba(102, 126, 234, 0.4)',
|
||||||
'&:disabled': {
|
},
|
||||||
background: 'rgba(0,0,0,0.1)',
|
'&:disabled': {
|
||||||
color: 'rgba(0,0,0,0.4)',
|
background: 'rgba(0,0,0,0.1)',
|
||||||
boxShadow: 'none',
|
color: 'rgba(0,0,0,0.4)',
|
||||||
transform: 'none',
|
boxShadow: 'none',
|
||||||
}
|
transform: 'none',
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
{isLastStep ? 'Complete Setup' : 'Continue'}
|
>
|
||||||
</Button>
|
Continue
|
||||||
</span>
|
</Button>
|
||||||
</Tooltip>
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</Box>
|
</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 ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||||
const { isSignedIn } = useAuth();
|
const { isLoaded, isSignedIn } = useAuth();
|
||||||
|
|
||||||
// Use onboarding context instead of making API calls
|
// Use onboarding context instead of making API calls
|
||||||
const {
|
const {
|
||||||
@@ -21,8 +21,23 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|||||||
clearError
|
clearError
|
||||||
} = useOnboarding();
|
} = useOnboarding();
|
||||||
|
|
||||||
// Loading state - show spinner
|
// Local fallback (in case context hasn't refreshed yet right after completion)
|
||||||
if (loading) {
|
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...');
|
console.log('ProtectedRoute: Loading onboarding state from context...');
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -41,8 +56,8 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state - show error with retry
|
// Error state - show error with retry (unless local flag allows pass-through)
|
||||||
if (error) {
|
if (error && !localComplete) {
|
||||||
console.error('ProtectedRoute: Error from context:', error);
|
console.error('ProtectedRoute: Error from context:', error);
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -84,19 +99,19 @@ const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not signed in - redirect to landing
|
// Not signed in - redirect to landing
|
||||||
if (!isSignedIn) {
|
if (isLoaded && !isSignedIn) {
|
||||||
console.log('ProtectedRoute: Not signed in, redirecting to landing');
|
console.log('ProtectedRoute: Not signed in, redirecting to landing');
|
||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Onboarding not complete - redirect to onboarding
|
// Onboarding not complete - redirect to onboarding
|
||||||
if (!isOnboardingComplete) {
|
if (!allowAccess) {
|
||||||
console.log('ProtectedRoute: Onboarding not complete (from context), redirecting');
|
console.log('ProtectedRoute: Onboarding not complete (context/local), redirecting');
|
||||||
return <Navigate to="/onboarding" replace />;
|
return <Navigate to="/onboarding" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// All checks passed - render protected component
|
// 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}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client';
|
||||||
|
import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
|
||||||
|
|
||||||
export interface SubscriptionLimits {
|
export interface SubscriptionLimits {
|
||||||
gemini_calls: number;
|
gemini_calls: number;
|
||||||
@@ -29,6 +30,8 @@ interface SubscriptionContextType {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
checkSubscription: () => Promise<void>;
|
checkSubscription: () => Promise<void>;
|
||||||
refreshSubscription: () => Promise<void>;
|
refreshSubscription: () => Promise<void>;
|
||||||
|
showExpiredModal: () => void;
|
||||||
|
hideExpiredModal: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SubscriptionContext = createContext<SubscriptionContextType | undefined>(undefined);
|
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 [subscription, setSubscription] = useState<SubscriptionStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -71,6 +92,70 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
|||||||
|
|
||||||
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
|
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
|
||||||
setSubscription(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) {
|
} catch (err) {
|
||||||
console.error('Error checking subscription:', 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
|
// 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
|
// 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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil]);
|
||||||
|
|
||||||
const refreshSubscription = async () => {
|
const refreshSubscription = useCallback(async () => {
|
||||||
await checkSubscription();
|
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(() => {
|
useEffect(() => {
|
||||||
// Check subscription on mount
|
// Check subscription on mount
|
||||||
@@ -121,7 +267,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
|||||||
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
|
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
|
||||||
window.removeEventListener('user-authenticated', handleUserAuth);
|
window.removeEventListener('user-authenticated', handleUserAuth);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []); // Remove checkSubscription dependency to prevent loop
|
||||||
|
|
||||||
const value: SubscriptionContextType = {
|
const value: SubscriptionContextType = {
|
||||||
subscription,
|
subscription,
|
||||||
@@ -129,11 +275,20 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
|||||||
error,
|
error,
|
||||||
checkSubscription,
|
checkSubscription,
|
||||||
refreshSubscription,
|
refreshSubscription,
|
||||||
|
showExpiredModal,
|
||||||
|
hideExpiredModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SubscriptionContext.Provider value={value}>
|
<SubscriptionContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
|
<SubscriptionExpiredModal
|
||||||
|
open={showModal}
|
||||||
|
onClose={hideExpiredModal}
|
||||||
|
onRenewSubscription={handleRenewSubscription}
|
||||||
|
subscriptionData={subscription}
|
||||||
|
errorData={modalErrorData}
|
||||||
|
/>
|
||||||
</SubscriptionContext.Provider>
|
</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: [],
|
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 {
|
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 = {
|
const coerced: UsageStats = {
|
||||||
billing_period: raw?.billing_period ?? 'unknown',
|
billing_period: raw?.billing_period ?? new Date().toISOString().slice(0,7),
|
||||||
usage_status: (raw?.usage_status ?? 'active') as UsageStats['usage_status'],
|
usage_status: raw?.usage_status ?? 'active',
|
||||||
total_calls: Number(raw?.total_calls ?? 0),
|
total_calls: raw?.total_calls ?? 0,
|
||||||
total_tokens: Number(raw?.total_tokens ?? 0),
|
total_tokens: raw?.total_tokens ?? 0,
|
||||||
total_cost: Number(raw?.total_cost ?? 0),
|
total_cost: raw?.total_cost ?? 0,
|
||||||
avg_response_time: Number(raw?.avg_response_time ?? 0),
|
avg_response_time: raw?.avg_response_time ?? 0,
|
||||||
error_rate: Number(raw?.error_rate ?? 0),
|
error_rate: raw?.error_rate ?? 0,
|
||||||
limits: raw?.limits ?? defaultLimits,
|
limits: defaultLimits,
|
||||||
provider_breakdown: raw?.provider_breakdown ?? defaultProviderBreakdown,
|
provider_breakdown: {
|
||||||
alerts: Array.isArray(raw?.alerts) ? raw.alerts : [],
|
gemini: providerBreakdown.gemini ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
usage_percentages: raw?.usage_percentages ?? {
|
openai: providerBreakdown.openai ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
gemini_calls: 0,
|
anthropic: providerBreakdown.anthropic ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
openai_calls: 0,
|
mistral: providerBreakdown.mistral ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
anthropic_calls: 0,
|
tavily: providerBreakdown.tavily ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
mistral_calls: 0,
|
serper: providerBreakdown.serper ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
tavily_calls: 0,
|
metaphor: providerBreakdown.metaphor ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
serper_calls: 0,
|
firecrawl: providerBreakdown.firecrawl ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
metaphor_calls: 0,
|
stability: providerBreakdown.stability ?? { calls: 0, tokens: 0, cost: 0 },
|
||||||
firecrawl_calls: 0,
|
},
|
||||||
stability_calls: 0,
|
alerts: coerceAlerts(raw?.alerts),
|
||||||
cost: 0,
|
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(),
|
last_updated: raw?.last_updated ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
@@ -185,7 +239,7 @@ export const billingService = {
|
|||||||
provider_trends: {},
|
provider_trends: {},
|
||||||
},
|
},
|
||||||
limits: raw?.limits ?? defaultLimits,
|
limits: raw?.limits ?? defaultLimits,
|
||||||
alerts: Array.isArray(raw?.alerts) ? raw.alerts : [],
|
alerts: coerceAlerts(raw?.alerts),
|
||||||
projections: raw?.projections ?? {
|
projections: raw?.projections ?? {
|
||||||
projected_monthly_cost: 0,
|
projected_monthly_cost: 0,
|
||||||
cost_limit: 0,
|
cost_limit: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user