Subscription dashboard improvements, AI text generation limit, and other fixes.
This commit is contained in:
@@ -58,19 +58,25 @@ const InitialRouteHandler: React.FC = () => {
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Check subscription on mount
|
||||
// Check subscription on mount (non-blocking - don't wait for it to route)
|
||||
useEffect(() => {
|
||||
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: true,
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
});
|
||||
// Delay subscription check slightly to allow auth token getter to be installed first
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkSubscription().catch((err) => {
|
||||
console.error('Error checking subscription (non-blocking):', 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,
|
||||
});
|
||||
}
|
||||
// Don't block routing on subscription check errors - allow graceful degradation
|
||||
});
|
||||
}, 100); // Small delay to ensure TokenInstaller has run
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, []); // Remove checkSubscription dependency to prevent loop
|
||||
|
||||
// Initialize onboarding only after subscription is confirmed
|
||||
@@ -125,9 +131,10 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state - ensure we wait for onboarding init after subscription is confirmed
|
||||
const waitingForOnboardingInit = !!subscription && subscription.active && !subscriptionLoading && (loading || !data);
|
||||
if (subscriptionLoading || loading || waitingForOnboardingInit) {
|
||||
// Loading state - only wait for onboarding init, not subscription check
|
||||
// Subscription check is non-blocking and happens in background
|
||||
const waitingForOnboardingInit = loading || !data;
|
||||
if (loading || waitingForOnboardingInit) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
@@ -167,29 +174,79 @@ const InitialRouteHandler: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return null; // Should not happen, but just in case
|
||||
// Decision tree for SIGNED-IN users:
|
||||
// Priority: Subscription → Onboarding → Dashboard (as per user flow: Landing → Subscription → Onboarding → Dashboard)
|
||||
|
||||
// 1. If subscription is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision tree for SIGNED-IN users:
|
||||
// Priority: Subscription → Onboarding → Dashboard
|
||||
|
||||
// Check if user is new (no subscription record at all)
|
||||
// 2. No subscription data yet - handle gracefully
|
||||
// If onboarding is complete, allow access to dashboard (user already went through flow)
|
||||
// If onboarding not complete, check if subscription check is still loading or failed
|
||||
if (!subscription) {
|
||||
if (isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Onboarding complete but no subscription data → Dashboard (allow access)');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
// Onboarding not complete and no subscription data
|
||||
// If subscription check is still loading, show loading state
|
||||
if (subscriptionLoading) {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
minHeight="100vh"
|
||||
gap={2}
|
||||
>
|
||||
<CircularProgress size={60} />
|
||||
<Typography variant="h6" color="textSecondary">
|
||||
Checking subscription...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Subscription check completed but returned null/undefined
|
||||
// This likely means no subscription - redirect to pricing
|
||||
console.log('InitialRouteHandler: No subscription data after check → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
|
||||
// 3. Check subscription status first
|
||||
const isNewUser = !subscription || subscription.plan === 'none';
|
||||
|
||||
// 1. No active subscription? → Must subscribe first (even if onboarding is complete)
|
||||
// No active subscription → Must subscribe first
|
||||
if (isNewUser || !subscription.active) {
|
||||
console.log('InitialRouteHandler: No active subscription → Pricing page');
|
||||
return <Navigate to="/pricing" replace />;
|
||||
}
|
||||
|
||||
// 2. Has active subscription, check onboarding status
|
||||
// 4. Has active subscription, check onboarding status
|
||||
if (!isOnboardingComplete) {
|
||||
console.log('InitialRouteHandler: Subscription active but onboarding incomplete → Onboarding');
|
||||
return <Navigate to="/onboarding" replace />;
|
||||
}
|
||||
|
||||
// 3. Has subscription AND completed onboarding → Dashboard
|
||||
// 5. Has subscription AND completed onboarding → Dashboard
|
||||
console.log('InitialRouteHandler: All set (subscription + onboarding) → Dashboard');
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,24 @@ export const setGlobalSubscriptionErrorHandler = (handler: (error: any) => boole
|
||||
globalSubscriptionErrorHandler = handler;
|
||||
};
|
||||
|
||||
// Export a function to trigger subscription error handler from outside axios interceptors
|
||||
export const triggerSubscriptionError = (error: any) => {
|
||||
const status = error?.response?.status;
|
||||
console.log('triggerSubscriptionError: Received error', {
|
||||
hasHandler: !!globalSubscriptionErrorHandler,
|
||||
status,
|
||||
dataKeys: error?.response?.data ? Object.keys(error.response.data) : null
|
||||
});
|
||||
|
||||
if (globalSubscriptionErrorHandler) {
|
||||
console.log('triggerSubscriptionError: Calling global subscription error handler');
|
||||
return globalSubscriptionErrorHandler(error);
|
||||
}
|
||||
|
||||
console.warn('triggerSubscriptionError: No global subscription error handler registered');
|
||||
return false;
|
||||
};
|
||||
|
||||
// Optional token getter installed from within the app after Clerk is available
|
||||
let authTokenGetter: (() => Promise<string | null>) | null = null;
|
||||
|
||||
@@ -64,13 +82,27 @@ apiClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`Making ${config.method?.toUpperCase()} request to ${config.url}`);
|
||||
try {
|
||||
const token = authTokenGetter ? await authTokenGetter() : null;
|
||||
if (!authTokenGetter) {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter not set for ${config.url} - request may fail authentication`);
|
||||
console.warn(`[apiClient] This usually means TokenInstaller hasn't run yet. Request will likely fail with 401.`);
|
||||
} else {
|
||||
try {
|
||||
const token = await authTokenGetter();
|
||||
if (token) {
|
||||
config.headers = config.headers || {};
|
||||
(config.headers as any)['Authorization'] = `Bearer ${token}`;
|
||||
console.log(`[apiClient] ✅ Added auth token to request: ${config.url}`);
|
||||
} else {
|
||||
console.warn(`[apiClient] ⚠️ authTokenGetter returned null for ${config.url} - user may not be signed in`);
|
||||
console.warn(`[apiClient] User ID from localStorage: ${localStorage.getItem('user_id') || 'none'}`);
|
||||
}
|
||||
} catch (tokenError) {
|
||||
console.error(`[apiClient] ❌ Error getting auth token for ${config.url}:`, tokenError);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
console.error(`[apiClient] ❌ Unexpected error in request interceptor for ${config.url}:`, e);
|
||||
// non-fatal - let the request proceed, backend will return 401 if needed
|
||||
}
|
||||
return config;
|
||||
},
|
||||
@@ -138,13 +170,17 @@ apiClient.interceptors.response.use(
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
// If retry failed and not in onboarding, redirect
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
// If retry failed, don't redirect during app initialization (root route)
|
||||
// Only redirect if we're on a protected route and definitely authenticated
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization - allow InitialRouteHandler to work
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
// Only redirect if we're definitely not just initializing
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed');
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,12 +240,14 @@ aiApiClient.interceptors.response.use(
|
||||
console.error('Token refresh failed:', retryError);
|
||||
}
|
||||
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized - token refresh failed');
|
||||
console.warn('401 Unauthorized - token refresh failed (during initialization, not redirecting)');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,13 +292,15 @@ longRunningApiClient.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
@@ -304,13 +344,15 @@ pollingApiClient.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
if (error?.response?.status === 401) {
|
||||
// Only redirect on 401 if we're not in onboarding flow
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding') ||
|
||||
window.location.pathname === '/';
|
||||
if (!isOnboardingRoute) {
|
||||
// Only redirect on 401 if we're not in onboarding flow or root route
|
||||
const isOnboardingRoute = window.location.pathname.includes('/onboarding');
|
||||
const isRootRoute = window.location.pathname === '/';
|
||||
|
||||
// Don't redirect from root route during app initialization
|
||||
if (!isRootRoute && !isOnboardingRoute) {
|
||||
try { window.location.assign('/'); } catch {}
|
||||
} else {
|
||||
console.warn('401 Unauthorized during onboarding - token may need refresh');
|
||||
console.warn('401 Unauthorized during initialization - token may need refresh (not redirecting)');
|
||||
}
|
||||
}
|
||||
// Check if it's a subscription-related error and handle it globally
|
||||
|
||||
@@ -66,6 +66,7 @@ export const BlogWriter: React.FC = () => {
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
setOutline,
|
||||
setTitleOptions,
|
||||
setSelectedTitle,
|
||||
@@ -78,6 +79,7 @@ export const BlogWriter: React.FC = () => {
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setSectionImages,
|
||||
handleResearchComplete,
|
||||
handleOutlineComplete,
|
||||
handleOutlineError,
|
||||
@@ -670,6 +672,8 @@ export const BlogWriter: React.FC = () => {
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
outlineGenRef={outlineGenRef}
|
||||
blogWriterApi={blogWriterApi}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
contentConfirmed={contentConfirmed}
|
||||
seoAnalysis={seoAnalysis}
|
||||
seoMetadata={seoMetadata}
|
||||
|
||||
@@ -31,6 +31,8 @@ interface PhaseContentProps {
|
||||
seoMetadata: any;
|
||||
onTitleSelect: any;
|
||||
onCustomTitle: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
}
|
||||
|
||||
export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
@@ -58,7 +60,9 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
seoAnalysis,
|
||||
seoMetadata,
|
||||
onTitleSelect,
|
||||
onCustomTitle
|
||||
onCustomTitle,
|
||||
sectionImages,
|
||||
setSectionImages
|
||||
}) => {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
@@ -100,6 +104,8 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
optimizationResults={optimizationResults}
|
||||
researchCoverage={researchCoverage}
|
||||
onRefine={(op: any, id: any, payload: any) => blogWriterApi.refineOutline({ outline, operation: op, section_id: id, payload }).then((res: any) => setOutline(res.outline))}
|
||||
sectionImages={sectionImages}
|
||||
setSectionImages={setSectionImages}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
@@ -126,6 +132,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
@@ -151,6 +158,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
onSave={handleContentSave}
|
||||
continuityRefresh={continuityRefresh || undefined}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImages={sectionImages}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Box,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import { usePlatformConnections } from '../../../components/OnboardingWizard/common/usePlatformConnections';
|
||||
|
||||
interface WixConnectModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConnectionSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const WixConnectModal: React.FC<WixConnectModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConnectionSuccess
|
||||
}) => {
|
||||
const { handleConnect, isLoading } = usePlatformConnections();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
|
||||
// Handle OAuth success via postMessage (same pattern as onboarding)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handler = (event: MessageEvent) => {
|
||||
const trusted = [window.location.origin, 'https://littery-sonny-unscrutinisingly.ngrok-free.dev'];
|
||||
if (!trusted.includes(event.origin)) return;
|
||||
if (!event.data || typeof event.data !== 'object') return;
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_SUCCESS') {
|
||||
console.log('Wix OAuth success in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
// Close modal and notify parent
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (event.data.type === 'WIX_OAUTH_ERROR') {
|
||||
console.error('Wix OAuth error in modal:', event.data.error);
|
||||
setIsConnecting(false);
|
||||
setError(event.data.error || 'Wix connection failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handler);
|
||||
return () => window.removeEventListener('message', handler);
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
// Also check for URL param (fallback for same-tab redirect)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get('wix_connected') === 'true') {
|
||||
console.log('Wix connected via URL param in modal');
|
||||
setIsConnecting(false);
|
||||
setError(null);
|
||||
if (onConnectionSuccess) {
|
||||
onConnectionSuccess();
|
||||
}
|
||||
onClose();
|
||||
// Clean URL
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean || '/');
|
||||
}
|
||||
}, [isOpen, onClose, onConnectionSuccess]);
|
||||
|
||||
const handleConnectClick = async () => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
setError(null);
|
||||
await handleConnect('wix');
|
||||
// OAuth will redirect, so we don't need to do anything else here
|
||||
// The postMessage handler or URL param handler will close the modal
|
||||
} catch (err: any) {
|
||||
console.error('Error connecting to Wix:', err);
|
||||
setIsConnecting(false);
|
||||
setError(err?.message || 'Failed to start Wix connection. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 2,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, color: '#1e293b' }}>
|
||||
Connect Your Wix Account
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Box sx={{ py: 1 }}>
|
||||
<Typography variant="body2" color="text.secondary" paragraph>
|
||||
Connect your Wix account to publish blog posts directly to your website.
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isConnecting && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, py: 2 }}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Opening Wix authorization page...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2, p: 2, bgcolor: '#f8fafc', borderRadius: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>What happens next:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" component="div" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
<ol style={{ margin: '8px 0 0 20px', padding: 0 }}>
|
||||
<li>You'll be redirected to Wix to authorize ALwrity</li>
|
||||
<li>Grant permissions for blog creation and publishing</li>
|
||||
<li>You'll be redirected back to ALwrity</li>
|
||||
<li>Your blog post will be published automatically</li>
|
||||
</ol>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={isConnecting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleConnectClick}
|
||||
disabled={isConnecting || isLoading}
|
||||
startIcon={isConnecting ? <CircularProgress size={16} /> : undefined}
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect to Wix'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default WixConnectModal;
|
||||
|
||||
@@ -12,6 +12,8 @@ interface Props {
|
||||
groundingInsights?: GroundingInsights | null;
|
||||
optimizationResults?: OptimizationResults | null;
|
||||
researchCoverage?: ResearchCoverage | null;
|
||||
sectionImages?: Record<string, string>;
|
||||
setSectionImages?: (images: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => void;
|
||||
}
|
||||
|
||||
const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
@@ -21,14 +23,15 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
sourceMappingStats,
|
||||
groundingInsights,
|
||||
optimizationResults,
|
||||
researchCoverage
|
||||
researchCoverage,
|
||||
sectionImages = {},
|
||||
setSectionImages
|
||||
}) => {
|
||||
const [editingSection, setEditingSection] = useState<string | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
const [hoveredSection, setHoveredSection] = useState<string | null>(null);
|
||||
const [showAddSection, setShowAddSection] = useState(false);
|
||||
const [imageModalState, setImageModalState] = useState<{ open: boolean; sectionId?: string }>(() => ({ open: false }));
|
||||
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
|
||||
const [newSectionData, setNewSectionData] = useState({
|
||||
heading: '',
|
||||
subheadings: '',
|
||||
@@ -117,8 +120,8 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
|
||||
};
|
||||
})()}
|
||||
onImageGenerated={(imageBase64, sectionId) => {
|
||||
if (sectionId) {
|
||||
setSectionImages(prev => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
if (sectionId && setSectionImages) {
|
||||
setSectionImages((prev: Record<string, string>) => ({ ...prev, [sectionId]: imageBase64 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useCopilotAction } from '@copilotkit/react-core';
|
||||
import { blogWriterApi, BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { BlogSEOMetadataResponse } from '../../services/blogWriterApi';
|
||||
import { apiClient } from '../../api/client';
|
||||
import { wordpressAPI, WordPressSite, WordPressPublishRequest } from '../../api/wordpress';
|
||||
import { validateAndRefreshWixTokens } from '../../utils/wixTokenUtils';
|
||||
import WixConnectModal from './BlogWriterUtils/WixConnectModal';
|
||||
|
||||
interface PublisherProps {
|
||||
buildFullMarkdown: () => string;
|
||||
@@ -26,10 +29,15 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
}) => {
|
||||
const [wixConnectionStatus, setWixConnectionStatus] = useState<WixConnectionStatus | null>(null);
|
||||
const [checkingWixStatus, setCheckingWixStatus] = useState(false);
|
||||
const [wordpressSites, setWordpressSites] = useState<WordPressSite[]>([]);
|
||||
const [checkingWordPressStatus, setCheckingWordPressStatus] = useState(false);
|
||||
const [showWixConnectModal, setShowWixConnectModal] = useState(false);
|
||||
const [pendingWixPublish, setPendingWixPublish] = useState<(() => Promise<any>) | null>(null);
|
||||
|
||||
// Check Wix connection status on component mount
|
||||
// Check platform connection statuses on component mount
|
||||
useEffect(() => {
|
||||
checkWixConnectionStatus();
|
||||
checkWordPressConnectionStatus();
|
||||
}, []);
|
||||
|
||||
const checkWixConnectionStatus = async () => {
|
||||
@@ -48,6 +56,137 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
setCheckingWixStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkWordPressConnectionStatus = async () => {
|
||||
setCheckingWordPressStatus(true);
|
||||
try {
|
||||
const status = await wordpressAPI.getStatus();
|
||||
setWordpressSites(status.sites || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to check WordPress connection status:', error);
|
||||
setWordpressSites([]);
|
||||
} finally {
|
||||
setCheckingWordPressStatus(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to publish to Wix
|
||||
const publishToWix = async (md: string, metadata: BlogSEOMetadataResponse | null, accessToken?: string): Promise<any> => {
|
||||
// Get access token if not provided
|
||||
if (!accessToken) {
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (!tokenResult.accessToken) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens not available. Please connect your Wix account.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
accessToken = tokenResult.accessToken;
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = metadata?.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract cover image URL, skip if base64 (Wix needs HTTP URL)
|
||||
let coverImageUrl: string | undefined = undefined;
|
||||
if (metadata?.open_graph?.image) {
|
||||
const imageUrl = metadata.open_graph.image;
|
||||
// Skip base64 images - Wix import_image needs HTTP/HTTPS URL
|
||||
if (typeof imageUrl === 'string' && (imageUrl.startsWith('http://') || imageUrl.startsWith('https://'))) {
|
||||
coverImageUrl = imageUrl;
|
||||
} else {
|
||||
console.warn('Skipping cover image - Wix requires HTTP/HTTPS URL, received:', imageUrl?.substring(0, 50));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Publish using same endpoint as WixTestPage
|
||||
// Note: Wix requires category/tag IDs (UUIDs), not names
|
||||
// For now, skip categories/tags until we implement ID lookup/creation
|
||||
const response = await apiClient.post('/api/wix/test/publish/real', {
|
||||
title: title,
|
||||
content: md, // Use markdown, backend converts it
|
||||
cover_image_url: coverImageUrl,
|
||||
// TODO: Lookup/create category IDs from metadata?.blog_categories
|
||||
// TODO: Lookup/create tag IDs from metadata?.blog_tags
|
||||
category_ids: undefined,
|
||||
tag_ids: undefined,
|
||||
publish: true,
|
||||
access_token: accessToken,
|
||||
member_id: undefined // Let backend derive from token
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: response.data.url,
|
||||
post_id: response.data.post_id,
|
||||
message: 'Blog post published successfully to Wix!'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If auth error, token may be invalid - try refreshing or reconnect
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
// Try to refresh one more time
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
if (tokenResult.needsReconnect) {
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, metadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix tokens expired. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix'
|
||||
};
|
||||
}
|
||||
// If refresh worked, retry once
|
||||
if (tokenResult.accessToken) {
|
||||
return await publishToWix(md, metadata, tokenResult.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Wix connection success - retry publish
|
||||
const handleWixConnectionSuccess = async () => {
|
||||
if (pendingWixPublish) {
|
||||
const publishFn = pendingWixPublish;
|
||||
setPendingWixPublish(null);
|
||||
// Small delay to ensure tokens are saved in sessionStorage
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// Retry the publish - this will be executed and return result
|
||||
// Note: The result won't show in CopilotKit UI since we're outside the action handler
|
||||
// But the publish will succeed and user will see their blog on Wix
|
||||
const result = await publishFn();
|
||||
console.log('Wix publish after connection:', result);
|
||||
// Optionally show a success notification
|
||||
if (result.success) {
|
||||
// Publish succeeded - user's blog is now on Wix
|
||||
console.log('Blog published to Wix successfully after connection');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error retrying publish after connection:', error);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
// Enhanced publish action with Wix support
|
||||
useCopilotActionTyped({
|
||||
name: 'publishToPlatform',
|
||||
@@ -61,58 +200,101 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
const html = convertMarkdownToHTML(md);
|
||||
|
||||
if (platform === 'wix') {
|
||||
// Check Wix connection status first
|
||||
if (!wixConnectionStatus?.connected) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix account not connected. Please connect your Wix account first using the Wix Test Page.',
|
||||
// Proactively validate and refresh tokens
|
||||
const tokenResult = await validateAndRefreshWixTokens();
|
||||
|
||||
if (tokenResult.needsReconnect || !tokenResult.accessToken) {
|
||||
// Store the publish function to retry after connection
|
||||
const publishFunction = async () => {
|
||||
return await publishToWix(md, seoMetadata);
|
||||
};
|
||||
setPendingWixPublish(() => publishFunction);
|
||||
setShowWixConnectModal(true);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Wix account not connected. Please connect your Wix account to publish.',
|
||||
action_required: 'connect_wix'
|
||||
};
|
||||
}
|
||||
|
||||
if (!wixConnectionStatus?.has_permissions) {
|
||||
|
||||
// We have a valid access token, proceed with publishing
|
||||
return await publishToWix(md, seoMetadata, tokenResult.accessToken);
|
||||
} else if (platform === 'wordpress') {
|
||||
// WordPress publishing
|
||||
if (!seoMetadata) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Insufficient Wix permissions. Please reconnect your Wix account.',
|
||||
action_required: 'reconnect_wix'
|
||||
message: 'Generate SEO metadata first. Use the "Next: Generate SEO Metadata" suggestion to create metadata before publishing.'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract title from markdown (first heading or use default)
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
|
||||
|
||||
// Check if user has connected WordPress sites
|
||||
if (wordpressSites.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No WordPress sites connected. Please connect a WordPress site first. Go to Settings > Integrations to add your WordPress site.',
|
||||
action_required: 'connect_wordpress'
|
||||
};
|
||||
}
|
||||
|
||||
// Find first active site, or use first site if none are active
|
||||
const activeSite = wordpressSites.find(site => site.is_active) || wordpressSites[0];
|
||||
if (!activeSite) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No active WordPress sites found. Please activate a WordPress site connection.',
|
||||
action_required: 'activate_wordpress'
|
||||
};
|
||||
}
|
||||
|
||||
// Extract title from SEO metadata or markdown
|
||||
const title = seoMetadata.seo_title || (() => {
|
||||
const titleMatch = md.match(/^#\s+(.+)$/m);
|
||||
return titleMatch ? titleMatch[1] : 'Blog Post from ALwrity';
|
||||
})();
|
||||
|
||||
// Extract excerpt from SEO metadata
|
||||
const excerpt = seoMetadata.meta_description || '';
|
||||
|
||||
// Build WordPress publish request
|
||||
const publishRequest: WordPressPublishRequest = {
|
||||
site_id: activeSite.id,
|
||||
title: title,
|
||||
content: html,
|
||||
excerpt: excerpt,
|
||||
status: 'publish',
|
||||
meta_description: seoMetadata.meta_description || excerpt,
|
||||
tags: seoMetadata.blog_tags || [],
|
||||
categories: seoMetadata.blog_categories || []
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/publish', {
|
||||
title: title,
|
||||
content: md,
|
||||
publish: true
|
||||
});
|
||||
const result = await wordpressAPI.publishContent(publishRequest);
|
||||
|
||||
if (response.data.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: response.data.url,
|
||||
post_id: response.data.post_id,
|
||||
message: 'Blog post published successfully to Wix!'
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
url: result.post_url || `${activeSite.site_url}/?p=${result.post_id}`,
|
||||
post_id: result.post_id,
|
||||
message: `Blog post published successfully to WordPress site "${activeSite.site_name}"!`
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: response.data.error || 'Failed to publish to Wix'
|
||||
return {
|
||||
success: false,
|
||||
message: result.error || 'Failed to publish to WordPress'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to Wix: ${error.response?.data?.detail || error.message}`
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to publish to WordPress: ${error.response?.data?.detail || error.message || 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// WordPress or other platforms
|
||||
if (!seoMetadata) return { success: false, message: 'Generate SEO metadata first' };
|
||||
const res = await blogWriterApi.publish({ platform, html, metadata: seoMetadata, schedule_time });
|
||||
return { success: true, url: res.url };
|
||||
return {
|
||||
success: false,
|
||||
message: `Unsupported platform: ${platform}. Supported platforms are 'wix' and 'wordpress'.`
|
||||
};
|
||||
}
|
||||
},
|
||||
render: ({ status, result }: any) => {
|
||||
@@ -153,6 +335,13 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{(result?.action_required === 'connect_wordpress' || result?.action_required === 'activate_wordpress') && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<a href="/settings/integrations" target="_blank" rel="noopener noreferrer">
|
||||
Manage WordPress Connections
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +350,18 @@ export const Publisher: React.FC<PublisherProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
return null; // This component only provides the copilot action
|
||||
return (
|
||||
<>
|
||||
<WixConnectModal
|
||||
isOpen={showWixConnectModal}
|
||||
onClose={() => {
|
||||
setShowWixConnectModal(false);
|
||||
setPendingWixPublish(null);
|
||||
}}
|
||||
onConnectionSuccess={handleWixConnectionSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Publisher;
|
||||
|
||||
@@ -145,11 +145,7 @@ export const useSuggestions = ({
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
|
||||
});
|
||||
} else if (seoAnalysis && !seoRecommendationsApplied) {
|
||||
@@ -160,7 +156,7 @@ export const useSuggestions = ({
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Run analyzeContentQuality to review narrative flow and get final improvement suggestions before publishing.'
|
||||
});
|
||||
items.push({
|
||||
@@ -175,33 +171,21 @@ export const useSuggestions = ({
|
||||
message: 'SEO recommendations are applied. Execute generateSEOMetadata immediately so we can prepare titles, descriptions, and schema without further prompts.',
|
||||
priority: 'high'
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
title: 'Next: Publish',
|
||||
message: 'The blog is SEO-optimized. Use publishToPlatform with your preferred destination (wix|wordpress) right away—no additional confirmation needed.',
|
||||
priority: 'high'
|
||||
title: '📊 Content Analysis',
|
||||
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
title: 'Content Analysis',
|
||||
message: 'Run analyzeContentQuality to validate flow, consistency, and progression before publishing.'
|
||||
});
|
||||
items.push({
|
||||
title: 'Publish',
|
||||
message: seoMetadata
|
||||
? 'Publish my blog to your preferred platform using publishToPlatform.'
|
||||
: 'Generate SEO metadata first, then publish your blog.'
|
||||
});
|
||||
|
||||
if (seoMetadata) {
|
||||
} else {
|
||||
// SEO metadata is ready - show publishing options
|
||||
items.push({
|
||||
title: '🚀 Publish to Wix',
|
||||
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".'
|
||||
message: 'Publish my blog to Wix using publishToPlatform with platform "wix".',
|
||||
priority: 'high'
|
||||
});
|
||||
items.push({
|
||||
title: '🌐 Publish to WordPress',
|
||||
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".'
|
||||
message: 'Publish my blog to WordPress using publishToPlatform with platform "wordpress".',
|
||||
priority: 'high'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface BlogEditorProps {
|
||||
onSave?: (content: any) => void;
|
||||
continuityRefresh?: number;
|
||||
flowAnalysisResults?: any;
|
||||
sectionImages?: Record<string, string>;
|
||||
}
|
||||
|
||||
const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
@@ -43,7 +44,8 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
onContentUpdate,
|
||||
onSave,
|
||||
continuityRefresh,
|
||||
flowAnalysisResults
|
||||
flowAnalysisResults,
|
||||
sectionImages = {}
|
||||
}) => {
|
||||
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
@@ -143,17 +145,25 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
|
||||
<Divider sx={{ mt: 3, opacity: 0.3 }} />
|
||||
</div>
|
||||
<div>
|
||||
{sections.map((section) => (
|
||||
<BlogSection
|
||||
key={section.id}
|
||||
{...section}
|
||||
onContentUpdate={onContentUpdate}
|
||||
expandedSections={expandedSections}
|
||||
toggleSectionExpansion={toggleSectionExpansion}
|
||||
refreshToken={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
/>
|
||||
))}
|
||||
{sections.map((section, index) => {
|
||||
// Robust image mapping: prefer outline index id (order is consistent across phases)
|
||||
const imageIdByIndex = outline[index]?.id;
|
||||
const outlineSection = outline.find(s => (s.id === section.id) || (s.heading === section.title));
|
||||
const imageId = imageIdByIndex || outlineSection?.id || section.id;
|
||||
const sectionImage = sectionImages?.[imageId] || null;
|
||||
return (
|
||||
<BlogSection
|
||||
key={section.id}
|
||||
{...section}
|
||||
onContentUpdate={onContentUpdate}
|
||||
expandedSections={expandedSections}
|
||||
toggleSectionExpansion={toggleSectionExpansion}
|
||||
refreshToken={continuityRefresh}
|
||||
flowAnalysisResults={flowAnalysisResults}
|
||||
sectionImage={sectionImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Paper>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +40,7 @@ interface BlogSectionProps {
|
||||
toggleSectionExpansion: (sectionId: any) => void;
|
||||
refreshToken?: number;
|
||||
flowAnalysisResults?: any;
|
||||
sectionImage?: string;
|
||||
}
|
||||
|
||||
const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
@@ -53,7 +54,8 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
expandedSections,
|
||||
toggleSectionExpansion,
|
||||
refreshToken,
|
||||
flowAnalysisResults
|
||||
flowAnalysisResults,
|
||||
sectionImage
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [sectionTitle, setSectionTitle] = useState(title);
|
||||
@@ -181,6 +183,31 @@ const BlogSection: React.FC<BlogSectionProps> = ({
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Section Image Display */}
|
||||
{sectionImage && (
|
||||
<div style={{ marginBottom: '16px', marginTop: '8px' }}>
|
||||
<div style={{
|
||||
border: '1px solid #e0e0e0',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
maxWidth: '100%',
|
||||
backgroundColor: '#fff'
|
||||
}}>
|
||||
<img
|
||||
src={`data:image/png;base64,${sectionImage}`}
|
||||
alt={`Cover image for ${sectionTitle}`}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
display: 'block',
|
||||
maxHeight: '400px',
|
||||
objectFit: 'contain'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="relative"
|
||||
|
||||
@@ -119,25 +119,44 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
const fetchDetailedStats = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/api/content-planning/monitoring/api-stats');
|
||||
const result = response.data;
|
||||
if (result.status === 'success') {
|
||||
setDetailedStats(result.data);
|
||||
if (result.data?.cache_performance) {
|
||||
setCachePerf(result.data.cache_performance);
|
||||
const result = response?.data;
|
||||
|
||||
// Validate response structure
|
||||
if (!result || result.status !== 'success' || !result.data) {
|
||||
console.warn('Invalid response structure from api-stats endpoint:', result);
|
||||
setChartData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = result.data;
|
||||
setDetailedStats(data);
|
||||
|
||||
if (data?.cache_performance) {
|
||||
setCachePerf(data.cache_performance);
|
||||
}
|
||||
|
||||
// Generate chart data
|
||||
const chartData = result.data.top_endpoints.slice(0, 5).map((endpoint: any, index: number) => ({
|
||||
name: endpoint.endpoint.split(' ')[1].split('/').pop() || 'API',
|
||||
requests: endpoint.count,
|
||||
avgTime: endpoint.avg_time,
|
||||
errors: endpoint.errors,
|
||||
hitRate: endpoint.cache_hit_rate
|
||||
// Generate chart data - safely handle missing top_endpoints
|
||||
if (data?.top_endpoints && Array.isArray(data.top_endpoints) && data.top_endpoints.length > 0) {
|
||||
try {
|
||||
const chartData = data.top_endpoints.slice(0, 5).map((endpoint: any) => ({
|
||||
name: endpoint?.endpoint?.split(' ')[1]?.split('/').pop() || 'API',
|
||||
requests: endpoint?.count || 0,
|
||||
avgTime: endpoint?.avg_time || 0,
|
||||
errors: endpoint?.errors || 0,
|
||||
hitRate: endpoint?.cache_hit_rate || 0
|
||||
}));
|
||||
setChartData(chartData);
|
||||
} catch (mapError) {
|
||||
console.error('Error mapping chart data:', mapError);
|
||||
setChartData([]);
|
||||
}
|
||||
} else {
|
||||
// If top_endpoints is missing or not an array, set empty chart data
|
||||
setChartData([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching detailed stats:', err);
|
||||
setChartData([]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -353,7 +372,7 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
)}
|
||||
|
||||
{/* Recent Errors Section */}
|
||||
{detailedStats?.recent_errors && detailedStats.recent_errors.length > 0 && (
|
||||
{detailedStats?.recent_errors && Array.isArray(detailedStats.recent_errors) && detailedStats.recent_errors.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -395,6 +414,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip title={loading ? "Refreshing data..." : "Refresh monitoring data"}>
|
||||
<span>
|
||||
<Button
|
||||
onClick={fetchDetailedStats}
|
||||
variant="contained"
|
||||
@@ -403,6 +424,8 @@ const SystemStatusIndicator: React.FC<SystemStatusIndicatorProps> = ({ className
|
||||
>
|
||||
Refresh Data
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
@@ -56,18 +56,10 @@ export interface PromptSuggestion {
|
||||
}
|
||||
|
||||
export async function fetchPromptSuggestions(payload: any): Promise<PromptSuggestion[]> {
|
||||
const res = await fetch('/api/images/suggest-prompts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || 'Failed to fetch prompt suggestions');
|
||||
}
|
||||
const data = await res.json();
|
||||
return data.suggestions || [];
|
||||
// Use apiClient directly (same pattern as SEO analysis in SEOAnalysisModal.tsx)
|
||||
// The apiClient interceptor will handle auth token injection automatically
|
||||
const response = await apiClient.post('/api/images/suggest-prompts', payload);
|
||||
return response.data.suggestions || [];
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
Modal,
|
||||
Fade,
|
||||
Backdrop,
|
||||
Snackbar,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Check as CheckIcon,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
Star as StarIcon,
|
||||
WorkspacePremium as PremiumIcon,
|
||||
Info as InfoIcon,
|
||||
Warning,
|
||||
Psychology,
|
||||
Search,
|
||||
FactCheck,
|
||||
@@ -83,6 +85,7 @@ const PricingPage: React.FC = () => {
|
||||
const [subscribing, setSubscribing] = useState(false);
|
||||
const [paymentModalOpen, setPaymentModalOpen] = useState(false);
|
||||
const [showSignInPrompt, setShowSignInPrompt] = useState(false);
|
||||
const [successSnackbar, setSuccessSnackbar] = useState({ open: false, message: '', countdown: 3 });
|
||||
const [knowMoreModal, setKnowMoreModal] = useState<{ open: boolean; title: string; content: React.ReactNode }>({
|
||||
open: false,
|
||||
title: '',
|
||||
@@ -172,27 +175,70 @@ const PricingPage: React.FC = () => {
|
||||
setSubscribing(true);
|
||||
const userId = localStorage.getItem('user_id') || 'anonymous';
|
||||
|
||||
await apiClient.post(`/api/subscription/subscribe/${userId}`, {
|
||||
const response = await apiClient.post(`/api/subscription/subscribe/${userId}`, {
|
||||
plan_id: selectedPlan,
|
||||
billing_cycle: yearlyBilling ? 'yearly' : 'monthly'
|
||||
});
|
||||
|
||||
// Refresh subscription status
|
||||
console.log('Subscription renewed successfully:', response.data);
|
||||
|
||||
// Refresh subscription status immediately
|
||||
window.dispatchEvent(new CustomEvent('subscription-updated'));
|
||||
|
||||
// Also trigger user authenticated event to refresh subscription context
|
||||
window.dispatchEvent(new CustomEvent('user-authenticated'));
|
||||
|
||||
setPaymentModalOpen(false);
|
||||
|
||||
// After subscription, check if onboarding is complete
|
||||
// If not complete, redirect to onboarding; otherwise to dashboard
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
if (onboardingComplete) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
// Get plan name for success message
|
||||
const planName = plans.find(p => p.id === selectedPlan)?.name || 'subscription';
|
||||
|
||||
// Show success message with countdown
|
||||
setSuccessSnackbar({
|
||||
open: true,
|
||||
message: `🎉 ${planName} plan activated! Your usage limits have been reset. Returning to your work in 3 seconds...`,
|
||||
countdown: 3
|
||||
});
|
||||
|
||||
// Countdown timer
|
||||
let countdown = 3;
|
||||
const countdownInterval = setInterval(() => {
|
||||
countdown -= 1;
|
||||
if (countdown > 0) {
|
||||
setSuccessSnackbar(prev => ({
|
||||
...prev,
|
||||
message: `🎉 ${planName} plan activated! Your usage limits have been reset. Returning to your work in ${countdown} second${countdown !== 1 ? 's' : ''}...`,
|
||||
countdown
|
||||
}));
|
||||
} else {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Auto-redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
clearInterval(countdownInterval);
|
||||
|
||||
// After subscription, check if onboarding is complete
|
||||
// If not complete, redirect to onboarding; otherwise to dashboard
|
||||
const onboardingComplete = localStorage.getItem('onboarding_complete') === 'true';
|
||||
if (onboardingComplete) {
|
||||
// Try to go back to where the user was (e.g., blog writer)
|
||||
// If no history, go to dashboard
|
||||
const referrer = sessionStorage.getItem('subscription_referrer');
|
||||
if (referrer && referrer !== '/pricing') {
|
||||
navigate(referrer);
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} else {
|
||||
navigate('/onboarding');
|
||||
}
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Error subscribing:', err);
|
||||
setError('Failed to process subscription');
|
||||
setSuccessSnackbar({ open: false, message: '', countdown: 0 });
|
||||
} finally {
|
||||
setSubscribing(false);
|
||||
}
|
||||
@@ -900,32 +946,71 @@ const PricingPage: React.FC = () => {
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 400,
|
||||
width: 450,
|
||||
bgcolor: 'background.paper',
|
||||
border: '2px solid #000',
|
||||
boxShadow: 24,
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
}}>
|
||||
<Typography variant="h6" component="h2" gutterBottom>
|
||||
<Typography variant="h6" component="h2" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ color: 'warning.main' }} />
|
||||
Alpha Testing Subscription
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 3 }}>
|
||||
Thank you for participating in our alpha testing! For the Basic plan, we're crediting $29 to your account.
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
In production, this would integrate with Stripe/Paddle for real payment processing.
|
||||
|
||||
{/* Alpha Testing Notice */}
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
⚠️ Alpha Testing Mode - No Payment Required
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ display: 'block' }}>
|
||||
Payment integration is coming soon. For now, subscriptions are activated without charge.
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
Thank you for participating in our alpha testing! We're crediting the Basic plan ($29 value) to your account.
|
||||
</Typography>
|
||||
|
||||
{/* TODO: Payment Integration Notice */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
mb: 3,
|
||||
bgcolor: 'info.lighter',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'info.light'
|
||||
}}>
|
||||
<Typography variant="body2" color="info.dark">
|
||||
<strong>Coming in Production:</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="info.dark" sx={{ display: 'block', mt: 0.5 }}>
|
||||
• Secure Stripe/PayPal payment processing<br />
|
||||
• Automatic renewal management<br />
|
||||
• Payment verification & receipts<br />
|
||||
• Upgrade/downgrade options
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Note: Current behavior allows renewal without payment verification */}
|
||||
{/* This is intentional for alpha testing but will be secured in production */}
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
|
||||
<Button onClick={() => setPaymentModalOpen(false)}>
|
||||
<Button onClick={() => setPaymentModalOpen(false)} variant="outlined">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handlePaymentConfirm}
|
||||
disabled={subscribing}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{subscribing ? <CircularProgress size={20} /> : 'Confirm Subscription'}
|
||||
{subscribing ? <CircularProgress size={20} sx={{ color: 'white' }} /> : 'Confirm Subscription'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -981,6 +1066,37 @@ const PricingPage: React.FC = () => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Snackbar */}
|
||||
<Snackbar
|
||||
open={successSnackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSuccessSnackbar({ open: false, message: '', countdown: 0 })}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
sx={{
|
||||
top: { xs: 16, sm: 24 },
|
||||
'& .MuiSnackbarContent-root': {
|
||||
minWidth: { xs: '90vw', sm: '500px' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="success"
|
||||
variant="filled"
|
||||
onClose={() => setSuccessSnackbar({ open: false, message: '', countdown: 0 })}
|
||||
sx={{
|
||||
width: '100%',
|
||||
fontSize: '1rem',
|
||||
alignItems: 'center',
|
||||
boxShadow: '0 8px 24px rgba(76, 175, 80, 0.4)',
|
||||
'& .MuiAlert-icon': {
|
||||
fontSize: '2rem'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{successSnackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -39,6 +39,25 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
|
||||
subscriptionData,
|
||||
errorData
|
||||
}) => {
|
||||
// Debug logging to verify modal state
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
console.log('SubscriptionExpiredModal: Modal opened', {
|
||||
open,
|
||||
errorData,
|
||||
hasUsageInfo: !!errorData?.usage_info
|
||||
});
|
||||
}
|
||||
}, [open, errorData]);
|
||||
|
||||
const handleDialogClose = (_event: object, reason?: string) => {
|
||||
if (reason === 'backdropClick') {
|
||||
console.log('SubscriptionExpiredModal: Ignoring backdrop click close');
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRenewClick = () => {
|
||||
onRenewSubscription();
|
||||
onClose();
|
||||
@@ -47,16 +66,21 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onClose={handleDialogClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
disableEscapeKeyDown
|
||||
PaperProps={{
|
||||
sx: {
|
||||
borderRadius: 3,
|
||||
background: 'linear-gradient(135deg, #fff 0%, #f8fafc 100%)',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
zIndex: 9999, // Ensure modal appears above everything
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
zIndex: 9999, // Ensure modal backdrop appears above everything
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ textAlign: 'center', pb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
@@ -93,56 +117,156 @@ const SubscriptionExpiredModal: React.FC<SubscriptionExpiredModalProps> = ({
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ mb: 2, color: 'text.secondary' }}>
|
||||
{/* Main error message */}
|
||||
<Typography variant="body1" sx={{ mb: 2, color: 'text.secondary', lineHeight: 1.6 }}>
|
||||
{errorData?.message || (errorData?.usage_info
|
||||
? 'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.'
|
||||
: 'To continue using Alwrity and access all features, you need to renew your subscription.'
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{/* Detailed usage information */}
|
||||
{errorData?.usage_info && (
|
||||
<Box sx={{ mb: 2, p: 2, background: 'rgba(255,255,255,0.7)', borderRadius: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, mb: 1, color: 'text.primary' }}>
|
||||
<Box sx={{ mb: 2, p: 2.5, background: 'rgba(255,255,255,0.9)', borderRadius: 2, border: '1px solid #e2e8f0' }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, mb: 2, color: 'text.primary', display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Warning sx={{ fontSize: 18, color: 'warning.main' }} />
|
||||
Usage Information:
|
||||
</Typography>
|
||||
{errorData.usage_info.call_usage_percentage && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
You've used {errorData.usage_info.call_usage_percentage.toFixed(1)}% of your monthly limit
|
||||
</Typography>
|
||||
|
||||
{/* Provider and operation type */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||||
{errorData.provider && (
|
||||
<Box sx={{
|
||||
flex: '1 1 auto',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%)',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #a5b4fc'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: '#4338ca', fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Provider:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#312e81', fontWeight: 700 }}>
|
||||
{errorData.provider}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{errorData.usage_info.operation_type && (
|
||||
<Box sx={{
|
||||
flex: '1 1 auto',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #fbbf24'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: '#92400e', fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Operation:
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#78350f', fontWeight: 700, textTransform: 'capitalize' }}>
|
||||
{errorData.usage_info.operation_type.replace(/_/g, ' ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Token usage details (if available) */}
|
||||
{(errorData.usage_info.current_tokens !== undefined || errorData.usage_info.current_calls !== undefined) && (
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
background: 'linear-gradient(135deg, #fee2e2 0%, #fecaca 100%)',
|
||||
borderRadius: 1.5,
|
||||
border: '1px solid #f87171',
|
||||
mb: 2
|
||||
}}>
|
||||
{errorData.usage_info.current_tokens !== undefined && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ color: '#7f1d1d', fontWeight: 600, mb: 1 }}>
|
||||
Token Usage:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1, mb: 0.5 }}>
|
||||
<Typography variant="h6" sx={{ color: '#991b1b', fontWeight: 700 }}>
|
||||
{errorData.usage_info.current_tokens?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#7f1d1d' }}>
|
||||
/ {errorData.usage_info.limit?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', ml: 'auto' }}>
|
||||
({((errorData.usage_info.current_tokens / errorData.usage_info.limit) * 100).toFixed(1)}% used)
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{errorData.usage_info.requested_tokens && (
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', display: 'block', mt: 1 }}>
|
||||
Requested: {errorData.usage_info.requested_tokens.toLocaleString()} tokens
|
||||
{errorData.usage_info.current_tokens + errorData.usage_info.requested_tokens > errorData.usage_info.limit && (
|
||||
<span style={{ fontWeight: 700, marginLeft: 4 }}>
|
||||
(Would exceed by: {((errorData.usage_info.current_tokens + errorData.usage_info.requested_tokens) - errorData.usage_info.limit).toLocaleString()} tokens)
|
||||
</span>
|
||||
)}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{errorData.usage_info.current_calls !== undefined && (
|
||||
<>
|
||||
<Typography variant="body2" sx={{ color: '#7f1d1d', fontWeight: 600, mb: 1, mt: errorData.usage_info.current_tokens !== undefined ? 2 : 0 }}>
|
||||
API Call Usage:
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Typography variant="h6" sx={{ color: '#991b1b', fontWeight: 700 }}>
|
||||
{errorData.usage_info.current_calls?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: '#7f1d1d' }}>
|
||||
/ {errorData.usage_info.call_limit?.toLocaleString() || 0}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: '#7f1d1d', ml: 'auto' }}>
|
||||
({((errorData.usage_info.current_calls / errorData.usage_info.call_limit) * 100).toFixed(1)}% used)
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{errorData.provider && (
|
||||
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||
Provider: {errorData.provider}
|
||||
</Typography>
|
||||
|
||||
{/* Error type badge */}
|
||||
{errorData.usage_info.error_type && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
background: '#dc2626',
|
||||
borderRadius: 1,
|
||||
display: 'inline-block'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'white', fontWeight: 700, textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||
{errorData.usage_info.error_type.replace(/_/g, ' ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Current plan information */}
|
||||
{subscriptionData && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{subscriptionData.plan && (
|
||||
<Box sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0'
|
||||
px: 3,
|
||||
py: 1.5,
|
||||
background: 'rgba(255,255,255,0.9)',
|
||||
borderRadius: 1.5,
|
||||
border: '2px solid #e2e8f0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
Current Plan: {subscriptionData.plan}
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 600, display: 'block', mb: 0.5 }}>
|
||||
Current Plan:
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{subscriptionData.tier && subscriptionData.tier !== subscriptionData.plan && (
|
||||
<Box sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e2e8f0'
|
||||
}}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
Tier: {subscriptionData.tier}
|
||||
<Typography variant="body2" sx={{ color: 'text.primary', fontWeight: 700, textTransform: 'capitalize' }}>
|
||||
{subscriptionData.plan}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -105,12 +105,13 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
/* Enhanced Start Button with Phase 1 Improvements */
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<Tooltip title={tooltipMessage} arrow placement="bottom">
|
||||
<Button
|
||||
variant="contained"
|
||||
size={isFirstVisit ? "medium" : "small"}
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={workflowControls.onStartWorkflow}
|
||||
disabled={workflowControls.isLoading}
|
||||
<span>
|
||||
<Button
|
||||
variant="contained"
|
||||
size={isFirstVisit ? "medium" : "small"}
|
||||
startIcon={<PlayArrow />}
|
||||
onClick={workflowControls.onStartWorkflow}
|
||||
disabled={workflowControls.isLoading}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
@@ -180,8 +181,9 @@ const DashboardHeader: React.FC<DashboardHeaderProps> = ({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{isFirstVisit ? '🚀 Start Journey' : 'Start'}
|
||||
</Button>
|
||||
{isFirstVisit ? '🚀 Start Journey' : 'Start'}
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useCallback, useRef } from 'react';
|
||||
import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client';
|
||||
import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
|
||||
|
||||
@@ -60,6 +60,8 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
// New: Grace window after plan changes to avoid noisy UX
|
||||
const [graceUntil, setGraceUntil] = useState<number>(0);
|
||||
const [planSignature, setPlanSignature] = useState<string>("");
|
||||
// Flag to track if current modal is a usage limit modal (should never be auto-closed)
|
||||
const [isUsageLimitModal, setIsUsageLimitModal] = useState<boolean>(false);
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
// Throttle subscription checks to prevent excessive API calls
|
||||
@@ -86,6 +88,10 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait a moment to ensure auth token getter is installed
|
||||
// This prevents 401 errors during app initialization
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
console.log('SubscriptionContext: Checking subscription for user:', userId);
|
||||
const response = await apiClient.get(`/api/subscription/status/${userId}`);
|
||||
const subscriptionData = response.data.data;
|
||||
@@ -101,29 +107,42 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
setPlanSignature(newSignature);
|
||||
setGraceUntil(Date.now() + 5 * 60 * 1000);
|
||||
// Close any existing modal as plan just changed
|
||||
if (showModal) {
|
||||
// BUT: Don't close usage limit modals - they're important even after plan changes
|
||||
if (showModal && !isUsageLimitModal) {
|
||||
console.log('SubscriptionContext: Plan changed, closing non-usage-limit modal');
|
||||
setShowModal(false);
|
||||
setModalErrorData(null);
|
||||
} else if (showModal && isUsageLimitModal) {
|
||||
console.log('SubscriptionContext: Plan changed but usage limit modal is open, keeping it open');
|
||||
}
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
// If we have a valid subscription and the modal is open, close it
|
||||
// BUT: NEVER close usage limit modals - user needs to see they hit a limit even with active subscription
|
||||
if (subscriptionData && subscriptionData.active && showModal) {
|
||||
console.log('SubscriptionContext: Valid subscription detected, closing modal');
|
||||
setShowModal(false);
|
||||
setModalErrorData(null);
|
||||
setLastModalShowTime(0); // Reset the cooldown timer
|
||||
}
|
||||
|
||||
// Also check if this is a usage limit error that should be suppressed
|
||||
if (subscriptionData && subscriptionData.active && modalErrorData) {
|
||||
const now = Date.now();
|
||||
const timeSinceLastModal = now - lastModalShowTime;
|
||||
|
||||
// If it's been less than 10 minutes since modal was shown for usage limits, keep it closed
|
||||
if (timeSinceLastModal < 600000 && modalErrorData.usage_info) {
|
||||
console.log('SubscriptionContext: Recent usage limit modal, keeping it closed');
|
||||
// Check if this is a usage limit modal (using flag or checking error data)
|
||||
const hasUsageInfo = modalErrorData?.usage_info ||
|
||||
(modalErrorData?.current_tokens !== undefined) ||
|
||||
(modalErrorData?.current_calls !== undefined) ||
|
||||
(modalErrorData?.limit !== undefined) ||
|
||||
(modalErrorData?.requested_tokens !== undefined);
|
||||
|
||||
const isUsageLimit = isUsageLimitModal || hasUsageInfo;
|
||||
|
||||
if (isUsageLimit) {
|
||||
console.log('SubscriptionContext: Usage limit modal detected - KEEPING OPEN (never auto-close usage limit modals)', {
|
||||
isUsageLimitModal,
|
||||
hasUsageInfo,
|
||||
modalErrorDataKeys: modalErrorData ? Object.keys(modalErrorData) : []
|
||||
});
|
||||
// Do NOT close - usage limit modals should stay open until user dismisses them
|
||||
} else {
|
||||
console.log('SubscriptionContext: Non-usage-limit modal detected, closing since subscription is active');
|
||||
setShowModal(false);
|
||||
setModalErrorData(null);
|
||||
setIsUsageLimitModal(false);
|
||||
setLastModalShowTime(0); // Reset the cooldown timer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +175,7 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
setLastModalShowTime(now);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error('Error checking subscription:', err);
|
||||
|
||||
// Check if it's a connection error that should be handled at the app level
|
||||
@@ -165,6 +184,16 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Handle 401 errors gracefully during initialization - don't block routing
|
||||
// 401 might happen if auth token getter isn't ready yet
|
||||
if (err?.response?.status === 401) {
|
||||
console.warn('Subscription check failed with 401 - auth may not be ready yet, will retry later');
|
||||
setError(null); // Don't set error for 401 during init
|
||||
setLoading(false);
|
||||
// Don't throw - allow routing to proceed, subscription check will retry later
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : 'Failed to check subscription');
|
||||
|
||||
// Don't default to free tier on error - preserve existing subscription or leave null
|
||||
@@ -173,21 +202,30 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil]);
|
||||
}, [lastCheckTime, planSignature, showModal, modalErrorData, lastModalShowTime, graceUntil, isUsageLimitModal]);
|
||||
|
||||
const refreshSubscription = useCallback(async () => {
|
||||
await checkSubscription();
|
||||
}, [checkSubscription]);
|
||||
|
||||
const showExpiredModal = useCallback(() => {
|
||||
setIsUsageLimitModal(false);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
|
||||
const hideExpiredModal = useCallback(() => {
|
||||
console.log('SubscriptionExpiredModal: User manually closed modal');
|
||||
setShowModal(false);
|
||||
setIsUsageLimitModal(false); // Reset flag when user closes modal
|
||||
setModalErrorData(null);
|
||||
}, []);
|
||||
|
||||
const handleRenewSubscription = useCallback(() => {
|
||||
// Save current location so we can return after renewal
|
||||
const currentPath = window.location.pathname;
|
||||
sessionStorage.setItem('subscription_referrer', currentPath);
|
||||
|
||||
console.log('SubscriptionContext: Navigating to pricing page, saved referrer:', currentPath);
|
||||
window.location.href = '/pricing';
|
||||
}, []);
|
||||
|
||||
@@ -203,42 +241,131 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// If we have subscription data and it's active, always suppress modal for usage limits
|
||||
if (subscription && subscription.active) {
|
||||
console.log('SubscriptionContext: Active subscription; suppressing usage-limit modal');
|
||||
return true; // Do not show modal for active plan usage limits
|
||||
// Check if this is a usage limit error (status 429) vs subscription expired (402)
|
||||
let errorData = error.response?.data || {};
|
||||
|
||||
// DEBUG: Log the raw error data structure
|
||||
console.log('SubscriptionContext: Raw error data', {
|
||||
type: typeof errorData,
|
||||
isArray: Array.isArray(errorData),
|
||||
data: errorData,
|
||||
stringified: JSON.stringify(errorData)
|
||||
});
|
||||
|
||||
// If errorData is an array, extract the first element (common FastAPI response format)
|
||||
if (Array.isArray(errorData)) {
|
||||
console.log('SubscriptionContext: errorData is array, extracting first element');
|
||||
errorData = errorData[0] || {};
|
||||
}
|
||||
|
||||
// If we don't have subscription data yet, defer the decision
|
||||
if (!subscription) {
|
||||
console.log('SubscriptionContext: No subscription data yet, deferring modal decision');
|
||||
setDeferredError(error);
|
||||
return true; // Handle the error but don't show modal yet
|
||||
}
|
||||
|
||||
// If subscription is not active, show modal immediately
|
||||
if (!subscription.active) {
|
||||
console.log('SubscriptionContext: Inactive subscription, showing modal immediately');
|
||||
const errorData = error.response?.data || {};
|
||||
setModalErrorData({
|
||||
provider: errorData.provider,
|
||||
usage_info: errorData.usage_info,
|
||||
message: errorData.message || errorData.error
|
||||
|
||||
// Check for usage_info in various possible locations
|
||||
const usageInfo = errorData.usage_info ||
|
||||
(errorData.current_calls !== undefined ? errorData : null) ||
|
||||
null;
|
||||
|
||||
// Usage limit error: 429 status with usage info OR 429 status without explicit expiration
|
||||
const isUsageLimitError = status === 429 && (usageInfo || errorData.provider || errorData.message);
|
||||
const isSubscriptionExpired = status === 402 || (status === 429 && !isUsageLimitError);
|
||||
|
||||
console.log('SubscriptionContext: Error analysis', {
|
||||
status,
|
||||
isUsageLimitError,
|
||||
isSubscriptionExpired,
|
||||
hasUsageInfo: !!usageInfo,
|
||||
errorDataType: typeof errorData,
|
||||
errorDataKeys: typeof errorData === 'object' && !Array.isArray(errorData) ? Object.keys(errorData) : 'not-an-object',
|
||||
errorData: errorData
|
||||
});
|
||||
|
||||
// For usage limit errors (429 with usage_info), always show modal - even for active subscriptions
|
||||
// Ignore grace window and cooldown for usage limit errors (user needs to know immediately)
|
||||
if (isUsageLimitError) {
|
||||
const modalData = {
|
||||
provider: errorData.provider || usageInfo?.provider || 'unknown',
|
||||
usage_info: usageInfo || errorData,
|
||||
message: errorData.message || errorData.error || 'You have reached your usage limit.'
|
||||
};
|
||||
|
||||
console.log('SubscriptionContext: Usage limit exceeded, showing modal (ignoring grace window/cooldown)', {
|
||||
modalData,
|
||||
errorData: Object.keys(errorData),
|
||||
usageInfo: usageInfo ? Object.keys(usageInfo) : null
|
||||
});
|
||||
|
||||
// Set flag to mark this as a usage limit modal (should never be auto-closed)
|
||||
setIsUsageLimitModal(true);
|
||||
setModalErrorData(modalData);
|
||||
setShowModal(true);
|
||||
setLastModalShowTime(now);
|
||||
|
||||
console.log('SubscriptionContext: Modal state updated - showModal should be true, isUsageLimitModal = true');
|
||||
return true;
|
||||
}
|
||||
|
||||
// For subscription expired errors, handle based on subscription status
|
||||
if (isSubscriptionExpired) {
|
||||
// If we have subscription data and it's active, this shouldn't happen but suppress anyway
|
||||
if (subscription && subscription.active) {
|
||||
console.log('SubscriptionContext: Active subscription but got expired error, suppressing modal');
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we don't have subscription data yet, defer the decision
|
||||
if (!subscription) {
|
||||
console.log('SubscriptionContext: No subscription data yet, deferring modal decision');
|
||||
setDeferredError(error);
|
||||
return true; // Handle the error but don't show modal yet
|
||||
}
|
||||
|
||||
// If subscription is not active, show modal immediately
|
||||
if (!subscription.active) {
|
||||
console.log('SubscriptionContext: Inactive subscription, showing modal immediately');
|
||||
setIsUsageLimitModal(false);
|
||||
setModalErrorData({
|
||||
provider: errorData.provider,
|
||||
usage_info: errorData.usage_info,
|
||||
message: errorData.message || errorData.error
|
||||
});
|
||||
setShowModal(true);
|
||||
setLastModalShowTime(now);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // Not a subscription error
|
||||
}, [subscription]);
|
||||
|
||||
// Register the global error handler with the API client
|
||||
// Use a ref to ensure the latest handler is always used
|
||||
const handlerRef = useRef(globalSubscriptionErrorHandler);
|
||||
useEffect(() => {
|
||||
handlerRef.current = globalSubscriptionErrorHandler;
|
||||
}, [globalSubscriptionErrorHandler]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('SubscriptionContext: Registering global subscription error handler');
|
||||
setGlobalSubscriptionErrorHandler(globalSubscriptionErrorHandler);
|
||||
}, [globalSubscriptionErrorHandler]);
|
||||
setGlobalSubscriptionErrorHandler((error: any) => {
|
||||
// Always use the latest handler from ref
|
||||
return handlerRef.current(error);
|
||||
});
|
||||
|
||||
// Cleanup: Don't remove the handler on unmount - it should persist
|
||||
// This ensures errors can still be caught even during component transitions
|
||||
}, []); // Empty deps - only register once, but handler ref updates automatically
|
||||
|
||||
useEffect(() => {
|
||||
const eventHandler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
console.log('SubscriptionContext: Received subscription-error event fallback', customEvent.detail);
|
||||
handlerRef.current(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener('subscription-error', eventHandler as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('subscription-error', eventHandler as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check subscription on mount
|
||||
|
||||
@@ -33,6 +33,9 @@ export const useBlogWriterState = () => {
|
||||
// Content confirmation state
|
||||
const [contentConfirmed, setContentConfirmed] = useState<boolean>(false);
|
||||
|
||||
// Section images state - persists images generated in outline phase to content phase
|
||||
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
|
||||
|
||||
// Cache recovery - restore most recent research on page load
|
||||
useEffect(() => {
|
||||
const cachedEntries = researchCache.getAllCachedEntries();
|
||||
@@ -211,6 +214,7 @@ export const useBlogWriterState = () => {
|
||||
contentConfirmed,
|
||||
flowAnalysisCompleted,
|
||||
flowAnalysisResults,
|
||||
sectionImages,
|
||||
|
||||
// Setters
|
||||
setResearch,
|
||||
@@ -233,6 +237,7 @@ export const useBlogWriterState = () => {
|
||||
setContentConfirmed,
|
||||
setFlowAnalysisCompleted,
|
||||
setFlowAnalysisResults,
|
||||
setSectionImages,
|
||||
|
||||
// Handlers
|
||||
handleResearchComplete,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { blogWriterApi, TaskStatusResponse } from '../services/blogWriterApi';
|
||||
import { triggerSubscriptionError } from '../api/client';
|
||||
|
||||
export interface UsePollingOptions {
|
||||
interval?: number; // Polling interval in milliseconds
|
||||
@@ -108,6 +109,43 @@ export function usePolling(
|
||||
console.log('❌ Task failed - stopping polling immediately');
|
||||
setError(status.error || 'Task failed');
|
||||
onError?.(status.error || 'Task failed');
|
||||
|
||||
// Check if this is a subscription error and trigger modal
|
||||
if (status.error_status === 429 || status.error_status === 402) {
|
||||
console.log('usePolling: Detected subscription error in task status', {
|
||||
error_status: status.error_status,
|
||||
error_data: status.error_data,
|
||||
error: status.error
|
||||
});
|
||||
|
||||
// Create a mock error object with the subscription error data
|
||||
const errorData = status.error_data || {};
|
||||
|
||||
// Ensure usage_info is properly nested - it might be at the top level or nested
|
||||
const usageInfo = errorData.usage_info ||
|
||||
(errorData.current_calls !== undefined ? errorData : null) ||
|
||||
errorData;
|
||||
|
||||
const mockError = {
|
||||
response: {
|
||||
status: status.error_status,
|
||||
data: {
|
||||
error: errorData.error || status.error || 'Subscription limit exceeded',
|
||||
message: errorData.message || errorData.error || status.error || 'You have reached your usage limit.',
|
||||
provider: errorData.provider || usageInfo?.provider || 'unknown',
|
||||
usage_info: usageInfo
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
console.log('usePolling: Triggering subscription error handler with:', mockError);
|
||||
const handled = triggerSubscriptionError(mockError);
|
||||
|
||||
if (!handled) {
|
||||
console.warn('usePolling: Subscription error handler did not handle the error');
|
||||
}
|
||||
}
|
||||
|
||||
stopPolling();
|
||||
return; // Exit early to prevent further processing
|
||||
}
|
||||
@@ -117,6 +155,38 @@ export function usePolling(
|
||||
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
||||
console.error('Polling error:', errorMessage);
|
||||
|
||||
// Check if this is an axios error with subscription limit status
|
||||
// This is a fallback in case the interceptor doesn't catch it
|
||||
const axiosError = err as any;
|
||||
if (axiosError?.response?.status === 429 || axiosError?.response?.status === 402) {
|
||||
console.log('usePolling: Detected subscription error in axios error response', {
|
||||
status: axiosError.response.status,
|
||||
data: axiosError.response.data
|
||||
});
|
||||
|
||||
// Trigger subscription error handler (modal will show)
|
||||
const handled = triggerSubscriptionError(axiosError);
|
||||
console.log('usePolling: triggerSubscriptionError returned', handled);
|
||||
|
||||
if (handled) {
|
||||
console.log('usePolling: Subscription error handled, stopping polling');
|
||||
const errorMsg = axiosError.response?.data?.message ||
|
||||
axiosError.response?.data?.error ||
|
||||
'Subscription limit exceeded';
|
||||
setError(errorMsg);
|
||||
onError?.(errorMsg);
|
||||
stopPolling();
|
||||
return; // Exit early - don't continue processing
|
||||
} else {
|
||||
console.warn('usePolling: Subscription error not handled by global handler, dispatching fallback event');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('subscription-error', { detail: axiosError }));
|
||||
} catch (eventError) {
|
||||
console.error('usePolling: Failed to dispatch subscription-error event', eventError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop polling for task failures and rate limiting
|
||||
if (errorMessage.includes('404') || errorMessage.includes('Task not found')) {
|
||||
setError('Task not found - it may have expired or been cleaned up');
|
||||
|
||||
@@ -219,9 +219,22 @@ export interface BlogSEOMetadataResponse {
|
||||
success: boolean;
|
||||
title_options: string[];
|
||||
meta_descriptions: string[];
|
||||
seo_title?: string;
|
||||
meta_description?: string;
|
||||
url_slug?: string;
|
||||
blog_tags: string[];
|
||||
blog_categories: string[];
|
||||
social_hashtags: string[];
|
||||
open_graph: Record<string, any>;
|
||||
twitter_card: Record<string, any>;
|
||||
schema: Record<string, any>;
|
||||
json_ld_schema?: Record<string, any>;
|
||||
schema?: Record<string, any>; // Legacy field name
|
||||
canonical_url?: string;
|
||||
reading_time?: number;
|
||||
focus_keyword?: string;
|
||||
generated_at?: string;
|
||||
optimization_score?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BlogPublishResponse {
|
||||
@@ -241,6 +254,26 @@ export interface TaskStatusResponse {
|
||||
}>;
|
||||
result?: BlogResearchResponse;
|
||||
error?: string;
|
||||
// Subscription error details (set by backend when subscription limit is exceeded)
|
||||
error_status?: number; // HTTP status code (429 for usage limit, 402 for subscription expired)
|
||||
error_data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
provider?: string;
|
||||
usage_info?: {
|
||||
provider?: string;
|
||||
current_calls?: number;
|
||||
limit?: number;
|
||||
type?: string;
|
||||
breakdown?: {
|
||||
gemini?: number;
|
||||
openai?: number;
|
||||
anthropic?: number;
|
||||
mistral?: number;
|
||||
};
|
||||
};
|
||||
[key: string]: any; // Allow additional fields
|
||||
};
|
||||
}
|
||||
|
||||
export const blogWriterApi = {
|
||||
|
||||
198
frontend/src/utils/wixTokenUtils.ts
Normal file
198
frontend/src/utils/wixTokenUtils.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Wix Token Utilities
|
||||
* Functions for validating and refreshing Wix OAuth tokens
|
||||
*/
|
||||
|
||||
import { apiClient } from '../api/client';
|
||||
|
||||
interface WixTokens {
|
||||
accessToken?: {
|
||||
value: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
refreshToken?: {
|
||||
value: string;
|
||||
};
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}
|
||||
|
||||
interface TokenValidationResult {
|
||||
valid: boolean;
|
||||
accessToken: string | null;
|
||||
needsRefresh: boolean;
|
||||
needsReconnect: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Wix tokens from sessionStorage
|
||||
*/
|
||||
export function getWixTokens(): WixTokens | null {
|
||||
try {
|
||||
const tokensRaw = sessionStorage.getItem('wix_tokens');
|
||||
if (!tokensRaw) return null;
|
||||
return JSON.parse(tokensRaw);
|
||||
} catch (error) {
|
||||
console.error('Error parsing Wix tokens:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract access token from token structure
|
||||
*/
|
||||
export function extractAccessToken(tokens: WixTokens | null): string | null {
|
||||
if (!tokens) return null;
|
||||
return tokens.accessToken?.value || tokens.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract refresh token from token structure
|
||||
*/
|
||||
export function extractRefreshToken(tokens: WixTokens | null): string | null {
|
||||
if (!tokens) return null;
|
||||
return tokens.refreshToken?.value || tokens.refresh_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Wix access token using refresh token
|
||||
*/
|
||||
export async function refreshWixToken(refreshToken: string): Promise<WixTokens | null> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/wix/refresh-token', {
|
||||
refresh_token: refreshToken
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
// Create new token structure matching Wix SDK format
|
||||
const newTokens: WixTokens = {
|
||||
accessToken: {
|
||||
value: response.data.access_token
|
||||
},
|
||||
refreshToken: {
|
||||
value: response.data.refresh_token || refreshToken // Keep old refresh token if new one not provided
|
||||
},
|
||||
access_token: response.data.access_token,
|
||||
refresh_token: response.data.refresh_token || refreshToken
|
||||
};
|
||||
|
||||
// Update sessionStorage
|
||||
try {
|
||||
sessionStorage.setItem('wix_tokens', JSON.stringify(newTokens));
|
||||
sessionStorage.setItem('wix_connected', 'true');
|
||||
} catch (e) {
|
||||
console.error('Error saving refreshed tokens:', e);
|
||||
}
|
||||
|
||||
return newTokens;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error('Error refreshing Wix token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token is expired based on expiresAt timestamp
|
||||
*/
|
||||
function isTokenExpired(tokens: WixTokens): boolean {
|
||||
if (tokens.accessToken?.expiresAt) {
|
||||
try {
|
||||
const expiresAt = new Date(tokens.accessToken.expiresAt);
|
||||
return expiresAt < new Date();
|
||||
} catch (e) {
|
||||
// If we can't parse, assume not expired (will validate during publish)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// If no expiration info, we can't tell - assume valid for now
|
||||
// Real validation happens during actual API call
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and refresh Wix tokens proactively
|
||||
* Returns access token if valid, or null if needs reconnection
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Check if tokens exist
|
||||
* 2. Check if token is expired (if expiration info available)
|
||||
* 3. If expired, attempt refresh
|
||||
* 4. If refresh fails or no refresh token, needs reconnection
|
||||
* 5. Real validation happens during actual publish (we catch 401/403 errors)
|
||||
*/
|
||||
export async function validateAndRefreshWixTokens(): Promise<TokenValidationResult> {
|
||||
const tokens = getWixTokens();
|
||||
|
||||
if (!tokens) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
const accessToken = extractAccessToken(tokens);
|
||||
const refreshToken = extractRefreshToken(tokens);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if token is expired (if we have expiration info)
|
||||
const expired = isTokenExpired(tokens);
|
||||
|
||||
if (!expired) {
|
||||
// Token appears valid (not expired or no expiration info)
|
||||
// We'll do real validation during publish
|
||||
return {
|
||||
valid: true,
|
||||
accessToken: accessToken,
|
||||
needsRefresh: false,
|
||||
needsReconnect: false
|
||||
};
|
||||
}
|
||||
|
||||
// Token is expired, try to refresh
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
// Attempt to refresh token
|
||||
const refreshedTokens = await refreshWixToken(refreshToken);
|
||||
|
||||
if (refreshedTokens) {
|
||||
const newAccessToken = extractAccessToken(refreshedTokens);
|
||||
if (newAccessToken) {
|
||||
return {
|
||||
valid: true,
|
||||
accessToken: newAccessToken,
|
||||
needsRefresh: true,
|
||||
needsReconnect: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh failed, needs reconnection
|
||||
return {
|
||||
valid: false,
|
||||
accessToken: null,
|
||||
needsRefresh: false,
|
||||
needsReconnect: true
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user