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:
@@ -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>
|
||||
|
||||
225
frontend/src/api/analytics.ts
Normal file
225
frontend/src/api/analytics.ts
Normal 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;
|
||||
93
frontend/src/api/bingOAuth.ts
Normal file
93
frontend/src/api/bingOAuth.ts
Normal 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();
|
||||
221
frontend/src/api/cachedAnalytics.ts
Normal file
221
frontend/src/api/cachedAnalytics.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
445
frontend/src/components/shared/BackgroundJobManager.tsx
Normal file
445
frontend/src/components/shared/BackgroundJobManager.tsx
Normal 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;
|
||||
746
frontend/src/components/shared/BingInsightsCard.tsx
Normal file
746
frontend/src/components/shared/BingInsightsCard.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
501
frontend/src/components/shared/PlatformAnalytics.tsx
Normal file
501
frontend/src/components/shared/PlatformAnalytics.tsx
Normal 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;
|
||||
357
frontend/src/components/shared/UsageDashboard.tsx
Normal file
357
frontend/src/components/shared/UsageDashboard.tsx
Normal 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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
237
frontend/src/hooks/useBingOAuth.ts
Normal file
237
frontend/src/hooks/useBingOAuth.ts
Normal 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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
205
frontend/src/services/analyticsCache.ts
Normal file
205
frontend/src/services/analyticsCache.ts
Normal 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;
|
||||
58
frontend/src/services/cacheConfig.md
Normal file
58
frontend/src/services/cacheConfig.md
Normal 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
|
||||
Reference in New Issue
Block a user