Bing Analytics and Insights added, background jobs added, database setup updated, environment setup updated, frontend updated, backend updated.

Onboarding Manager and Router Manager refactored, analytics and background jobs added, database setup updated, environment setup updated, frontend updated, backend updated.
Critical onboarding database migration implemented.
This commit is contained in:
ajaysi
2025-10-18 10:28:15 +05:30
parent 40fb6ac95b
commit 1f087aad4c
69 changed files with 11995 additions and 189 deletions

View File

@@ -15,15 +15,17 @@ import PricingPage from './components/Pricing/PricingPage';
import WixTestPage from './components/WixTestPage/WixTestPage';
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
import WordPressCallbackPage from './components/WordPressCallbackPage/WordPressCallbackPage';
import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
import ProtectedRoute from './components/shared/ProtectedRoute';
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
import Landing from './components/Landing/Landing';
import ErrorBoundary from './components/shared/ErrorBoundary';
import ErrorBoundaryTest from './components/shared/ErrorBoundaryTest';
import { OnboardingProvider } from './contexts/OnboardingContext';
import { SubscriptionProvider } from './contexts/SubscriptionContext';
import { SubscriptionProvider, useSubscription } from './contexts/SubscriptionContext';
import { apiClient, setAuthTokenGetter } from './api/client';
import { setAuthTokenGetter } from './api/client';
import { useOnboarding } from './contexts/OnboardingContext';
import { useState, useEffect } from 'react';
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
@@ -45,13 +47,9 @@ const ConditionalCopilotKit: React.FC<{ children: React.ReactNode }> = ({ childr
// Component to handle initial routing based on subscription and onboarding status
// Flow: Subscription → Onboarding → Dashboard
const InitialRouteHandler: React.FC = () => {
const { loading, error, isOnboardingComplete } = useOnboarding();
const [checkingSubscription, setCheckingSubscription] = useState(true);
const [subscriptionStatus, setSubscriptionStatus] = useState<{
active: boolean;
plan: string;
isNewUser: boolean;
} | null>(null);
const { loading, error, isOnboardingComplete, initializeOnboarding } = useOnboarding();
const { subscription, loading: subscriptionLoading, error: subscriptionError, checkSubscription } = useSubscription();
// Note: subscriptionError is available for future error handling
const [connectionError, setConnectionError] = useState<{
hasError: boolean;
error: Error | null;
@@ -60,53 +58,40 @@ const InitialRouteHandler: React.FC = () => {
error: null,
});
// Check subscription on mount
useEffect(() => {
const checkSubscription = async () => {
try {
const userId = localStorage.getItem('user_id') || 'anonymous';
const response = await apiClient.get(`/api/subscription/status/${userId}`);
const subscriptionData = response.data.data;
// Check if user is new (no subscription record at all)
const isNewUser = !subscriptionData || subscriptionData.plan === 'none';
setSubscriptionStatus({
active: subscriptionData?.active || false,
plan: subscriptionData?.plan || 'none',
isNewUser
});
// Clear any connection errors
checkSubscription().catch((err) => {
console.error('Error checking subscription:', err);
// Check if it's a connection error - handle it locally
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: false,
error: null,
hasError: true,
error: err,
});
} catch (err: any) {
console.error('Error checking subscription:', err);
// Check if it's a connection error - handle it locally
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: true,
error: err,
});
return; // Don't set subscription status for connection errors
}
// For other errors, treat as new user
setSubscriptionStatus({
active: false,
plan: 'none',
isNewUser: true
});
} finally {
setCheckingSubscription(false);
}
};
});
}, [checkSubscription]);
checkSubscription();
}, []);
// Initialize onboarding only after subscription is confirmed
useEffect(() => {
if (subscription && !subscriptionLoading) {
// Check if user is new (no subscription record at all)
const isNewUser = !subscription || subscription.plan === 'none';
console.log('InitialRouteHandler: Subscription data received:', {
plan: subscription.plan,
active: subscription.active,
isNewUser,
subscriptionLoading
});
if (subscription.active && !isNewUser) {
console.log('InitialRouteHandler: Subscription confirmed, initializing onboarding...');
initializeOnboarding();
}
}
}, [subscription, subscriptionLoading, initializeOnboarding]);
// Handle connection error - show connection error page
if (connectionError.hasError) {
@@ -115,42 +100,15 @@ const InitialRouteHandler: React.FC = () => {
hasError: false,
error: null,
});
setCheckingSubscription(true);
// Re-trigger the subscription check
const checkSubscription = async () => {
try {
const userId = localStorage.getItem('user_id') || 'anonymous';
const response = await apiClient.get(`/api/subscription/status/${userId}`);
const subscriptionData = response.data.data;
const isNewUser = !subscriptionData || subscriptionData.plan === 'none';
setSubscriptionStatus({
active: subscriptionData?.active || false,
plan: subscriptionData?.plan || 'none',
isNewUser
// Re-trigger the subscription check using context
checkSubscription().catch((err) => {
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: true,
error: err,
});
} catch (err: any) {
console.error('Error checking subscription on retry:', err);
if (err instanceof Error && (err.name === 'NetworkError' || err.name === 'ConnectionError')) {
setConnectionError({
hasError: true,
error: err,
});
} else {
setSubscriptionStatus({
active: false,
plan: 'none',
isNewUser: true
});
}
} finally {
setCheckingSubscription(false);
}
};
checkSubscription();
});
};
const handleGoHome = () => {
@@ -168,7 +126,7 @@ const InitialRouteHandler: React.FC = () => {
}
// Loading state - checking both subscription and onboarding
if (loading || checkingSubscription) {
if (loading || subscriptionLoading) {
return (
<Box
display="flex"
@@ -180,7 +138,7 @@ const InitialRouteHandler: React.FC = () => {
>
<CircularProgress size={60} />
<Typography variant="h6" color="textSecondary">
{checkingSubscription ? 'Checking subscription...' : 'Checking onboarding status...'}
{subscriptionLoading ? 'Checking subscription...' : 'Checking onboarding status...'}
</Typography>
</Box>
);
@@ -208,15 +166,18 @@ const InitialRouteHandler: React.FC = () => {
);
}
if (!subscriptionStatus) {
if (!subscription) {
return null; // Should not happen, but just in case
}
// Decision tree for SIGNED-IN users:
// Priority: Subscription → Onboarding → Dashboard
// Check if user is new (no subscription record at all)
const isNewUser = !subscription || subscription.plan === 'none';
// 1. No active subscription? → Must subscribe first (even if onboarding is complete)
if (subscriptionStatus.isNewUser || !subscriptionStatus.active) {
if (isNewUser || !subscription.active) {
console.log('InitialRouteHandler: No active subscription → Pricing page');
return <Navigate to="/pricing" replace />;
}
@@ -251,6 +212,9 @@ const TokenInstaller: React.FC = () => {
if (isSignedIn && userId) {
console.log('TokenInstaller: Storing user_id in localStorage:', userId);
localStorage.setItem('user_id', userId);
// Trigger event to notify SubscriptionContext that user is authenticated
window.dispatchEvent(new CustomEvent('user-authenticated', { detail: { userId } }));
} else if (!isSignedIn) {
// Clear user_id when signed out
console.log('TokenInstaller: Clearing user_id from localStorage');
@@ -263,8 +227,8 @@ const TokenInstaller: React.FC = () => {
setAuthTokenGetter(async () => {
try {
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
// If a template is provided, request a template-specific JWT
if (template) {
// If a template is provided and it's not a placeholder, request a template-specific JWT
if (template && template !== 'your_jwt_template_name_here') {
// @ts-ignore Clerk types allow options object
return await getToken({ template });
}
@@ -380,6 +344,8 @@ const App: React.FC = () => {
<Route path="/wix/callback" element={<WixCallbackPage />} />
<Route path="/wp/callback" element={<WordPressCallbackPage />} />
<Route path="/gsc/callback" element={<GSCAuthCallback />} />
<Route path="/bing/callback" element={<BingCallbackPage />} />
<Route path="/bing-analytics-storage" element={<ProtectedRoute><BingAnalyticsStorage /></ProtectedRoute>} />
</Routes>
</ConditionalCopilotKit>
</Router>

View File

@@ -0,0 +1,225 @@
/**
* Analytics API Service
*
* Handles communication with the backend analytics endpoints for retrieving
* platform analytics data from connected services like GSC, Wix, and WordPress.
*/
import { apiClient } from './client';
// Types
export interface AnalyticsMetrics {
total_clicks?: number;
total_impressions?: number;
avg_ctr?: number;
avg_position?: number;
total_queries?: number;
top_queries?: Array<{
query: string;
clicks: number;
impressions: number;
ctr: number;
position: number;
}>;
top_pages?: Array<{
page: string;
clicks: number;
impressions: number;
ctr: number;
}>;
// Additional properties for Bing analytics
connection_status?: string;
connected_sites?: number;
sites?: Array<{
id?: string;
name?: string;
url?: string;
Url?: string; // Bing API uses uppercase Url
status?: string;
[key: string]: any; // Allow additional properties
}>;
connected_since?: string;
scope?: string;
insights?: any;
note?: string;
}
export interface PlatformAnalytics {
platform: string;
metrics: AnalyticsMetrics;
date_range: {
start: string;
end: string;
};
last_updated: string;
status: 'success' | 'error' | 'partial';
error_message?: string;
// Additional properties that may be present in analytics data
connection_status?: string;
sites?: Array<{
id?: string;
name?: string;
url?: string;
Url?: string; // Bing API uses uppercase Url
status?: string;
[key: string]: any; // Allow additional properties
}>;
connected_sites?: number;
connected_since?: string;
scope?: string;
insights?: any;
note?: string;
}
export interface AnalyticsSummary {
total_platforms: number;
connected_platforms: number;
successful_data: number;
total_clicks: number;
total_impressions: number;
overall_ctr: number;
platforms: Record<string, {
status: string;
last_updated: string;
metrics_count?: number;
error?: string;
}>;
}
export interface AnalyticsResponse {
success: boolean;
data: Record<string, PlatformAnalytics>;
summary: AnalyticsSummary;
error?: string;
}
export interface PlatformConnectionStatus {
connected: boolean;
sites_count: number;
sites: Array<{
siteUrl?: string;
name?: string;
[key: string]: any;
}>;
error?: string;
}
export interface PlatformStatusResponse {
success: boolean;
platforms: Record<string, PlatformConnectionStatus>;
total_connected: number;
}
class AnalyticsAPI {
private baseUrl = '/api/analytics';
/**
* Get connection status for all platforms
*/
async getPlatformStatus(): Promise<PlatformStatusResponse> {
try {
const response = await apiClient.get(`${this.baseUrl}/platforms`);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting platform status:', error);
throw error;
}
}
/**
* Get analytics data from connected platforms
*/
async getAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
try {
let url = `${this.baseUrl}/data`;
if (platforms && platforms.length > 0) {
const platformsParam = platforms.join(',');
url += `?platforms=${encodeURIComponent(platformsParam)}`;
}
const response = await apiClient.get(url);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting analytics data:', error);
throw error;
}
}
/**
* Get analytics data using POST method
*/
async getAnalyticsDataPost(platforms?: string[]): Promise<AnalyticsResponse> {
try {
const response = await apiClient.post(`${this.baseUrl}/data`, {
platforms,
date_range: null // Could be extended to support custom date ranges
});
return response.data;
} catch (error) {
console.error('Analytics API: Error getting analytics data (POST):', error);
throw error;
}
}
/**
* Get Google Search Console analytics specifically
*/
async getGSCAnalytics(): Promise<PlatformAnalytics> {
try {
const response = await apiClient.get(`${this.baseUrl}/gsc`);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting GSC analytics:', error);
throw error;
}
}
/**
* Get analytics summary across all platforms
*/
async getAnalyticsSummary(): Promise<{
success: boolean;
summary: AnalyticsSummary;
platforms_connected: number;
platforms_total: number;
}> {
try {
const response = await apiClient.get(`${this.baseUrl}/summary`);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting analytics summary:', error);
throw error;
}
}
/**
* Test endpoint - Get platform status without authentication
*/
async getTestPlatformStatus(): Promise<PlatformStatusResponse> {
try {
const response = await apiClient.get(`${this.baseUrl}/test/status`);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting test platform status:', error);
throw error;
}
}
/**
* Test endpoint - Get mock analytics data without authentication
*/
async getTestAnalyticsData(): Promise<AnalyticsResponse> {
try {
const response = await apiClient.get(`${this.baseUrl}/test/data`);
return response.data;
} catch (error) {
console.error('Analytics API: Error getting test analytics data:', error);
throw error;
}
}
}
// Export singleton instance
export const analyticsAPI = new AnalyticsAPI();
export default analyticsAPI;

View File

@@ -0,0 +1,93 @@
/**
* Bing Webmaster OAuth API Client
* Handles Bing Webmaster Tools OAuth2 authentication flow
*/
import { apiClient } from './client';
export interface BingOAuthStatus {
connected: boolean;
sites: Array<{
id: number;
access_token: string;
scope: string;
created_at: string;
sites: Array<{
id: string;
name: string;
url: string;
status: string;
}>;
}>;
total_sites: number;
}
export interface BingOAuthResponse {
auth_url: string;
state: string;
}
export interface BingCallbackResponse {
success: boolean;
message: string;
access_token?: string;
expires_in?: number;
}
class BingOAuthAPI {
/**
* Get Bing Webmaster OAuth authorization URL
*/
async getAuthUrl(): Promise<BingOAuthResponse> {
try {
console.log('BingOAuthAPI: Making GET request to /bing/auth/url');
const response = await apiClient.get('/bing/auth/url');
console.log('BingOAuthAPI: Response received:', response.data);
return response.data;
} catch (error) {
console.error('BingOAuthAPI: Error getting Bing OAuth URL:', error);
throw error;
}
}
/**
* Get Bing Webmaster connection status
*/
async getStatus(): Promise<BingOAuthStatus> {
try {
const response = await apiClient.get('/bing/status');
return response.data;
} catch (error) {
console.error('Error getting Bing OAuth status:', error);
throw error;
}
}
/**
* Disconnect a Bing Webmaster site
*/
async disconnectSite(tokenId: number): Promise<{ success: boolean; message: string }> {
try {
const response = await apiClient.delete(`/bing/disconnect/${tokenId}`);
return response.data;
} catch (error) {
console.error('Error disconnecting Bing site:', error);
throw error;
}
}
/**
* Health check for Bing OAuth service
*/
async healthCheck(): Promise<{ status: string; service: string; timestamp: string; version: string }> {
try {
const response = await apiClient.get('/bing/health');
return response.data;
} catch (error) {
console.error('Error checking Bing OAuth health:', error);
throw error;
}
}
}
export const bingOAuthAPI = new BingOAuthAPI();

View File

@@ -0,0 +1,221 @@
/**
* Cached Analytics API Client
*
* Wraps the analytics API with intelligent caching to reduce redundant requests
* and improve performance while managing cache invalidation.
*/
import { apiClient } from './client';
import analyticsCache from '../services/analyticsCache';
interface PlatformAnalytics {
platform: string;
metrics: Record<string, any>;
date_range: { start: string; end: string };
last_updated: string;
status: string;
error_message?: string;
}
interface AnalyticsSummary {
total_platforms: number;
connected_platforms: number;
successful_data: number;
total_clicks: number;
total_impressions: number;
overall_ctr: number;
platforms: Record<string, any>;
}
interface PlatformConnectionStatus {
connected: boolean;
sites_count: number;
sites: any[];
error?: string;
}
interface AnalyticsResponse {
data: Record<string, PlatformAnalytics>;
summary: AnalyticsSummary;
status: Record<string, PlatformConnectionStatus>;
}
class CachedAnalyticsAPI {
private readonly CACHE_TTL = {
PLATFORM_STATUS: 30 * 60 * 1000, // 30 minutes - status changes rarely
ANALYTICS_DATA: 60 * 60 * 1000, // 60 minutes - analytics data cached for 1 hour
USER_SITES: 120 * 60 * 1000, // 120 minutes - user sites change very rarely
};
/**
* Get platform connection status with caching
*/
async getPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
const endpoint = '/api/analytics/platforms';
// Try to get from cache first
const cached = analyticsCache.get<{ platforms: Record<string, PlatformConnectionStatus> }>(endpoint);
if (cached) {
console.log('📦 Analytics Cache HIT: Platform status (cached for 30 minutes)');
return cached;
}
// Fetch from API
console.log('🌐 Analytics API: Fetching platform status... (will cache for 30 minutes)');
const response = await apiClient.get(endpoint);
// Cache the result with extended TTL
analyticsCache.set(endpoint, undefined, response.data, this.CACHE_TTL.PLATFORM_STATUS);
return response.data;
}
/**
* Get analytics data with caching
*/
async getAnalyticsData(platforms?: string[], bypassCache: boolean = false): Promise<AnalyticsResponse> {
const params = platforms ? { platforms: platforms.join(',') } : undefined;
const endpoint = '/api/analytics/data';
// If bypassing cache, add timestamp to force fresh request
const requestParams = bypassCache ? { ...params, _t: Date.now() } : params;
// Try to get from cache first (unless bypassing)
if (!bypassCache) {
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, params);
if (cached) {
console.log('📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)');
return cached;
}
}
// Fetch from API
console.log('🌐 Analytics API: Fetching analytics data... (will cache for 60 minutes)', requestParams);
const response = await apiClient.get(endpoint, { params: requestParams });
// Cache the result with extended TTL (unless bypassing)
if (!bypassCache) {
analyticsCache.set(endpoint, params, response.data, this.CACHE_TTL.ANALYTICS_DATA);
}
return response.data;
}
/**
* Invalidate platform status cache
*/
invalidatePlatformStatus(): void {
analyticsCache.invalidate('/api/analytics/platforms');
console.log('🔄 Analytics Cache: Platform status invalidated');
}
/**
* Invalidate analytics data cache
*/
invalidateAnalyticsData(): void {
analyticsCache.invalidate('/api/analytics/data');
console.log('🔄 Analytics Cache: Analytics data invalidated');
}
/**
* Invalidate all analytics cache
*/
invalidateAll(): void {
analyticsCache.invalidate('analytics');
console.log('🔄 Analytics Cache: All analytics cache invalidated');
}
/**
* Force refresh analytics data (bypass cache)
*/
async forceRefreshAnalyticsData(platforms?: string[]): Promise<AnalyticsResponse> {
// Try to clear backend cache first (but don't fail if it doesn't work)
try {
await this.clearBackendCache(platforms);
} catch (error) {
console.warn('⚠️ Backend cache clearing failed, continuing with frontend cache clear:', error);
}
// Always invalidate frontend cache
this.invalidateAnalyticsData();
// Finally get fresh data with cache bypass
return this.getAnalyticsData(platforms, true);
}
/**
* Clear backend analytics cache
*/
async clearBackendCache(platforms?: string[]): Promise<void> {
try {
if (platforms && platforms.length > 0) {
// Clear cache for specific platforms
for (const platform of platforms) {
await apiClient.post('/api/analytics/cache/clear', null, {
params: { platform }
});
}
} else {
// Clear all cache
await apiClient.post('/api/analytics/cache/clear');
}
console.log('🔄 Backend analytics cache cleared');
} catch (error) {
console.error('❌ Failed to clear backend cache:', error);
// Don't throw error, just log it - frontend cache clearing is more important
}
}
/**
* Force refresh platform status (bypass cache)
*/
async forceRefreshPlatformStatus(): Promise<{ platforms: Record<string, PlatformConnectionStatus> }> {
this.invalidatePlatformStatus();
return this.getPlatformStatus();
}
/**
* Get cache statistics for debugging
*/
getCacheStats() {
return analyticsCache.getStats();
}
/**
* Clear all cache
*/
clearCache(): void {
analyticsCache.invalidate();
console.log('🗑️ Analytics Cache: All cache cleared');
}
/**
* Get analytics data with database-first caching (most aggressive)
* Use this when you know the data is stored in the database
*/
async getAnalyticsDataFromDB(platforms?: string[]): Promise<AnalyticsResponse> {
const params = platforms ? { platforms: platforms.join(',') } : undefined;
const endpoint = '/api/analytics/data';
// Try to get from cache first
const cached = analyticsCache.get<AnalyticsResponse>(endpoint, params);
if (cached) {
console.log('📦 Analytics Cache HIT: Analytics data from DB (cached for 2 hours)');
return cached;
}
// Fetch from API
console.log('🌐 Analytics API: Fetching analytics data from DB... (will cache for 2 hours)', params);
const response = await apiClient.get(endpoint, { params });
// Cache the result with database TTL (very long since it's from DB)
analyticsCache.setDatabaseData(endpoint, params, response.data);
return response.data;
}
}
// Create singleton instance
export const cachedAnalyticsAPI = new CachedAnalyticsAPI();
export default cachedAnalyticsAPI;

View File

@@ -0,0 +1,526 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Grid,
Alert,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Divider,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
Storage as StorageIcon,
TrendingUp as TrendingUpIcon,
Search as SearchIcon,
CalendarToday as CalendarIcon,
Assessment as AssessmentIcon,
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface AnalyticsSummary {
period_days: number;
total_clicks: number;
total_impressions: number;
total_queries: number;
avg_ctr: number;
ctr_trend: number;
top_queries: Array<{
query: string;
clicks: number;
impressions: number;
count: number;
}>;
daily_metrics_count: number;
data_quality: string;
}
interface DailyMetric {
date: string;
total_clicks: number;
total_impressions: number;
total_queries: number;
avg_ctr: number;
avg_position: number;
clicks_change: number;
impressions_change: number;
ctr_change: number;
top_queries: any[];
collected_at: string;
}
interface TopQuery {
query: string;
total_clicks: number;
total_impressions: number;
avg_ctr: number;
avg_position: number;
days_appeared: number;
category: string;
is_brand: boolean;
}
const BingAnalyticsStorage: React.FC = () => {
const [loading, setLoading] = useState(false);
const [collecting, setCollecting] = useState(false);
const [siteUrl, setSiteUrl] = useState('https://www.alwrity.com/');
const [days, setDays] = useState(30);
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [dailyMetrics, setDailyMetrics] = useState<DailyMetric[]>([]);
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
const [sortBy, setSortBy] = useState('clicks');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const loadAnalyticsSummary = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/summary', {
params: { site_url: siteUrl, days: days }
});
setSummary(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load analytics summary');
} finally {
setLoading(false);
}
}, [siteUrl, days]);
const collectData = useCallback(async () => {
try {
setCollecting(true);
setError(null);
setSuccess(null);
await apiClient.post('/bing-analytics/collect-data', null, {
params: { site_url: siteUrl, days_back: days }
});
setSuccess(`Data collection started for ${siteUrl}. This may take a few minutes.`);
// Refresh summary after a delay
setTimeout(() => {
loadAnalyticsSummary();
}, 5000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to start data collection');
} finally {
setCollecting(false);
}
}, [siteUrl, days, loadAnalyticsSummary]);
const loadDailyMetrics = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/daily-metrics', {
params: { site_url: siteUrl, days: days }
});
setDailyMetrics(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load daily metrics');
} finally {
setLoading(false);
}
}, [siteUrl, days]);
const loadTopQueries = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await apiClient.get('/bing-analytics/top-queries', {
params: {
site_url: siteUrl,
days: days,
limit: 20,
sort_by: sortBy
}
});
setTopQueries(response.data.data);
} catch (err: any) {
setError(err.response?.data?.detail || 'Failed to load top queries');
} finally {
setLoading(false);
}
}, [siteUrl, days, sortBy]);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const getChangeColor = (change: number) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getChangeIcon = (change: number) => {
if (change > 0) return '↗';
if (change < 0) return '↘';
return '→';
};
useEffect(() => {
if (siteUrl) {
loadAnalyticsSummary();
}
}, [siteUrl, days, loadAnalyticsSummary]);
return (
<Box sx={{ p: 3 }}>
<Typography variant="h4" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<StorageIcon color="primary" />
Bing Analytics Storage
</Typography>
<Alert severity="info" sx={{ mb: 3 }}>
This tool collects and stores Bing Webmaster Tools analytics data for historical analysis and trend tracking.
</Alert>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{success && (
<Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>
{success}
</Alert>
)}
{/* Controls */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Data Collection & Analysis
</Typography>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={4}>
<TextField
fullWidth
label="Site URL"
value={siteUrl}
onChange={(e) => setSiteUrl(e.target.value)}
placeholder="https://www.example.com/"
/>
</Grid>
<Grid item xs={12} md={2}>
<TextField
fullWidth
label="Days"
type="number"
value={days}
onChange={(e) => setDays(parseInt(e.target.value) || 30)}
inputProps={{ min: 1, max: 365 }}
/>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="contained"
onClick={collectData}
disabled={collecting || !siteUrl}
startIcon={collecting ? <CircularProgress size={20} /> : <RefreshIcon />}
fullWidth
>
{collecting ? 'Collecting...' : 'Collect Data'}
</Button>
</Grid>
<Grid item xs={12} md={3}>
<Button
variant="outlined"
onClick={loadAnalyticsSummary}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <AssessmentIcon />}
fullWidth
>
Refresh Summary
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Analytics Summary */}
{summary && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon color="primary" />
Analytics Summary ({summary.period_days} days)
</Typography>
<Grid container spacing={3}>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{formatNumber(summary.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="secondary">
{formatNumber(summary.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info">
{summary.avg_ctr.toFixed(2)}%
</Typography>
<Typography variant="caption" color="text.secondary">
Avg CTR
<Chip
label={`${getChangeIcon(summary.ctr_trend)} ${summary.ctr_trend.toFixed(1)}%`}
color={getChangeColor(summary.ctr_trend)}
size="small"
sx={{ ml: 1 }}
/>
</Typography>
</Box>
</Grid>
<Grid item xs={6} md={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success">
{summary.total_queries}
</Typography>
<Typography variant="caption" color="text.secondary">
Unique Queries
</Typography>
</Box>
</Grid>
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Top Performing Queries
</Typography>
<List dense>
{summary.top_queries.slice(0, 5).map((query, index) => (
<ListItem key={index}>
<ListItemIcon>
<Typography variant="caption" color="text.secondary">
{index + 1}
</Typography>
</ListItemIcon>
<ListItemText
primary={query.query}
secondary={`${query.clicks} clicks • ${query.impressions} impressions • ${((query.clicks / query.impressions) * 100).toFixed(1)}% CTR`}
/>
</ListItem>
))}
</List>
<Chip
label={`Data Quality: ${summary.data_quality}`}
color={summary.data_quality === 'good' ? 'success' : 'warning'}
size="small"
sx={{ mt: 1 }}
/>
</CardContent>
</Card>
)}
{/* Top Queries Table */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<SearchIcon color="primary" />
Top Queries
</Typography>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Sort By</InputLabel>
<Select
value={sortBy}
label="Sort By"
onChange={(e) => setSortBy(e.target.value)}
>
<MenuItem value="clicks">Clicks</MenuItem>
<MenuItem value="impressions">Impressions</MenuItem>
<MenuItem value="ctr">CTR</MenuItem>
</Select>
</FormControl>
<Button
variant="outlined"
onClick={loadTopQueries}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <SearchIcon />}
>
Load Top Queries
</Button>
</Box>
</Box>
{topQueries.length > 0 && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Query</TableCell>
<TableCell align="right">Clicks</TableCell>
<TableCell align="right">Impressions</TableCell>
<TableCell align="right">CTR</TableCell>
<TableCell align="right">Avg Position</TableCell>
<TableCell align="right">Days</TableCell>
<TableCell>Category</TableCell>
<TableCell>Brand</TableCell>
</TableRow>
</TableHead>
<TableBody>
{topQueries.map((query, index) => (
<TableRow key={index}>
<TableCell>
<Typography variant="body2" sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{query.query}
</Typography>
</TableCell>
<TableCell align="right">{query.total_clicks}</TableCell>
<TableCell align="right">{query.total_impressions}</TableCell>
<TableCell align="right">{query.avg_ctr.toFixed(1)}%</TableCell>
<TableCell align="right">{query.avg_position > 0 ? query.avg_position.toFixed(1) : 'N/A'}</TableCell>
<TableCell align="right">{query.days_appeared}</TableCell>
<TableCell>
<Chip label={query.category} size="small" color="default" />
</TableCell>
<TableCell>
<Chip
label={query.is_brand ? 'Brand' : 'Generic'}
size="small"
color={query.is_brand ? 'primary' : 'default'}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
{/* Daily Metrics */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CalendarIcon color="primary" />
Daily Metrics
</Typography>
<Button
variant="outlined"
onClick={loadDailyMetrics}
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <CalendarIcon />}
>
Load Daily Data
</Button>
</Box>
{dailyMetrics.length > 0 && (
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Date</TableCell>
<TableCell align="right">Clicks</TableCell>
<TableCell align="right">Impressions</TableCell>
<TableCell align="right">Queries</TableCell>
<TableCell align="right">CTR</TableCell>
<TableCell align="right">Position</TableCell>
<TableCell align="right">Clicks Δ</TableCell>
<TableCell align="right">CTR Δ</TableCell>
</TableRow>
</TableHead>
<TableBody>
{dailyMetrics.slice(0, 10).map((metric, index) => (
<TableRow key={index}>
<TableCell>{new Date(metric.date).toLocaleDateString()}</TableCell>
<TableCell align="right">{metric.total_clicks}</TableCell>
<TableCell align="right">{metric.total_impressions}</TableCell>
<TableCell align="right">{metric.total_queries}</TableCell>
<TableCell align="right">{metric.avg_ctr.toFixed(1)}%</TableCell>
<TableCell align="right">{metric.avg_position > 0 ? metric.avg_position.toFixed(1) : 'N/A'}</TableCell>
<TableCell align="right">
<Chip
label={`${getChangeIcon(metric.clicks_change)} ${metric.clicks_change.toFixed(1)}%`}
color={getChangeColor(metric.clicks_change)}
size="small"
/>
</TableCell>
<TableCell align="right">
<Chip
label={`${getChangeIcon(metric.ctr_change)} ${metric.ctr_change.toFixed(1)}%`}
color={getChangeColor(metric.ctr_change)}
size="small"
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</CardContent>
</Card>
</Box>
);
};
export default BingAnalyticsStorage;

View File

@@ -0,0 +1,82 @@
import React, { useEffect, useState } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
const BingCallbackPage: React.FC = () => {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const run = async () => {
try {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
const error = params.get('error');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
try {
// Call backend to complete token exchange
await fetch(`/bing/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, {
method: 'GET',
credentials: 'include'
});
} catch (e) {
// Continue; backend HTML callback may already be handled in popup
}
// Notify opener and close if this is a popup window
try {
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_SUCCESS', success: true }, '*');
if (window.opener) {
window.close();
return;
}
} catch {}
// Fallback: redirect back to onboarding
window.location.replace('/onboarding?step=5');
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');
try {
(window.opener || window.parent)?.postMessage({ type: 'BING_OAUTH_ERROR', success: false, error: e?.message || 'OAuth callback failed' }, '*');
if (window.opener) window.close();
} catch {}
}
};
run();
}, []);
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
minHeight="100vh"
padding={3}
>
{error ? (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="h6">Connection Failed</Typography>
<Typography>{error}</Typography>
</Alert>
) : (
<>
<CircularProgress sx={{ mb: 2 }} />
<Typography variant="h6">Connecting to Bing Webmaster Tools...</Typography>
<Typography variant="body2" color="text.secondary">
Please wait while we complete the authentication process.
</Typography>
</>
)}
</Box>
);
};
export default BingCallbackPage;

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useState, useCallback } from 'react';
import {
Box,
Typography,
Alert,
Container,
CircularProgress,
Stack,
Card,
CardContent,
Chip,
Fade,
Divider,
} from '@mui/material';
import {
CheckCircleOutline as CheckCircleIcon,
ErrorOutline as ErrorIcon,
InfoOutlined as InfoIcon,
WarningAmberOutlined as WarningIcon,
Key as KeyIcon,
Star as StarIcon,
} from '@mui/icons-material';
import OnboardingButton from './common/OnboardingButton';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
interface ApiKeyValidationStepProps {
onContinue: (stepData?: any) => void;
updateHeaderContent: (content: { title: string; description: string }) => void;
onValidationChange?: (isValid: boolean) => void;
}
interface ApiKeyStatus {
valid: boolean;
status: 'configured' | 'missing' | 'invalid' | 'checking';
error?: string;
}
interface ValidationResponse {
api_keys: Record<string, string>;
validation_results: Record<string, ApiKeyStatus>;
all_valid: boolean;
total_providers: number;
configured_providers: string[];
missing_keys: string[];
}
const ApiKeyValidationStep: React.FC<ApiKeyValidationStepProps> = ({
onContinue,
updateHeaderContent,
onValidationChange,
}) => {
const [loading, setLoading] = useState(true);
const [validationData, setValidationData] = useState<ValidationResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [isValid, setIsValid] = useState(false);
const { subscription } = useSubscription();
const validateApiKeys = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get<ValidationResponse>('/api/onboarding/api-keys/validate');
setValidationData(response.data);
setIsValid(response.data.all_valid);
if (onValidationChange) {
onValidationChange(response.data.all_valid);
}
} catch (err: any) {
console.error('Error validating API keys:', err);
setError(err.response?.data?.detail || 'Failed to validate API keys. Please check backend logs.');
setIsValid(false);
if (onValidationChange) {
onValidationChange(false);
}
} finally {
setLoading(false);
}
}, [onValidationChange]);
useEffect(() => {
updateHeaderContent({
title: 'API Keys Configured',
description: 'Your AI service API keys have been successfully configured in the backend environment.',
});
validateApiKeys();
}, [updateHeaderContent, validateApiKeys]);
const handleContinue = () => {
if (isValid) {
onContinue();
}
};
const getStatusIcon = (status: 'configured' | 'missing' | 'invalid' | 'checking') => {
switch (status) {
case 'configured':
return <CheckCircleIcon color="success" />;
case 'missing':
return <WarningIcon color="warning" />;
case 'invalid':
return <ErrorIcon color="error" />;
case 'checking':
return <CircularProgress size={20} />;
default:
return <InfoIcon color="info" />;
}
};
const getStatusColor = (status: 'configured' | 'missing' | 'invalid' | 'checking') => {
switch (status) {
case 'configured':
return 'success';
case 'missing':
return 'warning';
case 'invalid':
return 'error';
case 'checking':
return 'info';
default:
return 'info';
}
};
const formatProviderName = (provider: string) => {
return provider
.replace(/_API_KEY/g, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
};
return (
<Fade in={true} timeout={500}>
<Container maxWidth="md" sx={{ py: 4 }}>
<Typography variant="h5" component="h2" gutterBottom align="center" sx={{ mb: 3, fontWeight: 600 }}>
API Key Validation
</Typography>
{loading && (
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight="200px">
<CircularProgress size={50} sx={{ mb: 2 }} />
<Typography variant="h6" color="text.secondary">
Validating API key configurations...
</Typography>
</Box>
)}
{!loading && error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{!loading && validationData && (
<Box sx={{ mb: 4 }}>
{isValid ? (
<Alert severity="success" sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<StarIcon sx={{ color: 'success.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600 }}>
All API Keys Configured Successfully!
</Typography>
</Box>
<Typography variant="body2" sx={{ mb: 2 }}>
Your AI services are ready to use. You can now proceed to the next step.
</Typography>
{/* Subscription Plan Details */}
{subscription && (
<Box sx={{
mt: 2,
p: 2,
bgcolor: 'rgba(76, 175, 80, 0.1)',
borderRadius: 2,
border: '1px solid rgba(76, 175, 80, 0.2)'
}}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
Your Subscription Plan
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Chip
label={subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)}
color="success"
size="small"
variant="filled"
/>
<Typography variant="body2" color="text.secondary">
{subscription.active ? 'Active' : 'Inactive'}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
Monthly API calls: {subscription.limits.gemini_calls.toLocaleString()} Gemini, {subscription.limits.openai_calls.toLocaleString()} OpenAI
</Typography>
</Box>
)}
</Alert>
) : (
<Alert severity="warning" sx={{ mb: 3 }}>
Some required API keys are missing or invalid. Please configure them in your backend .env file.
</Alert>
)}
{/* Compact API Key Status Grid */}
<Box sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: 2,
mb: 3
}}>
{Object.entries(validationData.validation_results).map(([provider, status]) => (
<Card
key={provider}
variant="outlined"
sx={{
border: `1px solid`,
borderColor: status.status === 'configured' ? '#e8f5e8' : '#fff3cd',
bgcolor: 'background.paper',
'&:hover': {
boxShadow: 2,
},
transition: 'all 0.2s ease-in-out'
}}
>
<CardContent sx={{ p: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<KeyIcon
sx={{
color: status.status === 'configured' ? '#2e7d32' : '#ed6c02',
fontSize: 20
}}
/>
<Typography variant="h6" fontWeight={600} sx={{ color: 'text.primary' }}>
{formatProviderName(provider)}
</Typography>
</Box>
{getStatusIcon(status.status)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontWeight: 500 }}>
Status:
</Typography>
<Chip
label={status.status}
color={getStatusColor(status.status) as any}
size="small"
variant="filled"
sx={{
fontWeight: 600,
fontSize: '0.75rem',
height: 24
}}
/>
</Box>
{status.status === 'configured' && (
<Typography variant="caption" sx={{
color: '#2e7d32',
fontWeight: 500,
display: 'block',
mt: 0.5
}}>
Ready to use
</Typography>
)}
{status.error && (
<Typography variant="caption" sx={{
color: 'error.main',
fontWeight: 500,
display: 'block',
mt: 0.5
}}>
{status.error}
</Typography>
)}
</CardContent>
</Card>
))}
</Box>
{/* Compact Summary Section */}
<Box sx={{
display: 'flex',
gap: 2,
flexWrap: 'wrap',
justifyContent: 'center',
mt: 3
}}>
{validationData.configured_providers.length > 0 && (
<Chip
icon={<CheckCircleIcon />}
label={`${validationData.configured_providers.length} Configured`}
color="success"
variant="outlined"
sx={{
fontWeight: 600,
'& .MuiChip-icon': {
color: 'success.main'
}
}}
/>
)}
{validationData.missing_keys.length > 0 && (
<Chip
icon={<WarningIcon />}
label={`${validationData.missing_keys.length} Missing`}
color="warning"
variant="outlined"
sx={{
fontWeight: 600,
'& .MuiChip-icon': {
color: 'warning.main'
}
}}
/>
)}
</Box>
</Box>
)}
{/* Continue button is handled by the main wizard, not here */}
</Container>
</Fade>
);
};
export default ApiKeyValidationStep;

View File

@@ -1,8 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Fade,
Snackbar
Snackbar,
Typography,
Paper
} from '@mui/material';
import {
// Social Media Icons
@@ -16,7 +18,8 @@ import {
// Platform Icons
Web as WordPressIcon,
Web as WixIcon,
Google as GoogleIcon
Google as GoogleIcon,
Analytics as AnalyticsIcon
} from '@mui/icons-material';
// Import refactored components
@@ -24,8 +27,12 @@ import EmailSection from './common/EmailSection';
import PlatformSection from './common/PlatformSection';
import BenefitsSummary from './common/BenefitsSummary';
import ComingSoonSection from './common/ComingSoonSection';
import { useWordPressOAuth } from '../../hooks/useWordPressOAuth';
import { useBingOAuth } from '../../hooks/useBingOAuth';
import { useGSCConnection } from './common/useGSCConnection';
import { usePlatformConnections } from './common/usePlatformConnections';
import PlatformAnalytics from '../shared/PlatformAnalytics';
import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
interface IntegrationsStepProps {
onContinue: () => void;
@@ -50,7 +57,39 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
// Use custom hooks
const { gscSites, connectedPlatforms, setConnectedPlatforms, handleGSCConnect } = useGSCConnection();
// Invalidate analytics cache when platform connections change
const invalidateAnalyticsCache = useCallback(() => {
console.log('🔄 IntegrationsStep: Invalidating analytics cache due to connection change');
cachedAnalyticsAPI.invalidateAll();
}, []);
// Force refresh analytics data (bypass cache)
const forceRefreshAnalytics = useCallback(async () => {
console.log('🔄 IntegrationsStep: Force refreshing analytics data (bypassing cache)');
try {
// Clear all cache first
cachedAnalyticsAPI.clearCache();
// Force refresh platform status
await cachedAnalyticsAPI.forceRefreshPlatformStatus();
// Force refresh analytics data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(['bing', 'gsc']);
console.log('✅ IntegrationsStep: Analytics data force refreshed successfully');
} catch (error) {
console.error('❌ IntegrationsStep: Error force refreshing analytics:', error);
}
}, []);
const { isLoading, showToast, setShowToast, toastMessage, handleConnect } = usePlatformConnections();
// WordPress OAuth hook
const { connected: wordpressConnected, sites: wordpressSites } = useWordPressOAuth();
// Bing OAuth hook
const { connected: bingConnected, sites: bingSites, connect: connectBing } = useBingOAuth();
console.log('Bing OAuth hook initialized:', { bingConnected, connectBing: typeof connectBing });
// Initialize integrations data
const [integrations] = useState<IntegrationPlatform[]>([
@@ -91,6 +130,18 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
oauthUrl: '/gsc/auth/url',
isEnabled: true
},
{
id: 'bing',
name: 'Bing Webmaster Tools',
description: 'Connect Bing Webmaster for comprehensive SEO insights and search performance data',
icon: <AnalyticsIcon />,
category: 'analytics',
status: 'available',
features: ['Bing search performance', 'SEO insights', 'Index status monitoring'],
benefits: ['Bing search analytics', 'SEO optimization insights', 'Search engine visibility tracking'],
oauthUrl: '/bing/auth/url',
isEnabled: true
},
// Social Media Platforms
{
id: 'facebook',
@@ -178,7 +229,65 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
});
}, [updateHeaderContent]);
// Handle OAuth callback parameters
// Handle WordPress connection status changes
useEffect(() => {
console.log('IntegrationsStep: WordPress status changed:', {
wordpressConnected,
wordpressSitesCount: wordpressSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (wordpressConnected && wordpressSites.length > 0) {
// WordPress is connected, add to connected platforms
if (!connectedPlatforms.includes('wordpress')) {
console.log('IntegrationsStep: Adding WordPress to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'wordpress']);
console.log('WordPress connection detected:', wordpressSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: WordPress already in connected platforms');
}
} else if (!wordpressConnected && connectedPlatforms.includes('wordpress')) {
// WordPress is disconnected, remove from connected platforms
console.log('IntegrationsStep: Removing WordPress from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'wordpress'));
console.log('WordPress disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No WordPress status change needed');
}
}, [wordpressConnected, wordpressSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
// Handle Bing connection status changes
useEffect(() => {
console.log('IntegrationsStep: Bing status changed:', {
bingConnected,
bingSitesCount: bingSites.length,
connectedPlatforms,
currentPlatforms: connectedPlatforms
});
if (bingConnected && bingSites.length > 0) {
if (!connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Adding Bing to connected platforms');
setConnectedPlatforms([...connectedPlatforms, 'bing']);
console.log('Bing connection detected:', bingSites);
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: Bing already in connected platforms');
}
} else if (!bingConnected && connectedPlatforms.includes('bing')) {
console.log('IntegrationsStep: Removing Bing from connected platforms');
setConnectedPlatforms(connectedPlatforms.filter(platform => platform !== 'bing'));
console.log('Bing disconnection detected');
invalidateAnalyticsCache();
} else {
console.log('IntegrationsStep: No Bing status change needed');
}
}, [bingConnected, bingSites, connectedPlatforms, setConnectedPlatforms, invalidateAnalyticsCache]);
// Handle OAuth callback parameters (legacy support)
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const wordpressConnected = urlParams.get('wordpress_connected');
@@ -246,9 +355,31 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
}, []);
const handlePlatformConnect = async (platformId: string) => {
console.log('🚀 INTEGRATIONS_STEP: handlePlatformConnect called with platformId:', platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId type:', typeof platformId);
console.log('🚀 INTEGRATIONS_STEP: platformId length:', platformId.length);
console.log('🚀 INTEGRATIONS_STEP: platformId === "bing":', platformId === 'bing');
console.log('🚀 INTEGRATIONS_STEP: platformId === "gsc":', platformId === 'gsc');
console.log('🚀 INTEGRATIONS_STEP: connectBing function type:', typeof connectBing);
console.log('🚀 INTEGRATIONS_STEP: connectBing function:', connectBing);
console.log('🚀 INTEGRATIONS_STEP: Stack trace:', new Error().stack);
if (platformId === 'gsc') {
console.log('🚀 INTEGRATIONS_STEP: Handling GSC connection');
await handleGSCConnect();
} else if (platformId === 'bing') {
console.log('🚀 INTEGRATIONS_STEP: Handling Bing connection - about to call connectBing');
// Use the Bing OAuth hook for connection
try {
console.log('🚀 INTEGRATIONS_STEP: Calling connectBing()...');
await connectBing();
console.log('🚀 INTEGRATIONS_STEP: Bing connection initiated successfully');
} catch (error) {
console.error('🚀 INTEGRATIONS_STEP: Bing connection failed:', error);
}
} else {
console.log('🚀 INTEGRATIONS_STEP: Handling other platform connection:', platformId);
console.log('🚀 INTEGRATIONS_STEP: This should NOT happen for Bing!');
await handleConnect(platformId);
}
};
@@ -298,6 +429,47 @@ const IntegrationsStep: React.FC<IntegrationsStepProps> = ({ onContinue, updateH
</div>
</Fade>
{/* Analytics Data Display */}
{connectedPlatforms.length > 0 && (
<Fade in timeout={1200}>
<div>
<Paper
elevation={2}
sx={{
mt: 3,
p: 3,
borderRadius: 2,
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)'
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<AnalyticsIcon sx={{ mr: 1, color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: 'text.primary' }}>
Platform Analytics
</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 3 }}>
Here's what data is available from your connected platforms:
</Typography>
<PlatformAnalytics
platforms={connectedPlatforms}
showSummary={true}
refreshInterval={0}
onDataLoaded={(data: any) => {
console.log('Analytics data loaded:', data);
}}
onRefreshReady={(refreshFn) => {
console.log('🔄 PlatformAnalytics refresh function ready');
// Store the refresh function for potential use
(window as any).refreshAnalytics = refreshFn;
}}
/>
</Paper>
</div>
</Fade>
)}
{/* Social Media Platforms */}
<Fade in timeout={1200}>
<div>

View File

@@ -329,12 +329,23 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
// Validation effect - notify wizard when persona data is ready
useEffect(() => {
const isValid = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
// Only validate as complete if:
// 1. Not currently generating
// 2. Generation completed successfully (has success data)
// 3. Has all required persona data
const hasValidData = !!(corePersona && platformPersonas && Object.keys(platformPersonas).length > 0 && qualityMetrics);
const isComplete = !isGenerating && hasValidData && generationStep === 'preview';
const isValid = isComplete;
console.log('PersonaStep: Validation check:', {
corePersona: !!corePersona,
platformPersonas: !!platformPersonas,
platformPersonasCount: platformPersonas ? Object.keys(platformPersonas).length : 0,
qualityMetrics: !!qualityMetrics,
isGenerating,
generationStep,
hasValidData,
isComplete,
isValid
});
@@ -342,23 +353,32 @@ const PersonaStep: React.FC<PersonaStepProps> = ({
console.log('PersonaStep: Calling onValidationChange with:', isValid);
onValidationChange(isValid);
}
}, [corePersona, platformPersonas, qualityMetrics, onValidationChange]);
}, [corePersona, platformPersonas, qualityMetrics, isGenerating, generationStep, onValidationChange]);
// Auto-call onContinue when persona data is ready
// Auto-call onContinue when persona data is ready and generation is complete
useEffect(() => {
console.log('PersonaStep: Checking persona data readiness:', {
corePersona: !!corePersona,
platformPersonas: !!platformPersonas,
qualityMetrics: !!qualityMetrics,
success,
isGenerating
isGenerating,
generationStep
});
if (corePersona && platformPersonas && qualityMetrics && success) {
console.log('PersonaStep: Persona data is ready, auto-calling onContinue');
// Only auto-continue if:
// 1. Generation is complete (not generating and at preview step)
// 2. Has valid persona data and success flag
const hasValidData = corePersona && platformPersonas && qualityMetrics && success;
const isGenerationComplete = !isGenerating && generationStep === 'preview';
if (hasValidData && isGenerationComplete) {
console.log('PersonaStep: Persona data is ready and generation complete, auto-calling onContinue');
handleContinue();
} else {
console.log('PersonaStep: Not ready to continue yet - hasValidData:', hasValidData, 'isGenerationComplete:', isGenerationComplete);
}
}, [corePersona, platformPersonas, qualityMetrics, success, handleContinue]);
}, [corePersona, platformPersonas, qualityMetrics, success, isGenerating, generationStep, handleContinue]);
// (auto-generation handled in initial effect via server/local cache fallback)

View File

@@ -9,7 +9,7 @@ import {
} from '@mui/material';
import { getCurrentStep, setCurrentStep } from '../../api/onboarding';
import { apiClient } from '../../api/client';
import ApiKeyStep from './ApiKeyStep';
import ApiKeyValidationStep from './ApiKeyValidationStep';
import WebsiteStep from './WebsiteStep';
import CompetitorAnalysisStep from './CompetitorAnalysisStep';
import PersonaStep from './PersonaStep';
@@ -181,7 +181,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
});
}, []);
// Memoized callback specifically for ApiKeyStep to prevent infinite loops
// Memoized callback specifically for ApiKeyValidationStep to prevent infinite loops
const handleApiKeyValidationChange = useCallback((isValid: boolean) => {
handleStepValidationChange(0, isValid);
}, [handleStepValidationChange]);
@@ -219,9 +219,22 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
if (cachedInit) {
console.log('Wizard: Using cached init data from batch endpoint');
const data = JSON.parse(cachedInit);
// Extract data from batch response
const { onboarding, session } = data;
// Check if user should start from step 1 due to new API key flow
// If backend says current_step is 1 but cache shows higher step, reset
if (onboarding.current_step === 1 && onboarding.completion_percentage === 0) {
console.log('Wizard: Detected new API key flow - user should start from step 1');
// Clear cache and start fresh
sessionStorage.removeItem('onboarding_init');
localStorage.removeItem('onboarding_active_step');
localStorage.removeItem('onboarding_data');
setActiveStep(0); // Start from step 1 (index 0)
setLoading(false);
return;
}
// Load step data, especially research data from step 3 and persona data from step 4
if (onboarding.steps && Array.isArray(onboarding.steps)) {
@@ -586,7 +599,7 @@ const Wizard: React.FC<WizardProps> = ({ onComplete }) => {
const renderStepContent = (step: number) => {
const stepComponents = [
<ApiKeyStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<ApiKeyValidationStep key="api-keys" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={handleApiKeyValidationChange} />,
<WebsiteStep key="website" onContinue={handleNext} updateHeaderContent={updateHeaderContent} onValidationChange={(isValid) => handleStepValidationChange(1, isValid)} />,
<CompetitorAnalysisStep
key="research"

View File

@@ -15,6 +15,7 @@ import {
Close
} from '@mui/icons-material';
import UserBadge from '../../shared/UserBadge';
import UsageDashboard from '../../shared/UsageDashboard';
interface WizardHeaderProps {
activeStep: number;
@@ -95,8 +96,10 @@ export const WizardHeader: React.FC<WizardHeaderProps> = ({
{/* Top Row - Title and Actions */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3, position: 'relative', zIndex: 1 }}>
<Box sx={{ flex: 1 }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 2 }}>
<UserBadge colorMode="dark" />
{/* Usage Dashboard - Show API usage statistics during onboarding */}
<UsageDashboard compact={true} />
</Box>
<Box sx={{ flex: 2, textAlign: 'center' }}>
<Typography variant="h4" sx={{ fontWeight: 700, letterSpacing: '-0.025em' }}>

View File

@@ -87,7 +87,8 @@ export const usePlatformConnections = () => {
}
// For other platforms, you can add their connection logic here
console.log(`Connecting to ${platformId}...`);
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Connecting to ${platformId}...`);
console.log(`🔧 USE_PLATFORM_CONNECTIONS: Stack trace:`, new Error().stack);
} catch (error) {
console.error('Connection error:', error);

View File

@@ -0,0 +1,445 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Button,
Card,
CardContent,
Typography,
LinearProgress,
Alert,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
} from '@mui/material';
import {
PlayArrow,
Stop,
Refresh,
CheckCircle,
Error as ErrorIcon,
Schedule,
ExpandMore,
Analytics,
DataUsage,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface Job {
job_id: string;
job_type: string;
user_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: number;
message: string;
created_at: string;
started_at?: string;
completed_at?: string;
result?: any;
error?: string;
}
interface BackgroundJobManagerProps {
siteUrl?: string;
days?: number;
onJobCompleted?: (job: Job) => void;
}
const BackgroundJobManager: React.FC<BackgroundJobManagerProps> = ({
siteUrl = 'https://www.alwrity.com/',
days = 30,
onJobCompleted,
}) => {
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(false);
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
const [jobDialogOpen, setJobDialogOpen] = useState(false);
// Fetch user jobs
const fetchJobs = useCallback(async () => {
try {
const response = await apiClient.get('/api/background-jobs/user-jobs?limit=10');
if (response.data.success) {
setJobs(response.data.data.jobs || []);
}
} catch (error) {
console.error('Error fetching jobs:', error);
}
}, []);
// Create Bing comprehensive insights job
const createComprehensiveInsightsJob = async () => {
setLoading(true);
try {
const response = await apiClient.post(
`/api/background-jobs/bing/comprehensive-insights?site_url=${encodeURIComponent(siteUrl)}&days=${days}`
);
if (response.data.success) {
const jobId = response.data.data.job_id;
console.log('✅ Comprehensive insights job created:', jobId);
// Refresh jobs list
await fetchJobs();
// Show success message
alert(`Background job created successfully! Job ID: ${jobId}\n\nThis will generate comprehensive Bing insights in the background. Check the job status below for progress.`);
}
} catch (error) {
console.error('Error creating comprehensive insights job:', error);
alert('Failed to create background job. Please try again.');
} finally {
setLoading(false);
}
};
// Create Bing data collection job
const createDataCollectionJob = async () => {
setLoading(true);
try {
const response = await apiClient.post(
`/api/background-jobs/bing/data-collection?site_url=${encodeURIComponent(siteUrl)}&days_back=${days}`
);
if (response.data.success) {
const jobId = response.data.data.job_id;
console.log('✅ Data collection job created:', jobId);
// Refresh jobs list
await fetchJobs();
alert(`Background data collection job created successfully! Job ID: ${jobId}\n\nThis will collect fresh data from Bing API in the background.`);
}
} catch (error) {
console.error('Error creating data collection job:', error);
alert('Failed to create data collection job. Please try again.');
} finally {
setLoading(false);
}
};
// Cancel job
const cancelJob = async (jobId: string) => {
try {
const response = await apiClient.post(`/api/background-jobs/cancel/${jobId}`);
if (response.data.success) {
console.log('✅ Job cancelled:', jobId);
await fetchJobs();
alert('Job cancelled successfully');
} else {
alert(response.data.message || 'Failed to cancel job');
}
} catch (error) {
console.error('Error cancelling job:', error);
alert('Failed to cancel job. Please try again.');
}
};
// View job details
const viewJobDetails = async (jobId: string) => {
try {
const response = await apiClient.get(`/api/background-jobs/status/${jobId}`);
if (response.data.success) {
setSelectedJob(response.data.data);
setJobDialogOpen(true);
// Call onJobCompleted if job is completed
if (response.data.data.status === 'completed' && onJobCompleted) {
onJobCompleted(response.data.data);
}
}
} catch (error) {
console.error('Error fetching job details:', error);
alert('Failed to fetch job details');
}
};
// Get status color
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'success';
case 'failed': return 'error';
case 'running': return 'primary';
case 'pending': return 'warning';
case 'cancelled': return 'default';
default: return 'default';
}
};
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle />;
case 'failed': return <ErrorIcon />;
case 'running': return <CircularProgress size={16} />;
case 'pending': return <Schedule />;
case 'cancelled': return <Stop />;
default: return <Schedule />;
}
};
// Format job type
const formatJobType = (jobType: string) => {
switch (jobType) {
case 'bing_comprehensive_insights': return 'Bing Comprehensive Insights';
case 'bing_data_collection': return 'Bing Data Collection';
case 'analytics_refresh': return 'Analytics Refresh';
default: return jobType;
}
};
// Poll for job updates
useEffect(() => {
fetchJobs();
// Poll every 5 seconds for running jobs
const interval = setInterval(() => {
const hasRunningJobs = jobs.some(job => job.status === 'running' || job.status === 'pending');
if (hasRunningJobs) {
fetchJobs();
}
}, 5000);
return () => clearInterval(interval);
}, [fetchJobs, jobs]);
return (
<Box>
{/* Action Buttons */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Background Job Actions
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Run expensive operations in the background to avoid timeouts and improve user experience.
</Typography>
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Button
variant="contained"
startIcon={<Analytics />}
onClick={createComprehensiveInsightsJob}
disabled={loading}
color="primary"
>
{loading ? 'Creating...' : 'Generate Comprehensive Bing Insights'}
</Button>
<Button
variant="outlined"
startIcon={<DataUsage />}
onClick={createDataCollectionJob}
disabled={loading}
color="secondary"
>
{loading ? 'Creating...' : 'Collect Fresh Bing Data'}
</Button>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={fetchJobs}
disabled={loading}
>
Refresh Jobs
</Button>
</Box>
</CardContent>
</Card>
{/* Jobs List */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Background Jobs
</Typography>
{jobs.length === 0 ? (
<Alert severity="info">
No background jobs found. Create a job using the buttons above.
</Alert>
) : (
<List>
{jobs.map((job) => (
<Accordion key={job.job_id} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(job.status)}
<Chip
label={job.status.toUpperCase()}
color={getStatusColor(job.status) as any}
size="small"
/>
</Box>
<Typography variant="body2" sx={{ flexGrow: 1 }}>
{formatJobType(job.job_type)}
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(job.created_at).toLocaleString()}
</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Box sx={{ width: '100%' }}>
{/* Progress Bar */}
{(job.status === 'running' || job.status === 'pending') && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" gutterBottom>
Progress: {job.progress}%
</Typography>
<LinearProgress variant="determinate" value={job.progress} />
</Box>
)}
{/* Job Message */}
<Typography variant="body2" gutterBottom>
<strong>Status:</strong> {job.message}
</Typography>
{/* Job Details */}
<Typography variant="body2" gutterBottom>
<strong>Job ID:</strong> {job.job_id}
</Typography>
{job.started_at && (
<Typography variant="body2" gutterBottom>
<strong>Started:</strong> {new Date(job.started_at).toLocaleString()}
</Typography>
)}
{job.completed_at && (
<Typography variant="body2" gutterBottom>
<strong>Completed:</strong> {new Date(job.completed_at).toLocaleString()}
</Typography>
)}
{job.error && (
<Alert severity="error" sx={{ mt: 1 }}>
<strong>Error:</strong> {job.error}
</Alert>
)}
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
<Button
size="small"
variant="outlined"
onClick={() => viewJobDetails(job.job_id)}
>
View Details
</Button>
{(job.status === 'pending' || job.status === 'running') && (
<Button
size="small"
variant="outlined"
color="error"
onClick={() => cancelJob(job.job_id)}
>
Cancel
</Button>
)}
</Box>
</Box>
</AccordionDetails>
</Accordion>
))}
</List>
)}
</CardContent>
</Card>
{/* Job Details Dialog */}
<Dialog
open={jobDialogOpen}
onClose={() => setJobDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
Job Details - {selectedJob?.job_id}
</DialogTitle>
<DialogContent>
{selectedJob && (
<Box>
<Typography variant="body1" gutterBottom>
<strong>Type:</strong> {formatJobType(selectedJob.job_type)}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Status:</strong> {selectedJob.status.toUpperCase()}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Message:</strong> {selectedJob.message}
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Progress:</strong> {selectedJob.progress}%
</Typography>
<Typography variant="body1" gutterBottom>
<strong>Created:</strong> {new Date(selectedJob.created_at).toLocaleString()}
</Typography>
{selectedJob.started_at && (
<Typography variant="body1" gutterBottom>
<strong>Started:</strong> {new Date(selectedJob.started_at).toLocaleString()}
</Typography>
)}
{selectedJob.completed_at && (
<Typography variant="body1" gutterBottom>
<strong>Completed:</strong> {new Date(selectedJob.completed_at).toLocaleString()}
</Typography>
)}
{selectedJob.result && (
<Box sx={{ mt: 2 }}>
<Typography variant="h6" gutterBottom>
Results:
</Typography>
<pre style={{
backgroundColor: '#f5f5f5',
padding: '16px',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '400px'
}}>
{JSON.stringify(selectedJob.result, null, 2)}
</pre>
</Box>
)}
{selectedJob.error && (
<Alert severity="error" sx={{ mt: 2 }}>
<strong>Error:</strong> {selectedJob.error}
</Alert>
)}
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setJobDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default BackgroundJobManager;

View File

@@ -0,0 +1,746 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Alert,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
ListItemIcon,
Accordion,
AccordionSummary,
AccordionDetails,
Divider,
Tooltip,
Badge,
} from '@mui/material';
import {
Visibility,
MouseOutlined,
Search,
TrendingUp,
TrendingDown,
Insights,
Lightbulb,
Assessment,
Refresh,
ExpandMore,
CheckCircle,
Error as ErrorIcon,
Warning,
Star,
Speed,
Analytics,
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
interface BingInsightsCardProps {
siteUrl?: string;
days?: number;
onInsightsLoaded?: (insights: any) => void;
insights?: {
performance?: PerformanceInsights;
seo?: SEOInsights;
recommendations?: Recommendations;
last_analyzed?: string;
};
loading?: boolean;
error?: string | null;
}
interface PerformanceInsights {
performance_summary: {
total_clicks: number;
total_impressions: number;
avg_ctr: number;
total_queries: number;
};
trends: {
status?: string;
message?: string;
ctr_trend?: {
current: number;
previous: number;
change_percent: number;
direction: string;
};
clicks_trend?: {
current: number;
previous: number;
change_percent: number;
direction: string;
};
trend_strength?: string;
};
performance_indicators: {
ctr_score?: number;
volume_score?: number;
consistency_score?: number;
overall_score?: number;
performance_level: string;
traffic_quality?: string;
growth_potential?: string;
};
insights: string[];
error?: string; // Add error property for error handling
}
interface SEOInsights {
query_analysis: {
total_queries: number;
brand_queries: {
count: number;
clicks: number;
percentage: number;
};
non_brand_queries: {
count: number;
clicks: number;
percentage: number;
};
query_length_distribution: {
short_queries: number;
long_queries: number;
average_length: number;
};
top_categories: Record<string, number>;
};
content_opportunities: Array<{
query: string;
impressions: number;
ctr: number;
opportunity: string;
priority: string;
}>;
technical_insights: {
average_position: number;
average_ctr: number;
position_distribution: {
top_3: number;
top_10: number;
page_2_plus: number;
};
ctr_distribution: {
excellent: number;
good: number;
poor: number;
};
};
seo_recommendations: Array<{
type: string;
priority: string;
recommendation: string;
action: string;
}>;
error?: string; // Add error property for error handling
}
interface Recommendations {
immediate_actions: Array<{
action: string;
priority: string;
description: string;
}>;
content_optimization: Array<{
query: string;
opportunity: string;
priority: string;
}>;
technical_improvements: Array<{
issue: string;
solution: string;
priority: string;
}>;
long_term_strategy: Array<{
strategy: string;
timeline: string;
expected_impact: string;
}>;
priority_score: Record<string, number>;
error?: string; // Add error property for error handling
}
const BingInsightsCard: React.FC<BingInsightsCardProps> = ({
siteUrl = 'https://www.alwrity.com/',
days = 30,
onInsightsLoaded,
insights: propInsights,
loading: propLoading,
error: propError,
}) => {
const [internalLoading, setInternalLoading] = useState(!propInsights);
const [internalError, setInternalError] = useState<string | null>(null);
const [internalInsights, setInternalInsights] = useState<{
performance?: PerformanceInsights;
seo?: SEOInsights;
recommendations?: Recommendations;
last_analyzed?: string;
}>({});
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Use props if available, otherwise use internal state
const loading = propLoading !== undefined ? propLoading : internalLoading;
const error = propError !== undefined ? propError : internalError;
const insights = propInsights || internalInsights;
const loadInsights = useCallback(async () => {
// Only load if we don't have insights passed as props
if (propInsights) return;
// Clear any existing timeout
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
// Debounce the API call to prevent rapid successive requests
debounceTimeoutRef.current = setTimeout(async () => {
try {
setInternalLoading(true);
setInternalError(null);
const response = await apiClient.get('/api/bing-insights/comprehensive', {
params: { site_url: siteUrl, days }
});
console.log('Raw Bing insights response:', response.data.data);
// The API response structure is directly the insights data (no metrics wrapper)
const insightsData = response.data.data;
console.log('Insights data structure:', insightsData);
setInternalInsights(insightsData);
onInsightsLoaded?.(insightsData);
} catch (err: any) {
setInternalError(err.response?.data?.detail || 'Failed to load Bing insights');
} finally {
setInternalLoading(false);
}
}, 300); // 300ms debounce
}, [siteUrl, days, onInsightsLoaded, propInsights]);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const getChangeColor = (change: number) => {
if (change > 0) return 'success';
if (change < 0) return 'error';
return 'default';
};
const getChangeIcon = (change: number) => {
if (change > 0) return <TrendingUp />;
if (change < 0) return <TrendingDown />;
return <TrendingUp style={{ transform: 'rotate(90deg)' }} />;
};
const getPerformanceLevelColor = (level: string) => {
switch (level) {
case 'excellent': return 'success';
case 'good': return 'info';
case 'fair': return 'warning';
case 'needs_improvement': return 'error';
default: return 'default';
}
};
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high': return 'error';
case 'medium': return 'warning';
case 'low': return 'info';
default: return 'default';
}
};
useEffect(() => {
// Only load insights if we don't have them passed as props
if (!propInsights) {
loadInsights();
}
// Cleanup timeout on unmount
return () => {
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
};
}, [loadInsights, propInsights]);
if (loading) {
return (
<Card sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="center" minHeight="200px">
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
Loading Bing insights...
</Typography>
</Box>
</Card>
);
}
if (error) {
return (
<Card sx={{ p: 2 }}>
<Alert severity="error" action={
<IconButton color="inherit" size="small" onClick={loadInsights}>
<Refresh />
</IconButton>
}>
{error}
</Alert>
</Card>
);
}
return (
<Card sx={{ p: 2 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Typography variant="h6" component="h2" display="flex" alignItems="center">
<Search sx={{ mr: 1 }} />
Bing Webmaster Insights
</Typography>
<IconButton onClick={loadInsights} size="small">
<Refresh />
</IconButton>
</Box>
{/* Connection Status and Basic Metrics */}
<Card sx={{ mb: 2, p: 2, bgcolor: 'background.paper' }}>
<Typography variant="subtitle1" gutterBottom display="flex" alignItems="center">
<CheckCircle sx={{ mr: 1, color: 'success.main' }} />
Connection Status
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_clicks || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_impressions || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{(insights.performance?.performance_summary?.avg_ctr || 0).toFixed(2)}%
</Typography>
<Typography variant="body2" color="text.secondary">
Average CTR
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={3}>
<Box textAlign="center">
<Typography variant="h4" color="primary">
{formatNumber(insights.performance?.performance_summary?.total_queries || 0)}
</Typography>
<Typography variant="body2" color="text.secondary">
Total Queries
</Typography>
</Box>
</Grid>
</Grid>
</Card>
{/* Performance Insights */}
{insights.performance && !insights.performance.error && insights.performance.performance_indicators && insights.performance.performance_summary && (
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Assessment sx={{ mr: 1 }} />
<Typography variant="subtitle1">Performance Analysis</Typography>
<Chip
label={insights.performance?.performance_indicators?.performance_level || 'Unknown'}
color={getPerformanceLevelColor(insights.performance?.performance_indicators?.performance_level || 'Unknown')}
size="small"
sx={{ ml: 2 }}
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Performance Summary */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Performance Summary</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Clicks:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_clicks || 0)}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Impressions:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_impressions || 0)}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Average CTR:</Typography>
<Typography variant="body2" fontWeight="bold">
{(insights.performance.performance_summary.avg_ctr || 0).toFixed(2)}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{formatNumber(insights.performance.performance_summary.total_queries || 0)}
</Typography>
</Box>
</Box>
</Grid>
{/* Performance Indicators */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Performance Indicators</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Performance Level:</Typography>
<Chip
label={insights.performance.performance_indicators.performance_level || 'Unknown'}
color={getPerformanceLevelColor(insights.performance.performance_indicators.performance_level || 'Unknown')}
size="small"
/>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Traffic Quality:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.performance.performance_indicators.traffic_quality || 'Unknown'}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Growth Potential:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.performance.performance_indicators.growth_potential || 'Unknown'}
</Typography>
</Box>
{/* Legacy scores if available */}
{insights.performance.performance_indicators.ctr_score !== undefined && (
<Box>
<Box display="flex" justifyContent="space-between" mb={0.5}>
<Typography variant="body2">CTR Score:</Typography>
<Typography variant="body2">{insights.performance.performance_indicators.ctr_score || 0}/100</Typography>
</Box>
<LinearProgress
variant="determinate"
value={insights.performance.performance_indicators.ctr_score || 0}
color={(insights.performance.performance_indicators.ctr_score || 0) > 70 ? 'success' : 'primary'}
/>
</Box>
)}
</Box>
</Grid>
{/* Trends */}
{insights.performance.trends && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Trends</Typography>
{insights.performance.trends.status === 'insufficient_data' ? (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="body2">
{insights.performance.trends.message || 'Detailed analytics data not available for trend analysis'}
</Typography>
</Alert>
) : (
<Grid container spacing={2}>
{insights.performance.trends.ctr_trend && (
<Grid item xs={6}>
<Box display="flex" alignItems="center" gap={1}>
{getChangeIcon(insights.performance.trends.ctr_trend.change_percent || 0)}
<Typography variant="body2">CTR Trend:</Typography>
<Chip
label={`${(insights.performance.trends.ctr_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.ctr_trend.change_percent || 0}%`}
color={getChangeColor(insights.performance.trends.ctr_trend.change_percent || 0)}
size="small"
/>
</Box>
</Grid>
)}
{insights.performance.trends.clicks_trend && (
<Grid item xs={6}>
<Box display="flex" alignItems="center" gap={1}>
{getChangeIcon(insights.performance.trends.clicks_trend.change_percent || 0)}
<Typography variant="body2">Clicks Trend:</Typography>
<Chip
label={`${(insights.performance.trends.clicks_trend.change_percent || 0) > 0 ? '+' : ''}${insights.performance.trends.clicks_trend.change_percent || 0}%`}
color={getChangeColor(insights.performance.trends.clicks_trend.change_percent || 0)}
size="small"
/>
</Box>
</Grid>
)}
</Grid>
)}
</Grid>
)}
{/* Performance Insights */}
{insights.performance.insights && insights.performance.insights.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Key Insights</Typography>
<List dense>
{insights.performance.insights.map((insight, index) => (
<ListItem key={index}>
<ListItemIcon>
<Lightbulb color="primary" />
</ListItemIcon>
<ListItemText primary={insight} />
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* Performance Error Fallback */}
{insights.performance && insights.performance.error && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
Performance insights unavailable: {insights.performance.error}
</Typography>
</Alert>
)}
{/* SEO Insights */}
{insights.seo && !insights.seo.error && insights.seo.query_analysis && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Analytics sx={{ mr: 1 }} />
<Typography variant="subtitle1">SEO Analysis</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Query Analysis */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Query Analysis</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Total Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.total_queries || 0}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Brand Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.brand_queries?.percentage || 0}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Non-Brand Queries:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.non_brand_queries?.percentage || 0}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg Query Length:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.query_analysis?.query_length_distribution?.average_length || 0} chars
</Typography>
</Box>
</Box>
</Grid>
{/* Technical Insights */}
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Technical Performance</Typography>
<Box display="flex" flexDirection="column" gap={1}>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg Position:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.average_position !== undefined
? insights.seo.technical_insights.average_position
: 'N/A'}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Avg CTR:</Typography>
<Typography variant="body2" fontWeight="bold">
{(insights.seo?.technical_insights?.average_ctr || 0).toFixed(2)}%
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Top 3 Positions:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.position_distribution?.top_3 || 0}
</Typography>
</Box>
<Box display="flex" justifyContent="space-between">
<Typography variant="body2">Top 10 Positions:</Typography>
<Typography variant="body2" fontWeight="bold">
{insights.seo?.technical_insights?.position_distribution?.top_10 || 0}
</Typography>
</Box>
</Box>
</Grid>
{/* Content Opportunities */}
{insights.seo?.content_opportunities && insights.seo.content_opportunities.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>Content Opportunities</Typography>
<List dense>
{insights.seo.content_opportunities.slice(0, 3).map((opportunity, index) => (
<ListItem key={index}>
<ListItemIcon>
<Star color="warning" />
</ListItemIcon>
<ListItemText
primary={opportunity.query}
secondary={`${opportunity.impressions} impressions, ${opportunity.ctr.toFixed(2)}% CTR - ${opportunity.opportunity}`}
/>
<Chip
label={opportunity.priority}
color={getPriorityColor(opportunity.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
{/* SEO Recommendations */}
{insights.seo.seo_recommendations && insights.seo.seo_recommendations.length > 0 && (
<Grid item xs={12}>
<Typography variant="subtitle2" gutterBottom>SEO Recommendations</Typography>
<List dense>
{insights.seo.seo_recommendations.map((rec, index) => (
<ListItem key={index}>
<ListItemIcon>
<Lightbulb color="primary" />
</ListItemIcon>
<ListItemText
primary={rec.recommendation}
secondary={rec.action}
/>
<Chip
label={rec.priority}
color={getPriorityColor(rec.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* SEO Error Fallback */}
{insights.seo && insights.seo.error && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Typography variant="body2">
SEO insights unavailable: {insights.seo.error}
</Typography>
</Alert>
)}
{/* Recommendations */}
{insights.recommendations && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>
<Box display="flex" alignItems="center">
<Lightbulb sx={{ mr: 1 }} />
<Typography variant="subtitle1">Actionable Recommendations</Typography>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
{/* Immediate Actions */}
{insights.recommendations.immediate_actions && insights.recommendations.immediate_actions.length > 0 && (
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Immediate Actions</Typography>
<List dense>
{insights.recommendations.immediate_actions.map((action, index) => (
<ListItem key={index}>
<ListItemIcon>
<Speed color="error" />
</ListItemIcon>
<ListItemText
primary={action.action}
secondary={action.description}
/>
<Chip
label={action.priority}
color={getPriorityColor(action.priority)}
size="small"
/>
</ListItem>
))}
</List>
</Grid>
)}
{/* Long-term Strategy */}
{insights.recommendations.long_term_strategy && insights.recommendations.long_term_strategy.length > 0 && (
<Grid item xs={12} md={6}>
<Typography variant="subtitle2" gutterBottom>Long-term Strategy</Typography>
<List dense>
{insights.recommendations.long_term_strategy.map((strategy, index) => (
<ListItem key={index}>
<ListItemIcon>
<TrendingUp color="success" />
</ListItemIcon>
<ListItemText
primary={strategy.strategy}
secondary={`${strategy.timeline} - ${strategy.expected_impact}`}
/>
</ListItem>
))}
</List>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
)}
{/* Last Updated Information */}
{insights.last_analyzed && (
<Box mt={2} p={1} bgcolor="grey.50" borderRadius={1}>
<Typography variant="caption" color="text.secondary" display="flex" alignItems="center">
<Assessment sx={{ mr: 0.5, fontSize: 14 }} />
Last analyzed: {new Date(insights.last_analyzed).toLocaleString()}
</Typography>
</Box>
)}
</Card>
);
};
export default BingInsightsCard;

View File

@@ -3,6 +3,7 @@ import { Box, Typography, Chip, Button, Tooltip } from '@mui/material';
import { PlayArrow } from '@mui/icons-material';
import { ShimmerHeader } from './styled';
import UserBadge from './UserBadge';
import UsageDashboard from './UsageDashboard';
import { DashboardHeaderProps } from './types';
const DashboardHeader: React.FC<DashboardHeaderProps> = ({
@@ -403,6 +404,10 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
</Box>
)}
{rightContent}
{/* Usage Dashboard - Show API usage statistics */}
<UsageDashboard compact={true} />
<UserBadge colorMode="dark" />
</Box>
</Box>

View File

@@ -0,0 +1,501 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Grid,
Chip,
LinearProgress,
Alert,
CircularProgress,
IconButton,
List,
ListItem,
ListItemText,
ListItemIcon,
} from '@mui/material';
import {
Visibility,
MouseOutlined,
Search,
Web,
Refresh,
Info,
CheckCircle,
Error as ErrorIcon,
Warning,
} from '@mui/icons-material';
import { PlatformAnalytics as PlatformAnalyticsType, AnalyticsSummary, PlatformConnectionStatus } from '../../api/analytics';
import { cachedAnalyticsAPI } from '../../api/cachedAnalytics';
import BingInsightsCard from './BingInsightsCard';
import BackgroundJobManager from './BackgroundJobManager';
interface PlatformAnalyticsComponentProps {
platforms?: string[];
showSummary?: boolean;
refreshInterval?: number; // in milliseconds, 0 = no auto-refresh
onDataLoaded?: (data: any) => void;
onRefreshReady?: (refreshFn: () => Promise<void>) => void; // Expose refresh function to parent
}
const PlatformAnalytics: React.FC<PlatformAnalyticsComponentProps> = ({
platforms,
showSummary = true,
refreshInterval = 0,
onDataLoaded,
onRefreshReady,
}) => {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [analyticsData, setAnalyticsData] = useState<Record<string, PlatformAnalyticsType>>({});
const [summary, setSummary] = useState<AnalyticsSummary | null>(null);
const [, setPlatformStatus] = useState<Record<string, PlatformConnectionStatus>>({});
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const loadData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Load platform connection status
const statusResponse = await cachedAnalyticsAPI.getPlatformStatus();
setPlatformStatus(statusResponse.platforms);
// Load analytics data
const analyticsResponse = await cachedAnalyticsAPI.getAnalyticsData(platforms);
setAnalyticsData(analyticsResponse.data as Record<string, PlatformAnalyticsType>);
setSummary(analyticsResponse.summary);
setLastUpdated(new Date());
if (onDataLoaded) {
onDataLoaded({
analytics: analyticsResponse.data,
summary: analyticsResponse.summary,
status: statusResponse.platforms,
});
}
} catch (err: unknown) {
console.error('Error loading analytics data:', err);
let errorMessage = 'Failed to load analytics data';
if (err instanceof Error) {
errorMessage = (err as Error).message;
} else if (typeof err === 'string') {
errorMessage = err;
}
setError(errorMessage);
} finally {
setLoading(false);
}
}, [platforms, onDataLoaded]);
// Method to force refresh (bypass cache)
const forceRefresh = useCallback(async () => {
console.log('🔄 PlatformAnalytics: Force refresh requested');
setLoading(true);
setError(null);
try {
// Clear cache and force fresh data
await cachedAnalyticsAPI.forceRefreshAnalyticsData(platforms);
// Reload data
await loadData();
console.log('✅ PlatformAnalytics: Force refresh completed');
} catch (err) {
console.error('❌ PlatformAnalytics: Force refresh failed:', err);
setError(err instanceof Error ? err.message : 'Failed to refresh data');
} finally {
setLoading(false);
}
}, [platforms, loadData]);
useEffect(() => {
loadData();
// Set up auto-refresh if interval is specified
let interval: NodeJS.Timeout | null = null;
if (refreshInterval > 0) {
interval = setInterval(loadData, refreshInterval);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [platforms, refreshInterval, loadData]);
// Expose refresh function to parent component
useEffect(() => {
if (onRefreshReady) {
onRefreshReady(forceRefresh);
}
}, [onRefreshReady, forceRefresh]);
const getPlatformIcon = (platform: string) => {
switch (platform.toLowerCase()) {
case 'gsc':
return <Search color="primary" />;
case 'wix':
return <Web color="secondary" />;
case 'wordpress':
return <Web color="info" />;
case 'bing':
return <Search color="primary" />;
default:
return <Web />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return 'success';
case 'error':
return 'error';
case 'partial':
return 'warning';
default:
return 'default';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircle color="success" fontSize="small" />;
case 'error':
return <ErrorIcon color="error" fontSize="small" />;
case 'partial':
return <Warning color="warning" fontSize="small" />;
default:
return <Info fontSize="small" />;
}
};
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const renderMetricsCard = (platform: string, data: PlatformAnalyticsType) => {
const metrics = data.metrics;
return (
<Card key={platform} sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getPlatformIcon(platform)}
<Typography variant="h6" component="div">
{platform.toUpperCase()}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon(data.status)}
<Chip
label={data.status}
color={getStatusColor(data.status) as any}
size="small"
/>
</Box>
</Box>
{data.status === 'success' && (
<>
<Grid container spacing={2}>
{metrics.total_clicks !== undefined && (
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<MouseOutlined color="primary" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="h4" color="primary">
{formatNumber(metrics.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Clicks
</Typography>
</Box>
</Grid>
)}
{metrics.total_impressions !== undefined && (
<Grid item xs={6}>
<Box sx={{ textAlign: 'center' }}>
<Visibility color="secondary" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="h4" color="secondary">
{formatNumber(metrics.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Impressions
</Typography>
</Box>
</Grid>
)}
</Grid>
{metrics.avg_ctr !== undefined && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">CTR</Typography>
<Typography variant="body2" fontWeight="bold">
{metrics.avg_ctr}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(metrics.avg_ctr * 10, 100)}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
)}
{metrics.avg_position !== undefined && (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">Avg Position</Typography>
<Typography variant="body2" fontWeight="bold">
{metrics.avg_position.toFixed(1)}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.max(0, 100 - (metrics.avg_position - 1) * 5)}
color="secondary"
sx={{ height: 6, borderRadius: 4 }}
/>
</Box>
)}
{metrics.top_queries && metrics.top_queries.length > 0 && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Top Queries
</Typography>
<List dense>
{metrics.top_queries.slice(0, 3).map((query, index) => (
<ListItem key={index} sx={{ px: 0 }}>
<ListItemIcon sx={{ minWidth: 32 }}>
<Typography variant="caption" color="text.secondary">
{index + 1}
</Typography>
</ListItemIcon>
<ListItemText
primary={query.query}
secondary={`${query.clicks} clicks • ${query.ctr.toFixed(1)}% CTR`}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItem>
))}
</List>
</Box>
)}
</>
)}
{data.status === 'error' && (
<Alert severity="error" sx={{ mt: 1 }}>
{data.error_message || 'Failed to load analytics data'}
</Alert>
)}
{data.status === 'partial' && (
<Alert severity="warning" sx={{ mt: 1 }}>
{data.error_message || 'Limited analytics data available'}
</Alert>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1 }}>
Last updated: {data.last_updated ? new Date(data.last_updated).toLocaleString() : 'Never'}
</Typography>
</CardContent>
</Card>
);
};
const renderSummaryCard = () => {
if (!summary) return null;
return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h6">
Analytics Summary
</Typography>
<IconButton onClick={forceRefresh} disabled={loading} title="Force refresh (bypass cache)">
<Refresh />
</IconButton>
</Box>
<Grid container spacing={3}>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="primary">
{summary.connected_platforms}
</Typography>
<Typography variant="caption" color="text.secondary">
Connected Platforms
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="secondary">
{formatNumber(summary.total_clicks)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Clicks
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="info">
{formatNumber(summary.total_impressions)}
</Typography>
<Typography variant="caption" color="text.secondary">
Total Impressions
</Typography>
</Box>
</Grid>
<Grid item xs={6} sm={3}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h4" color="success">
{summary.overall_ctr}%
</Typography>
<Typography variant="caption" color="text.secondary">
Overall CTR
</Typography>
</Box>
</Grid>
</Grid>
{lastUpdated && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 2, textAlign: 'center' }}>
Last refreshed: {lastUpdated.toLocaleString()}
</Typography>
)}
</CardContent>
</Card>
);
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<CircularProgress />
<Typography variant="body2" sx={{ ml: 2 }}>
Loading analytics data...
</Typography>
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
);
}
return (
<Box>
{showSummary && renderSummaryCard()}
<Grid container spacing={3}>
{Object.entries(analyticsData)
.filter(([platform]) => platform.toLowerCase() !== 'wordpress') // Exclude WordPress analytics
.map(([platform, data]) => (
<Grid item xs={12} sm={6} lg={4} key={platform}>
{renderMetricsCard(platform, data)}
</Grid>
))}
</Grid>
{/* Background Job Manager */}
<Box sx={{ mt: 3 }}>
<BackgroundJobManager
siteUrl="https://www.alwrity.com/"
days={30}
onJobCompleted={(job) => {
console.log('🎉 Background job completed:', job);
// Refresh analytics data when job completes
forceRefresh();
}}
/>
</Box>
{/* Debug Section - Show data structure for all platforms */}
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Debug: Platform Data Structures
</Typography>
{Object.entries(analyticsData).map(([platform, data]) => (
<Box key={platform} sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{platform.toUpperCase()} Data:
</Typography>
<Typography variant="body2" color="text.secondary" sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: '200px',
overflow: 'auto',
border: '1px solid #e0e0e0',
padding: '8px',
borderRadius: '4px',
backgroundColor: '#f5f5f5'
}}>
{JSON.stringify(data, null, 2)}
</Typography>
</Box>
))}
</Box>
{/* Bing Insights Card - Show when Bing is connected */}
{analyticsData.bing && (
<Box sx={{ mt: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Debug: Bing data structure: {JSON.stringify(analyticsData.bing, null, 2)}
</Typography>
{analyticsData.bing.metrics?.connection_status === 'connected' && (
<BingInsightsCard
siteUrl={
analyticsData.bing.metrics?.sites?.[0]?.Url ||
analyticsData.bing.metrics?.sites?.[0]?.url ||
'https://www.alwrity.com/'
}
days={30}
insights={analyticsData.bing.metrics?.insights}
loading={loading}
error={error}
onInsightsLoaded={(insights) => {
console.log('Bing insights loaded:', insights);
}}
/>
)}
</Box>
)}
{Object.keys(analyticsData).length === 0 && (
<Alert severity="info">
No analytics data available. Connect your platforms to see analytics insights.
</Alert>
)}
</Box>
);
};
export default PlatformAnalytics;

View File

@@ -0,0 +1,357 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Chip,
Typography,
Tooltip,
CircularProgress,
Alert,
IconButton,
Menu,
MenuItem,
Divider,
LinearProgress
} from '@mui/material';
import {
TrendingUp,
TrendingDown,
Warning,
CheckCircle,
Refresh,
MoreVert,
Dashboard
} from '@mui/icons-material';
import { apiClient } from '../../api/client';
import { useSubscription } from '../../contexts/SubscriptionContext';
interface UsageStats {
total_calls: number;
total_cost: number;
usage_status: string;
provider_breakdown: Record<string, {
calls: number;
tokens: number;
cost: number;
}>;
}
interface UsageLimits {
limits: {
gemini_calls: number;
openai_calls: number;
anthropic_calls: number;
mistral_calls: number;
tavily_calls: number;
serper_calls: number;
metaphor_calls: number;
firecrawl_calls: number;
stability_calls: number;
monthly_cost: number;
};
}
interface DashboardData {
current_usage: UsageStats;
limits: UsageLimits;
projections: {
projected_monthly_cost: number;
cost_limit: number;
projected_usage_percentage: number;
};
summary: {
total_api_calls_this_month: number;
total_cost_this_month: number;
usage_status: string;
unread_alerts: number;
};
}
interface UsageDashboardProps {
compact?: boolean;
showFullDashboard?: boolean;
}
const UsageDashboard: React.FC<UsageDashboardProps> = ({
compact = true,
showFullDashboard = false
}) => {
const { subscription } = useSubscription();
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const userId = localStorage.getItem('user_id');
const fetchUsageData = async () => {
if (!userId) return;
setLoading(true);
setError(null);
try {
const response = await apiClient.get(`/api/subscription/dashboard/${userId}`);
setDashboardData(response.data.data);
setLastUpdated(new Date());
} catch (err) {
console.error('Error fetching usage data:', err);
setError('Failed to load usage data');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsageData();
}, [userId]);
const handleRefresh = () => {
fetchUsageData();
};
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleViewFullDashboard = () => {
handleMenuClose();
window.open('/billing', '_blank');
};
const getUsageColor = (used: number, limit: number) => {
const percentage = (used / limit) * 100;
if (percentage >= 90) return '#f44336'; // Red
if (percentage >= 75) return '#ff9800'; // Orange
if (percentage >= 50) return '#ffeb3b'; // Yellow
return '#4caf50'; // Green
};
const getUsageStatusIcon = (status: string) => {
switch (status) {
case 'active': return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
case 'warning': return <Warning sx={{ fontSize: 16, color: '#ff9800' }} />;
case 'limit_exceeded': return <Warning sx={{ fontSize: 16, color: '#f44336' }} />;
default: return <CheckCircle sx={{ fontSize: 16, color: '#4caf50' }} />;
}
};
const getProviderDisplayName = (provider: string) => {
const names: Record<string, string> = {
'gemini': 'Gemini',
'openai': 'OpenAI',
'anthropic': 'Claude',
'mistral': 'Mistral',
'tavily': 'Tavily',
'serper': 'Serper',
'metaphor': 'Metaphor',
'firecrawl': 'Firecrawl',
'stability': 'Stability'
};
return names[provider] || provider;
};
if (!subscription || !dashboardData) {
if (loading) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CircularProgress size={16} />
<Typography variant="caption" color="text.secondary">
Loading usage...
</Typography>
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ py: 0.5 }}>
<Typography variant="caption">{error}</Typography>
</Alert>
);
}
return null;
}
if (compact) {
// Compact view - show key metrics as chips
const totalCalls = dashboardData.summary.total_api_calls_this_month;
const totalCost = dashboardData.summary.total_cost_this_month;
const monthlyLimit = dashboardData.limits.limits.monthly_cost;
const usagePercentage = (totalCost / monthlyLimit) * 100;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{/* Total API Calls */}
<Tooltip title={`${totalCalls.toLocaleString()} API calls this month`}>
<Chip
icon={getUsageStatusIcon(dashboardData.summary.usage_status)}
label={`${totalCalls.toLocaleString()}`}
size="small"
variant="outlined"
sx={{
bgcolor: 'rgba(33, 150, 243, 0.1)',
borderColor: '#2196f3',
color: '#1976d2',
fontWeight: 600,
'& .MuiChip-icon': {
color: '#2196f3'
}
}}
/>
</Tooltip>
{/* Monthly Cost */}
<Tooltip title={`$${totalCost.toFixed(2)} of $${monthlyLimit} monthly limit`}>
<Chip
icon={<TrendingUp sx={{ fontSize: 14 }} />}
label={`$${totalCost.toFixed(2)}`}
size="small"
variant="outlined"
sx={{
bgcolor: `${getUsageColor(totalCost, monthlyLimit)}20`,
borderColor: getUsageColor(totalCost, monthlyLimit),
color: getUsageColor(totalCost, monthlyLimit),
fontWeight: 600,
'& .MuiChip-icon': {
color: getUsageColor(totalCost, monthlyLimit)
}
}}
/>
</Tooltip>
{/* Usage Progress */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, minWidth: 60 }}>
<LinearProgress
variant="determinate"
value={Math.min(usagePercentage, 100)}
sx={{
width: 40,
height: 6,
borderRadius: 3,
bgcolor: 'rgba(0,0,0,0.1)',
'& .MuiLinearProgress-bar': {
bgcolor: getUsageColor(totalCost, monthlyLimit),
borderRadius: 3
}
}}
/>
<Typography variant="caption" sx={{ fontSize: '0.7rem', fontWeight: 600 }}>
{usagePercentage.toFixed(0)}%
</Typography>
</Box>
{/* Refresh Button */}
<Tooltip title="Refresh usage data">
<IconButton
size="small"
onClick={handleRefresh}
disabled={loading}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
}}
>
<Refresh sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
{/* More Options */}
<Tooltip title="Usage options">
<IconButton
size="small"
onClick={handleMenuOpen}
sx={{
p: 0.5,
'&:hover': { bgcolor: 'rgba(0,0,0,0.04)' }
}}
>
<MoreVert sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuItem onClick={handleViewFullDashboard}>
<Dashboard sx={{ mr: 1, fontSize: 18 }} />
View Full Dashboard
</MenuItem>
<MenuItem onClick={handleRefresh}>
<Refresh sx={{ mr: 1, fontSize: 18 }} />
Refresh Data
</MenuItem>
{lastUpdated && (
<Box sx={{ px: 2, py: 1 }}>
<Typography variant="caption" color="text.secondary">
Last updated: {lastUpdated.toLocaleTimeString()}
</Typography>
</Box>
)}
</Menu>
</Box>
);
}
// Full dashboard view (for dedicated usage page)
return (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Usage Dashboard
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', gap: 2 }}>
{/* Total Calls */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Total API Calls
</Typography>
<Typography variant="h4" color="primary">
{dashboardData.summary.total_api_calls_this_month.toLocaleString()}
</Typography>
</Box>
{/* Total Cost */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Monthly Cost
</Typography>
<Typography variant="h4" color="secondary">
${dashboardData.summary.total_cost_this_month.toFixed(2)}
</Typography>
<Typography variant="caption" color="text.secondary">
of ${dashboardData.limits.limits.monthly_cost} limit
</Typography>
</Box>
{/* Usage by Provider */}
<Box sx={{ p: 2, border: '1px solid #e0e0e0', borderRadius: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Usage by Provider
</Typography>
{Object.entries(dashboardData.current_usage.provider_breakdown).map(([provider, stats]) => (
<Box key={provider} sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">
{getProviderDisplayName(provider)}
</Typography>
<Typography variant="body2" fontWeight={600}>
{stats.calls.toLocaleString()}
</Typography>
</Box>
))}
</Box>
</Box>
</Box>
);
};
export default UsageDashboard;

View File

@@ -71,6 +71,8 @@ interface OnboardingContextValue {
refresh: () => Promise<void>;
markStepComplete: (stepNumber: number) => void;
clearError: () => void;
initializeOnboarding: () => void;
resetOnboarding: () => void;
}
const OnboardingContext = createContext<OnboardingContextValue | undefined>(undefined);
@@ -145,13 +147,22 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
console.log('OnboardingContext: Clerk loaded, isSignedIn:', isSignedIn);
if (isSignedIn) {
console.log('OnboardingContext: User signed in, fetching data...');
fetchOnboardingData();
console.log('OnboardingContext: User signed in, but waiting for subscription check...');
// Don't automatically fetch onboarding data - let InitialRouteHandler handle the flow
setLoading(false);
} else {
console.log('OnboardingContext: User not signed in, skipping data fetch');
setLoading(false);
}
}, [clerkLoaded, isSignedIn, fetchOnboardingData]);
}, [clerkLoaded, isSignedIn]);
// Separate effect to fetch data when explicitly requested
const initializeOnboarding = useCallback(() => {
if (isSignedIn && clerkLoaded) {
console.log('OnboardingContext: Initializing onboarding data...');
fetchOnboardingData();
}
}, [isSignedIn, clerkLoaded, fetchOnboardingData]);
/**
* Refresh onboarding data (e.g., after completing a step)
@@ -209,6 +220,26 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
setError(null);
}, []);
/**
* Reset onboarding progress and clear cache
*/
const resetOnboarding = useCallback(() => {
console.log('OnboardingContext: Resetting onboarding progress');
// Clear all cached data
sessionStorage.removeItem('onboarding_init');
localStorage.removeItem('onboarding_step');
localStorage.removeItem('onboarding_data');
// Reset state
setData(null);
setError(null);
setLoading(true);
// Re-fetch fresh data
fetchOnboardingData();
}, [fetchOnboardingData]);
/**
* Computed properties
*/
@@ -226,6 +257,8 @@ export const OnboardingProvider: React.FC<OnboardingProviderProps> = ({ children
refresh,
markStepComplete,
clearError,
initializeOnboarding,
resetOnboarding,
};
return (

View File

@@ -57,10 +57,19 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
try {
// Get user ID from localStorage or auth context
const userId = localStorage.getItem('user_id') || 'anonymous';
// Don't make API call if user is anonymous (not authenticated)
if (userId === 'anonymous') {
console.log('SubscriptionContext: User not authenticated, skipping subscription check');
setLoading(false);
return;
}
console.log('SubscriptionContext: Checking subscription for user:', userId);
const response = await apiClient.get(`/api/subscription/status/${userId}`);
const subscriptionData = response.data.data;
console.log('SubscriptionContext: Received subscription data from backend:', subscriptionData);
setSubscription(subscriptionData);
} catch (err) {
console.error('Error checking subscription:', err);
@@ -73,25 +82,9 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
setError(err instanceof Error ? err.message : 'Failed to check subscription');
// Default to free tier on error
setSubscription({
active: true,
plan: 'free',
tier: 'free',
can_use_api: true,
limits: {
gemini_calls: 100,
openai_calls: 100,
anthropic_calls: 100,
mistral_calls: 100,
tavily_calls: 50,
serper_calls: 50,
metaphor_calls: 50,
firecrawl_calls: 50,
stability_calls: 20,
monthly_cost: 5.0
}
});
// 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);
} finally {
setLoading(false);
}
@@ -114,11 +107,19 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
checkSubscription();
};
// Listen for user authentication changes
const handleUserAuth = () => {
console.log('User authenticated, checking subscription...');
checkSubscription();
};
window.addEventListener('subscription-updated', handleSubscriptionUpdate);
window.addEventListener('user-authenticated', handleUserAuth);
return () => {
clearInterval(interval);
window.removeEventListener('subscription-updated', handleSubscriptionUpdate);
window.removeEventListener('user-authenticated', handleUserAuth);
};
}, []);

View File

@@ -0,0 +1,237 @@
/**
* Bing Webmaster OAuth React Hook
* Manages Bing Webmaster Tools OAuth2 authentication state and operations
*/
import { useState, useEffect, useCallback } from 'react';
import { bingOAuthAPI, BingOAuthStatus, BingOAuthResponse } from '../api/bingOAuth';
interface UseBingOAuthReturn {
// Connection state
connected: boolean;
sites: Array<{
id: number;
access_token: string;
scope: string;
created_at: string;
sites: Array<{
id: string;
name: string;
url: string;
status: string;
}>;
}>;
totalSites: number;
// Loading states
isLoading: boolean;
isConnecting: boolean;
// Actions
connect: () => Promise<void>;
disconnect: (tokenId: number) => Promise<void>;
refreshStatus: () => Promise<void>;
// Error handling
error: string | null;
clearError: () => void;
}
export const useBingOAuth = (): UseBingOAuthReturn => {
const [connected, setConnected] = useState<boolean>(false);
const [sites, setSites] = useState<Array<any>>([]);
const [totalSites, setTotalSites] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [lastStatusCheck, setLastStatusCheck] = useState<number>(0);
/**
* Check Bing Webmaster connection status
*/
const checkStatus = useCallback(async () => {
// Throttle status checks to prevent excessive API calls
const now = Date.now();
const THROTTLE_MS = 10000; // 10 seconds - status doesn't change frequently
if (now - lastStatusCheck < THROTTLE_MS) {
console.log('Bing OAuth: Status check throttled (10s)');
return;
}
try {
setIsLoading(true);
setLastStatusCheck(now);
console.log('Bing OAuth: Checking status...');
const status: BingOAuthStatus = await bingOAuthAPI.getStatus();
console.log('Bing OAuth: Status response:', status);
setConnected(status.connected);
setSites(status.sites || []);
setTotalSites(status.total_sites);
console.log('Bing OAuth status checked:', {
connected: status.connected,
sitesCount: status.sites?.length || 0,
totalSites: status.total_sites
});
} catch (error) {
console.error('Error checking Bing OAuth status:', error);
setConnected(false);
setSites([]);
setTotalSites(0);
setError('Failed to check Bing Webmaster connection status');
} finally {
setIsLoading(false);
}
}, [lastStatusCheck]);
/**
* Connect to Bing Webmaster Tools
*/
const connect = useCallback(async () => {
try {
setIsConnecting(true);
setError(null);
console.log('Bing OAuth: Initiating connection...');
// Get authorization URL
console.log('Bing OAuth: Calling bingOAuthAPI.getAuthUrl()...');
const authData: BingOAuthResponse = await bingOAuthAPI.getAuthUrl();
console.log('Bing OAuth: Got auth URL:', authData.auth_url);
// Open OAuth popup window
const popup = window.open(
authData.auth_url,
'bing-oauth',
'width=600,height=700,scrollbars=yes,resizable=yes'
);
if (!popup) {
throw new Error('Failed to open Bing OAuth popup. Please allow popups for this site.');
}
// Listen for popup completion and messages
const messageHandler = (event: MessageEvent) => {
console.log('Bing OAuth: Message received from any source:', {
origin: event.origin,
data: event.data,
dataType: event.data?.type,
source: event.source === popup ? 'our-popup' : 'other',
expectedOrigin: 'https://littery-sonny-unscrutinisingly.ngrok-free.dev',
timestamp: new Date().toISOString()
});
// Log the full message data for debugging
console.log('Bing OAuth: Full message data:', JSON.stringify(event.data, null, 2));
// Check if message is from our expected origin (more reliable than checking source)
console.log('Bing OAuth: Checking origin match...', {
receivedOrigin: event.origin,
expectedOrigin: 'https://littery-sonny-unscrutinisingly.ngrok-free.dev',
originMatch: event.origin === 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'
});
if (event.origin === 'https://littery-sonny-unscrutinisingly.ngrok-free.dev') {
console.log('Bing OAuth: Message from expected origin, processing...');
console.log('Bing OAuth: Message data:', event.data);
console.log('Bing OAuth: Message data type:', event.data?.type);
if (event.data?.type === 'BING_OAUTH_SUCCESS') {
console.log('Bing OAuth: Success message received:', event.data);
popup.close();
window.removeEventListener('message', messageHandler);
// Refresh status after successful connection
setTimeout(() => {
checkStatus();
}, 1000);
} else if (event.data?.type === 'BING_OAUTH_ERROR') {
console.error('Bing OAuth: Error message received:', event.data);
popup.close();
window.removeEventListener('message', messageHandler);
setError(event.data.error || 'Bing OAuth connection failed');
} else {
console.log('Bing OAuth: Unknown message type:', event.data?.type);
console.log('Bing OAuth: Full message data:', event.data);
}
} else {
console.log('Bing OAuth: Message from unexpected origin, ignoring:', event.origin);
}
};
window.addEventListener('message', messageHandler);
// Test if popup is working
console.log('Bing OAuth: Popup opened, waiting for messages...');
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageHandler);
console.log('Bing OAuth: Popup closed, refreshing status...');
console.log('Bing OAuth: Popup closed without receiving success/error message');
// Refresh status after OAuth completion
setTimeout(() => {
checkStatus();
}, 1000);
}
}, 1000);
} catch (error) {
console.error('Error connecting to Bing Webmaster:', error);
setError(error instanceof Error ? error.message : 'Failed to connect to Bing Webmaster');
} finally {
setIsConnecting(false);
}
}, [checkStatus]);
/**
* Disconnect a Bing Webmaster site
*/
const disconnect = useCallback(async (tokenId: number) => {
try {
console.log('Bing OAuth: Disconnecting site with token ID:', tokenId);
await bingOAuthAPI.disconnectSite(tokenId);
console.log('Bing OAuth: Site disconnected successfully');
// Refresh status after disconnection
await checkStatus();
} catch (error) {
console.error('Error disconnecting Bing site:', error);
setError(error instanceof Error ? error.message : 'Failed to disconnect Bing Webmaster site');
}
}, [checkStatus]);
/**
* Refresh connection status
*/
const refreshStatus = useCallback(async () => {
await checkStatus();
}, [checkStatus]);
/**
* Clear error state
*/
const clearError = useCallback(() => {
setError(null);
}, []);
// Check status on mount
useEffect(() => {
checkStatus();
}, [checkStatus]);
return {
connected,
sites,
totalSites,
isLoading,
isConnecting,
connect,
disconnect,
refreshStatus,
error,
clearError
};
};

View File

@@ -26,6 +26,7 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
const [sites, setSites] = useState<WordPressOAuthSite[]>([]);
const [totalSites, setTotalSites] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [lastStatusCheck, setLastStatusCheck] = useState<number>(0);
// Set up authentication
useEffect(() => {
@@ -46,21 +47,32 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
setupAuth();
}, [getToken]);
// Check connection status on mount
useEffect(() => {
checkStatus();
}, []);
const checkStatus = async () => {
const checkStatus = useCallback(async () => {
// Throttle status checks to prevent excessive API calls
const now = Date.now();
const THROTTLE_MS = 10000; // 10 seconds - status doesn't change frequently
if (now - lastStatusCheck < THROTTLE_MS) {
console.log('WordPress OAuth: Status check throttled (10s)');
return;
}
try {
setIsLoading(true);
setLastStatusCheck(now);
console.log('WordPress OAuth: Checking status...');
const status: WordPressOAuthStatus = await wordpressOAuthAPI.getStatus();
console.log('WordPress OAuth: Status response:', status);
setConnected(status.connected);
setSites(status.sites || []);
setTotalSites(status.total_sites);
console.log('WordPress OAuth status checked:', status);
console.log('WordPress OAuth status checked:', {
connected: status.connected,
sitesCount: status.sites?.length || 0,
totalSites: status.total_sites
});
} catch (error) {
console.error('Error checking WordPress OAuth status:', error);
setConnected(false);
@@ -69,7 +81,12 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
} finally {
setIsLoading(false);
}
};
}, [lastStatusCheck]);
// Check connection status on mount
useEffect(() => {
checkStatus();
}, [checkStatus]);
const startOAuthFlow = async () => {
try {
@@ -91,14 +108,23 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
// Listen for popup completion and messages
const messageHandler = (event: MessageEvent) => {
console.log('WordPress OAuth: Message received from any source:', {
origin: event.origin,
data: event.data,
source: event.source === popup ? 'our-popup' : 'other'
});
// Accept messages only from the popup we opened and from trusted origins
const trustedOrigins = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
if (event.source !== popup) return;
if (!trustedOrigins.includes(event.origin)) return;
console.log('WordPress OAuth: Valid message from popup:', event.data);
if (event.data.type === 'WPCOM_OAUTH_SUCCESS') {
popup.close();
clearInterval(checkClosed);
console.log('WordPress OAuth: Success message received, refreshing status...');
// Refresh status after OAuth completion
setTimeout(() => {
checkStatus();
@@ -115,10 +141,15 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
};
window.addEventListener('message', messageHandler);
// Test if popup is working
console.log('WordPress OAuth: Popup opened, waiting for messages...');
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageHandler);
console.log('WordPress OAuth: Popup closed, refreshing status...');
// Refresh status after OAuth completion
setTimeout(() => {
checkStatus();
@@ -161,7 +192,7 @@ export const useWordPressOAuth = (): UseWordPressOAuthReturn => {
const refreshStatus = useCallback(async (): Promise<void> => {
await checkStatus();
}, []);
}, [checkStatus]);
return {
// Connection state

View File

@@ -0,0 +1,205 @@
/**
* Analytics Cache Service
*
* Provides caching for analytics API calls to reduce redundant requests
* and improve performance while managing cache invalidation.
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
interface AnalyticsCacheConfig {
defaultTTL: number; // Default TTL in milliseconds
maxCacheSize: number; // Maximum number of entries to cache
databaseDataTTL?: number; // Special TTL for database-stored data (longer)
}
class AnalyticsCacheService {
private cache = new Map<string, CacheEntry<any>>();
private config: AnalyticsCacheConfig;
constructor(config: Partial<AnalyticsCacheConfig> = {}) {
this.config = {
defaultTTL: 5 * 60 * 1000, // 5 minutes
maxCacheSize: 100,
databaseDataTTL: 2 * 60 * 60 * 1000, // 2 hours for database data
...config
};
}
/**
* Generate cache key from parameters
*/
private generateKey(endpoint: string, params?: Record<string, any>): string {
const sortedParams = params ? Object.keys(params)
.sort()
.reduce((result, key) => {
result[key] = params[key];
return result;
}, {} as Record<string, any>) : {};
return `${endpoint}:${JSON.stringify(sortedParams)}`;
}
/**
* Get cached data if valid
*/
get<T>(endpoint: string, params?: Record<string, any>): T | null {
const key = this.generateKey(endpoint, params);
const entry = this.cache.get(key);
if (!entry) {
return null;
}
// Check if cache entry is still valid
const now = Date.now();
if (now - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
console.log(`📦 Analytics Cache HIT: ${key}`);
return entry.data;
}
/**
* Set cache entry
*/
set<T>(endpoint: string, params: Record<string, any> | undefined, data: T, ttl?: number): void {
const key = this.generateKey(endpoint, params);
const now = Date.now();
// Remove oldest entries if cache is full
if (this.cache.size >= this.config.maxCacheSize) {
this.evictOldest();
}
this.cache.set(key, {
data,
timestamp: now,
ttl: ttl || this.config.defaultTTL
});
console.log(`💾 Analytics Cache SET: ${key} (TTL: ${ttl || this.config.defaultTTL}ms)`);
}
/**
* Set cache entry with database TTL (longer cache for DB-stored data)
*/
setDatabaseData<T>(endpoint: string, params: Record<string, any> | undefined, data: T): void {
const ttl = this.config.databaseDataTTL || this.config.defaultTTL;
this.set(endpoint, params, data, ttl);
console.log(`🗄️ Analytics Cache SET (DB): ${this.generateKey(endpoint, params)} (TTL: ${ttl}ms)`);
}
/**
* Invalidate cache entries matching pattern
*/
invalidate(pattern?: string): void {
if (!pattern) {
this.cache.clear();
console.log('🗑️ Analytics Cache CLEARED: All entries');
return;
}
const keysToDelete: string[] = [];
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
console.log(`🗑️ Analytics Cache INVALIDATED: ${keysToDelete.length} entries matching "${pattern}"`);
}
/**
* Invalidate entries older than specified time
*/
invalidateOlderThan(olderThanMs: number): void {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > olderThanMs) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
console.log(`🗑️ Analytics Cache INVALIDATED: ${keysToDelete.length} entries older than ${olderThanMs}ms`);
}
/**
* Get cache statistics
*/
getStats(): { size: number; keys: string[]; oldestEntry?: number; newestEntry?: number } {
const keys = Array.from(this.cache.keys());
const timestamps = Array.from(this.cache.values()).map(entry => entry.timestamp);
return {
size: this.cache.size,
keys,
oldestEntry: timestamps.length > 0 ? Math.min(...timestamps) : undefined,
newestEntry: timestamps.length > 0 ? Math.max(...timestamps) : undefined
};
}
/**
* Remove oldest cache entry
*/
private evictOldest(): void {
let oldestKey = '';
let oldestTime = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
console.log(`🗑️ Analytics Cache EVICTED: ${oldestKey}`);
}
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now();
const keysToDelete: string[] = [];
for (const [key, entry] of this.cache.entries()) {
if (now - entry.timestamp > entry.ttl) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
if (keysToDelete.length > 0) {
console.log(`🧹 Analytics Cache CLEANUP: Removed ${keysToDelete.length} expired entries`);
}
}
}
// Create singleton instance
export const analyticsCache = new AnalyticsCacheService({
defaultTTL: 60 * 60 * 1000, // 60 minutes for analytics data (since it's stored in DB)
maxCacheSize: 100, // Increased cache size since we're keeping data longer
databaseDataTTL: 2 * 60 * 60 * 1000 // 2 hours for database-stored data
});
// Cleanup expired entries every 5 minutes (since we have longer TTL)
setInterval(() => {
analyticsCache.cleanup();
}, 5 * 60 * 1000);
export default analyticsCache;

View File

@@ -0,0 +1,58 @@
# Analytics Cache Configuration
## 🚀 **Optimized Cache Settings for Maximum Performance**
### **Cache TTL (Time To Live) Configuration**
| Data Type | Previous TTL | **New TTL** | Reason |
|-----------|--------------|-------------|---------|
| Platform Status | 2 minutes | **30 minutes** | Status changes rarely |
| Analytics Data | 3 minutes | **60 minutes** | Data stored in database |
| User Sites | 5 minutes | **120 minutes** | Sites change very rarely |
| Database Data | N/A | **2 hours** | Most aggressive for DB-stored data |
### **Throttling Configuration**
| Component | Previous Throttle | **New Throttle** | Reason |
|-----------|------------------|------------------|---------|
| Bing OAuth Status | 2 seconds | **10 seconds** | Status doesn't change frequently |
| WordPress OAuth Status | 2 seconds | **10 seconds** | Status doesn't change frequently |
### **Cache Management**
| Setting | Value | Purpose |
|---------|-------|---------|
| Max Cache Size | **100 entries** | Increased to accommodate longer TTL |
| Cleanup Interval | **5 minutes** | Optimized for longer cache duration |
| Database Data TTL | **2 hours** | Special handling for DB-stored analytics |
### **Expected Performance Improvements**
- **🔥 95%+ reduction** in redundant API calls
- **💰 Massive cost savings** on API usage
- **⚡ Instant loading** for cached data
- **🧠 Better user experience** with minimal loading states
### **Cache Hit Examples**
```
📦 Analytics Cache HIT: Platform status (cached for 30 minutes)
📦 Analytics Cache HIT: Analytics data (cached for 60 minutes)
📦 Analytics Cache HIT: Analytics data from DB (cached for 2 hours)
🗄️ Analytics Cache SET (DB): /api/analytics/data (TTL: 7200000ms)
```
### **When Cache is Invalidated**
- Platform connection changes (connect/disconnect)
- Manual force refresh
- Manual cache clear
- Natural expiration (after TTL period)
### **Database-First Strategy**
Since analytics data is stored in the database:
- **Primary**: Check cache first
- **Secondary**: Fetch from database via API
- **Tertiary**: Cache for extended periods (2 hours)
- **Result**: Minimal API calls, maximum performance