story writer backend migration complete, Blog writer SEO and story writer backend migration complete, Blog writer SEO and story writer frontend migration complete

This commit is contained in:
ajaysi
2025-11-13 16:14:26 +05:30
parent 7191c7e7f0
commit 3b9356e2c8
124 changed files with 20055 additions and 1208 deletions

View File

@@ -11,6 +11,7 @@ import ContentPlanningDashboard from './components/ContentPlanningDashboard/Cont
import FacebookWriter from './components/FacebookWriter/FacebookWriter';
import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
import BlogWriter from './components/BlogWriter/BlogWriter';
import StoryWriter from './components/StoryWriter/StoryWriter';
import PricingPage from './components/Pricing/PricingPage';
import WixTestPage from './components/WixTestPage/WixTestPage';
import WixCallbackPage from './components/WixCallbackPage/WixCallbackPage';
@@ -19,6 +20,7 @@ import BingCallbackPage from './components/BingCallbackPage/BingCallbackPage';
import BingAnalyticsStorage from './components/BingAnalyticsStorage/BingAnalyticsStorage';
import ResearchTest from './pages/ResearchTest';
import SchedulerDashboard from './pages/SchedulerDashboard';
import BillingPage from './pages/BillingPage';
import ProtectedRoute from './components/shared/ProtectedRoute';
import GSCAuthCallback from './components/SEODashboard/components/GSCAuthCallback';
import Landing from './components/Landing/Landing';
@@ -31,6 +33,7 @@ import { CopilotKitHealthProvider } from './contexts/CopilotKitHealthContext';
import { useOAuthTokenAlerts } from './hooks/useOAuthTokenAlerts';
import { setAuthTokenGetter, setClerkSignOut } from './api/client';
import { setBillingAuthTokenGetter } from './services/billingService';
import { useOnboarding } from './contexts/OnboardingContext';
import { useState, useEffect } from 'react';
import ConnectionErrorPage from './components/shared/ConnectionErrorPage';
@@ -246,10 +249,25 @@ const InitialRouteHandler: React.FC = () => {
// 3. Check subscription status first
const isNewUser = !subscription || subscription.plan === 'none';
// No active subscription → Must subscribe first
// No active subscription → Show modal (SubscriptionContext handles this)
// Don't redirect immediately - let the modal show first
// User can click "Renew Subscription" button in modal to go to pricing
// Or click "Maybe Later" to dismiss (but they still can't use features)
if (isNewUser || !subscription.active) {
console.log('InitialRouteHandler: No active subscription → Pricing page');
return <Navigate to="/pricing" replace />;
console.log('InitialRouteHandler: No active subscription - modal will be shown by SubscriptionContext');
// Note: SubscriptionContext will show the modal automatically when subscription is inactive
// We still redirect to pricing for new users, but allow existing users with expired subscriptions
// to see the modal first. The modal has a "Renew Subscription" button that navigates to pricing.
// For new users (no subscription at all), redirect to pricing immediately
if (isNewUser) {
console.log('InitialRouteHandler: New user (no subscription) → Pricing page');
return <Navigate to="/pricing" replace />;
}
// For existing users with inactive subscription, show modal but don't redirect immediately
// The modal will be shown by SubscriptionContext, and user can click "Renew Subscription"
// Allow access to dashboard (modal will be shown and block functionality)
console.log('InitialRouteHandler: Inactive subscription - allowing access to show modal');
// Continue to onboarding/dashboard flow - modal will be shown by SubscriptionContext
}
// 4. Has active subscription, check onboarding status
@@ -294,7 +312,7 @@ const TokenInstaller: React.FC = () => {
// Install token getter for API calls
useEffect(() => {
setAuthTokenGetter(async () => {
const tokenGetter = async () => {
try {
const template = process.env.REACT_APP_CLERK_JWT_TEMPLATE;
// If a template is provided and it's not a placeholder, request a template-specific JWT
@@ -306,7 +324,13 @@ const TokenInstaller: React.FC = () => {
} catch {
return null;
}
});
};
// Set token getter for main API client
setAuthTokenGetter(tokenGetter);
// Set token getter for billing API client (same function)
setBillingAuthTokenGetter(tokenGetter);
}, [getToken]);
// Install Clerk signOut function for handling expired tokens
@@ -425,7 +449,9 @@ const App: React.FC = () => {
<Route path="/facebook-writer" element={<ProtectedRoute><FacebookWriter /></ProtectedRoute>} />
<Route path="/linkedin-writer" element={<ProtectedRoute><LinkedInWriter /></ProtectedRoute>} />
<Route path="/blog-writer" element={<ProtectedRoute><BlogWriter /></ProtectedRoute>} />
<Route path="/story-writer" element={<ProtectedRoute><StoryWriter /></ProtectedRoute>} />
<Route path="/scheduler-dashboard" element={<ProtectedRoute><SchedulerDashboard /></ProtectedRoute>} />
<Route path="/billing" element={<ProtectedRoute><BillingPage /></ProtectedRoute>} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/research-test" element={<ResearchTest />} />
<Route path="/wix-test" element={<WixTestPage />} />

View File

@@ -43,7 +43,7 @@ export const setAuthTokenGetter = (getter: () => Promise<string | null>) => {
};
// Get API URL from environment variables
const getApiUrl = () => {
export const getApiUrl = () => {
if (process.env.NODE_ENV === 'production') {
// In production, use the environment variable or fallback
return process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL;
@@ -52,8 +52,10 @@ const getApiUrl = () => {
};
// Create a shared axios instance for all API calls
const apiBaseUrl = getApiUrl();
export const apiClient = axios.create({
baseURL: getApiUrl(),
baseURL: apiBaseUrl,
timeout: 60000, // Increased to 60 seconds for regular API calls
headers: {
'Content-Type': 'application/json',
@@ -62,7 +64,7 @@ export const apiClient = axios.create({
// Create a specialized client for AI operations with extended timeout
export const aiApiClient = axios.create({
baseURL: getApiUrl(),
baseURL: apiBaseUrl,
timeout: 180000, // 3 minutes timeout for AI operations (matching 20-25 second responses)
headers: {
'Content-Type': 'application/json',
@@ -71,7 +73,7 @@ export const aiApiClient = axios.create({
// Create a specialized client for long-running operations like SEO analysis
export const longRunningApiClient = axios.create({
baseURL: getApiUrl(),
baseURL: apiBaseUrl,
timeout: 300000, // 5 minutes timeout for SEO analysis
headers: {
'Content-Type': 'application/json',
@@ -80,7 +82,7 @@ export const longRunningApiClient = axios.create({
// Create a specialized client for polling operations with reasonable timeout
export const pollingApiClient = axios.create({
baseURL: getApiUrl(),
baseURL: apiBaseUrl,
timeout: 60000, // 60 seconds timeout for polling status checks
headers: {
'Content-Type': 'application/json',

View File

@@ -235,7 +235,14 @@ export const BlogWriter: React.FC = () => {
});
// CopilotKit suggestions management - extracted to useCopilotSuggestions
const hasContent = React.useMemo(() => Object.keys(sections).length > 0, [sections]);
// Check if sections exist AND have actual content (not just empty strings)
const hasContent = React.useMemo(() => {
const sectionKeys = Object.keys(sections);
if (sectionKeys.length === 0) return false;
// Check if at least one section has actual content
const sectionsWithContent = Object.values(sections).filter(c => c && c.trim().length > 0);
return sectionsWithContent.length > 0;
}, [sections]);
const {
suggestions,
setSuggestionsRef,

View File

@@ -122,6 +122,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
aiGeneratedTitles={aiGeneratedTitles}
onTitleSelect={onTitleSelect}
onCustomTitle={onCustomTitle}
research={research}
/>
<EnhancedOutlineEditor
outline={outline}

View File

@@ -84,10 +84,31 @@ export const WixConnectModal: React.FC<WixConnectModalProps> = ({
// Store current page URL so we can redirect back after OAuth completes
// This MUST be stored before calling handleConnect to ensure it's available after redirect
// We ALWAYS override any existing redirect URL since we know the exact page we're on (Blog Writer)
const currentUrl = window.location.href;
// Build the redirect URL to ensure it includes the phase (publish) and works with both localhost and ngrok
const currentPath = window.location.pathname;
const currentHash = window.location.hash || '#publish'; // Default to publish phase if no hash
const currentSearch = window.location.search;
// Determine the correct origin - if using ngrok, use ngrok origin; otherwise use current origin
// This ensures consistency between where OAuth starts and where callback happens
const NGROK_ORIGIN = 'https://littery-sonny-unscrutinisingly.ngrok-free.dev';
const isUsingNgrok = window.location.origin.includes('localhost') ||
window.location.origin.includes('127.0.0.1') ||
window.location.origin === NGROK_ORIGIN;
const redirectOrigin = isUsingNgrok ? NGROK_ORIGIN : window.location.origin;
// Build redirect URL with normalized origin
const redirectUrl = `${redirectOrigin}${currentPath}${currentHash}${currentSearch}`;
try {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', currentUrl);
// Always override any existing redirect URL when connecting from Blog Writer
sessionStorage.setItem('wix_oauth_redirect', redirectUrl);
console.log('[WixConnectModal] Stored redirect URL (overriding any existing):', {
redirectUrl,
currentOrigin: window.location.origin,
redirectOrigin,
isUsingNgrok
});
} catch (e) {
console.warn('[WixConnectModal] Failed to store redirect URL:', e);
}

View File

@@ -1,6 +1,180 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { debug } from '../../../utils/debug';
import { blogWriterApi, BlogSEOActionableRecommendation } from '../../../services/blogWriterApi';
import { blogWriterCache } from '../../../services/blogWriterCache';
const registerContentKey = (map: Map<string, string>, key: any, content?: string) => {
if (key === undefined || key === null) {
return;
}
const trimmed = String(key).trim();
if (!trimmed) {
return;
}
const safeContent = content !== undefined && content !== null ? String(content) : '';
map.set(trimmed, safeContent);
map.set(trimmed.toLowerCase(), safeContent);
};
const getIdCandidatesForSection = (section: any, index: number): string[] => {
const rawCandidates = [
section?.id,
section?.section_id,
section?.sectionId,
section?.sectionID,
section?.heading_id,
`section_${index + 1}`,
`Section ${index + 1}`,
`section${index + 1}`,
`s${index + 1}`,
`S${index + 1}`,
`${index + 1}`,
];
const normalized = rawCandidates
.map((value) => (value === undefined || value === null ? '' : String(value).trim()))
.filter(Boolean);
return Array.from(new Set(normalized));
};
const buildExistingContentMap = (sectionsRecord: Record<string, string>): Map<string, string> => {
const map = new Map<string, string>();
if (!sectionsRecord) {
return map;
}
Object.entries(sectionsRecord).forEach(([key, value]) => {
registerContentKey(map, key, value ?? '');
});
return map;
};
const buildResponseContentMaps = (responseSections: any[]): { byId: Map<string, string>; byHeading: Map<string, string> } => {
const byId = new Map<string, string>();
const byHeading = new Map<string, string>();
if (!responseSections) {
return { byId, byHeading };
}
responseSections.forEach((section, index) => {
if (!section) {
return;
}
const content = section?.content;
const normalizedContent = content !== undefined && content !== null ? String(content).trim() : '';
if (!normalizedContent) {
return;
}
registerContentKey(byId, section?.id, normalizedContent);
registerContentKey(byId, section?.section_id, normalizedContent);
registerContentKey(byId, section?.sectionId, normalizedContent);
registerContentKey(byId, section?.sectionID, normalizedContent);
registerContentKey(byId, `section_${index + 1}`, normalizedContent);
registerContentKey(byId, `Section ${index + 1}`, normalizedContent);
registerContentKey(byId, `section${index + 1}`, normalizedContent);
registerContentKey(byId, `s${index + 1}`, normalizedContent);
registerContentKey(byId, `S${index + 1}`, normalizedContent);
registerContentKey(byId, `${index + 1}`, normalizedContent);
const heading = section?.heading || section?.title;
if (heading) {
registerContentKey(byHeading, heading, normalizedContent);
}
});
return { byId, byHeading };
};
const getPrimaryKeyForOutlineSection = (outlineSection: any, index: number): string => {
const candidates = getIdCandidatesForSection(outlineSection, index);
if (candidates.length > 0) {
return candidates[0];
}
const fallbackHeading = outlineSection?.heading || outlineSection?.title;
if (fallbackHeading) {
const trimmed = String(fallbackHeading).trim();
if (trimmed) {
return trimmed;
}
}
return `section_${index + 1}`;
};
const resolveContentForOutlineSection = (
outlineSection: any,
index: number,
responseSections: any[],
responseById: Map<string, string>,
responseByHeading: Map<string, string>,
existingContentMap: Map<string, string>
): { content: string; matchedKey: string } => {
const idCandidates = getIdCandidatesForSection(outlineSection, index);
for (const candidate of idCandidates) {
if (responseById.has(candidate)) {
return { content: responseById.get(candidate) || '', matchedKey: candidate };
}
const lower = candidate.toLowerCase();
if (responseById.has(lower)) {
return { content: responseById.get(lower) || '', matchedKey: candidate };
}
}
const heading = outlineSection?.heading || outlineSection?.title;
if (heading) {
const headingKey = String(heading).trim();
if (headingKey) {
const lowerHeading = headingKey.toLowerCase();
if (responseByHeading.has(lowerHeading)) {
return { content: responseByHeading.get(lowerHeading) || '', matchedKey: headingKey };
}
if (responseByHeading.has(headingKey)) {
return { content: responseByHeading.get(headingKey) || '', matchedKey: headingKey };
}
}
}
const responseSection = responseSections?.[index];
if (responseSection?.content) {
const normalizedContent = String(responseSection.content).trim();
if (normalizedContent) {
return {
content: normalizedContent,
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
};
}
}
for (const candidate of idCandidates) {
if (existingContentMap.has(candidate)) {
return { content: existingContentMap.get(candidate) || '', matchedKey: candidate };
}
const lower = candidate.toLowerCase();
if (existingContentMap.has(lower)) {
return { content: existingContentMap.get(lower) || '', matchedKey: candidate };
}
}
if (heading) {
const headingKey = String(heading).trim();
if (headingKey) {
const lowerHeading = headingKey.toLowerCase();
if (existingContentMap.has(lowerHeading)) {
return { content: existingContentMap.get(lowerHeading) || '', matchedKey: headingKey };
}
if (existingContentMap.has(headingKey)) {
return { content: existingContentMap.get(headingKey) || '', matchedKey: headingKey };
}
}
}
return {
content: '',
matchedKey: idCandidates[0] || getPrimaryKeyForOutlineSection(outlineSection, index),
};
};
interface UseSEOManagerProps {
sections: Record<string, string>;
@@ -47,8 +221,34 @@ export const useSEOManager = ({
// Helper: run same checks as analyzeSEO and open modal
const runSEOAnalysisDirect = useCallback((): string => {
const hasSections = !!sections && Object.keys(sections).length > 0;
// Check if sections have actual content (not just empty strings)
let sectionsWithContent = hasSections ? Object.values(sections).filter(c => c && c.trim().length > 0) : [];
let hasValidContent = sectionsWithContent.length > 0;
// If sections don't exist in state, check cache (similar to how content generation checks cache)
if (!hasValidContent && outline && outline.length > 0) {
try {
const outlineIds = outline.map(s => String(s.id));
const cachedContent = blogWriterCache.getCachedContent(outlineIds);
if (cachedContent && Object.keys(cachedContent).length > 0) {
sectionsWithContent = Object.values(cachedContent).filter(c => c && c.trim().length > 0);
hasValidContent = sectionsWithContent.length > 0;
if (hasValidContent) {
debug.log('[BlogWriter] Using cached content for SEO analysis', { sections: Object.keys(cachedContent).length });
// Update sections state with cached content
setSections(cachedContent);
}
}
} catch (e) {
debug.log('[BlogWriter] Error checking cache for SEO analysis', e);
}
}
const hasResearch = !!research && !!(research as any).keyword_analysis;
if (!hasSections) return "No blog content available for SEO analysis. Please generate content first.";
if (!hasValidContent) {
return "No blog content available for SEO analysis. Please generate content first. Content generation may still be in progress - please wait for it to complete.";
}
if (!hasResearch) return "Research data is required for SEO analysis. Please run research first.";
// Prevent rapid re-opens
const now = Date.now();
@@ -69,7 +269,7 @@ export const useSEOManager = ({
debug.log('[BlogWriter] SEO modal opened (direct)');
}
return "Running SEO analysis of your blog content. This will analyze content structure, keyword optimization, readability, and provide actionable recommendations.";
}, [sections, research, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed]);
}, [sections, research, outline, isSEOAnalysisModalOpen, contentConfirmed, setContentConfirmed, setSections]);
const handleApplySeoRecommendations = useCallback(async (
recommendations: BlogSEOActionableRecommendation[]
@@ -78,11 +278,29 @@ export const useSEOManager = ({
throw new Error('An outline is required before applying recommendations.');
}
const sectionPayload = outline.map((section) => ({
id: section.id,
heading: section.heading,
content: sections[section.id] ?? '',
}));
const existingContentMap = buildExistingContentMap(sections || {});
const emptyMap = new Map<string, string>();
const sectionPayload = outline.map((section, index) => {
const existingMatch = resolveContentForOutlineSection(
section,
index,
[],
emptyMap,
emptyMap,
existingContentMap
);
const payloadContentRaw = existingMatch.content ?? sections?.[section?.id] ?? '';
const payloadContent = payloadContentRaw !== undefined && payloadContentRaw !== null ? String(payloadContentRaw) : '';
const rawIdentifier = section?.id || section?.section_id || section?.sectionId || section?.sectionID || `section_${index + 1}`;
const identifier = String(rawIdentifier).trim();
return {
id: identifier,
heading: section.heading,
content: payloadContent,
};
});
const response = await blogWriterApi.applySeoRecommendations({
title: selectedTitle || outline[0]?.heading || 'Untitled Blog',
@@ -100,43 +318,59 @@ export const useSEOManager = ({
throw new Error('Recommendation response did not include updated sections.');
}
// Update sections - create new object reference to trigger React re-render
const newSections: Record<string, string> = {};
response.sections.forEach((section) => {
if (section.id && section.content) {
newSections[section.id] = section.content;
}
const { byId: responseById, byHeading: responseByHeading } = buildResponseContentMaps(response.sections);
const normalizedSections: Record<string, string> = {};
const sectionKeysForCache: string[] = [];
outline.forEach((section, index) => {
const { content: resolvedContent, matchedKey } = resolveContentForOutlineSection(
section,
index,
response.sections,
responseById,
responseByHeading,
existingContentMap
);
const finalContent = (resolvedContent ?? '').trim();
const contentToUse = finalContent || '';
const primaryKey = getPrimaryKeyForOutlineSection(section, index);
normalizedSections[primaryKey] = contentToUse;
sectionKeysForCache.push(primaryKey);
});
// Validate we have sections before updating
if (Object.keys(newSections).length === 0) {
const uniqueSectionKeys = Array.from(new Set(sectionKeysForCache));
if (uniqueSectionKeys.length === 0) {
throw new Error('No valid sections received from SEO recommendations application.');
}
// Validate sections have actual content
const sectionsWithContent = Object.values(newSections).filter(c => c && c.trim().length > 0);
const sectionsWithContent = Object.values(normalizedSections).filter(c => c && c.trim().length > 0);
if (sectionsWithContent.length === 0) {
throw new Error('SEO recommendations resulted in empty sections. Please try again.');
}
// Log detailed section info for debugging
const sectionIds = Object.keys(newSections);
const sectionSizes = sectionIds.map(id => ({ id, length: newSections[id]?.length || 0 }));
debug.log('[BlogWriter] Applied SEO recommendations: sections updated', {
sectionCount: sectionIds.length,
debug.log('[BlogWriter] Applied SEO recommendations: sections normalized', {
sectionCount: uniqueSectionKeys.length,
sectionsWithContent: sectionsWithContent.length,
sectionIds: sectionIds,
sectionSizes: sectionSizes,
totalContentLength: Object.values(newSections).reduce((sum, c) => sum + (c?.length || 0), 0)
sectionKeys: uniqueSectionKeys,
totalContentLength: Object.values(normalizedSections).reduce((sum, c) => sum + (c?.length || 0), 0)
});
// Update sections state
setSections(newSections);
setSections(normalizedSections);
try {
blogWriterCache.cacheContent(normalizedSections, uniqueSectionKeys);
} catch (cacheError) {
debug.log('[BlogWriter] Failed to cache SEO-applied content', cacheError);
}
// Force a delay to ensure React processes the state update before proceeding
// This gives React time to re-render with new sections before phase navigation checks
await new Promise(resolve => setTimeout(resolve, 200));
setContinuityRefresh(Date.now());
setFlowAnalysisCompleted(false);
setFlowAnalysisResults(null);
@@ -154,7 +388,7 @@ export const useSEOManager = ({
// But we'll stay in SEO phase to show updated content
setSeoRecommendationsApplied(true);
debug.log('[BlogWriter] seoRecommendationsApplied set to true');
// Ensure we stay in SEO phase to show updated content
// Force navigation to SEO phase if we're not already there (safeguard)
if (currentPhase !== 'seo') {
@@ -163,7 +397,7 @@ export const useSEOManager = ({
} else {
debug.log('[BlogWriter] Already in SEO phase, staying to show updated content');
}
}, [outline, sections, selectedTitle, research, setSections, setSelectedTitle, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSeoAnalysis, currentPhase, navigateToPhase]);
}, [outline, research, sections, selectedTitle, setSections, setContinuityRefresh, setFlowAnalysisCompleted, setFlowAnalysisResults, setSelectedTitle, setSeoAnalysis, setSeoRecommendationsApplied, currentPhase, navigateToPhase]);
// Handle SEO analysis completion
const handleSEOAnalysisComplete = useCallback((analysis: any) => {

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../../services/blogWriterApi';
import { BlogOutlineSection, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage, blogWriterApi } from '../../services/blogWriterApi';
import EnhancedOutlineInsights from './EnhancedOutlineInsights';
import OutlineIntelligenceChips from './OutlineIntelligenceChips';
import ImageGeneratorModal from '../ImageGen/ImageGeneratorModal';
@@ -38,6 +38,9 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
key_points: '',
target_words: 300
});
const [showRefineModal, setShowRefineModal] = useState(false);
const [refineFeedback, setRefineFeedback] = useState('');
const [isRefining, setIsRefining] = useState(false);
const toggleExpanded = (sectionId: string) => {
const newExpanded = new Set(expandedSections);
@@ -89,12 +92,53 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
}
};
const handleRefineOutline = async () => {
if (!refineFeedback.trim()) {
alert('Please provide feedback on how you would like to refine the outline.');
return;
}
setIsRefining(true);
try {
// Use the parent's onRefine callback which handles the API call and state update
// The callback expects: operation, sectionId, payload
await onRefine('refine', undefined, { feedback: refineFeedback.trim() });
setRefineFeedback('');
setShowRefineModal(false);
// Show success message
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
background-color: #4caf50;
color: white;
font-weight: 500;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
`;
toast.textContent = '✅ Outline refined successfully!';
document.body.appendChild(toast);
setTimeout(() => document.body.removeChild(toast), 3000);
} catch (error) {
console.error('Failed to refine outline:', error);
alert('Failed to refine outline. Please try again.');
} finally {
setIsRefining(false);
}
};
const getTotalWords = () => {
return outline.reduce((total, section) => total + (section.target_words || 0), 0);
};
return (
<>
<div style={{
backgroundColor: 'white',
borderRadius: '12px',
@@ -153,24 +197,45 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
/>
</div>
</div>
<button
onClick={() => setShowAddSection(!showAddSection)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
Add Section
</button>
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
<button
onClick={() => setShowRefineModal(true)}
style={{
backgroundColor: '#7b1fa2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
title="Refine the outline structure based on your feedback"
>
🔧 Refine Outline
</button>
<button
onClick={() => setShowAddSection(!showAddSection)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
Add Section
</button>
</div>
</div>
</div>
@@ -656,6 +721,120 @@ const EnhancedOutlineEditor: React.FC<Props> = ({
</div>
</div>
</div>
{/* Refine Outline Modal */}
{showRefineModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '32px',
maxWidth: '600px',
width: '90%',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
border: '1px solid #e5e7eb'
}}>
<div style={{ marginBottom: '24px' }}>
<h2 style={{ margin: '0 0 8px 0', color: '#1f2937', fontSize: '24px', fontWeight: '600' }}>
🔧 Refine Outline
</h2>
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
Provide feedback on how you'd like to improve the outline structure
</p>
</div>
<div style={{ marginBottom: '24px' }}>
<label style={{
display: 'block',
marginBottom: '8px',
fontSize: '14px',
fontWeight: '500',
color: '#333'
}}>
Your Feedback
</label>
<textarea
value={refineFeedback}
onChange={(e) => setRefineFeedback(e.target.value)}
placeholder="E.g., Add a section about best practices, merge sections 2 and 3, expand the introduction..."
style={{
width: '100%',
minHeight: '120px',
padding: '12px',
border: '1px solid #e5e7eb',
borderRadius: '8px',
fontSize: '14px',
fontFamily: 'inherit',
resize: 'vertical'
}}
/>
</div>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button
onClick={() => {
setShowRefineModal(false);
setRefineFeedback('');
}}
disabled={isRefining}
style={{
padding: '10px 20px',
backgroundColor: '#f5f5f5',
color: '#666',
border: 'none',
borderRadius: '8px',
cursor: isRefining ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Cancel
</button>
<button
onClick={handleRefineOutline}
disabled={isRefining || !refineFeedback.trim()}
style={{
padding: '10px 20px',
backgroundColor: isRefining || !refineFeedback.trim() ? '#9ca3af' : '#7b1fa2',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: isRefining || !refineFeedback.trim() ? 'not-allowed' : 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{isRefining ? (
<>
<span></span>
<span>Refining...</span>
</>
) : (
<>
<span>🔧</span>
<span>Refine Outline</span>
</>
)}
</button>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { BlogOutlineSection } from '../../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../services/blogWriterApi';
interface EnhancedTitleSelectorProps {
titleOptions: string[];
@@ -9,6 +9,8 @@ interface EnhancedTitleSelectorProps {
sections: BlogOutlineSection[];
researchTitles?: string[];
aiGeneratedTitles?: string[];
research?: BlogResearchResponse;
onTitlesGenerated?: (titles: string[]) => void;
}
const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
@@ -18,10 +20,15 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
onCustomTitle,
sections,
researchTitles = [],
aiGeneratedTitles = []
aiGeneratedTitles = [],
research,
onTitlesGenerated
}) => {
const [showModal, setShowModal] = useState(false);
const [customTitle, setCustomTitle] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedTitles, setGeneratedTitles] = useState<string[]>([]);
const [generationProgress, setGenerationProgress] = useState<string>('');
const handleTitleSelect = (title: string) => {
onTitleSelect(title);
@@ -36,6 +43,57 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
}
};
const handleGenerateSEOTitles = async () => {
if (!research || !sections.length || isGenerating) {
return;
}
setIsGenerating(true);
setGenerationProgress('Analyzing research data and outline structure...');
try {
const keywordAnalysis = research.keyword_analysis || {};
const primaryKeywords = keywordAnalysis.primary || [];
const secondaryKeywords = keywordAnalysis.secondary || [];
const contentAngles = research.suggested_angles || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Simulate progress updates
setTimeout(() => setGenerationProgress('Extracting keywords and content angles...'), 500);
setTimeout(() => setGenerationProgress('Generating SEO-optimized titles with AI...'), 1500);
const result = await blogWriterApi.generateSEOTitles({
research,
outline: sections,
primary_keywords: primaryKeywords,
secondary_keywords: secondaryKeywords,
content_angles: contentAngles,
search_intent: searchIntent,
word_count: sections.reduce((sum, s) => sum + (s.target_words || 0), 0)
});
setGenerationProgress('Finalizing titles...');
if (result.success && result.titles) {
setTimeout(() => {
setGeneratedTitles(result.titles);
setGenerationProgress('');
if (onTitlesGenerated) {
onTitlesGenerated(result.titles);
}
}, 500);
}
} catch (error) {
console.error('Failed to generate SEO titles:', error);
setGenerationProgress('');
alert('Failed to generate SEO titles. Please try again.');
} finally {
setTimeout(() => {
setIsGenerating(false);
}, 1000);
}
};
const getSectionSummary = () => {
return sections.map(section => ({
title: section.heading,
@@ -66,35 +124,39 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
margin: '0',
color: '#666',
fontSize: '14px',
wordBreak: 'break-word',
lineHeight: '1.4',
maxHeight: '60px',
whiteSpace: 'nowrap',
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical'
textOverflow: 'ellipsis',
maxWidth: '600px'
}}>
{selectedTitle || 'No title selected'}
{(selectedTitle || 'No title selected').length > 150
? (selectedTitle || 'No title selected').substring(0, 150) + '...'
: (selectedTitle || 'No title selected')}
</p>
</div>
<button
onClick={() => setShowModal(true)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
ALwrity it
</button>
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowModal(true)}
style={{
backgroundColor: '#1976d2',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
display: 'flex',
alignItems: 'center',
gap: '8px',
position: 'relative'
}}
title="Open title suggestions. Click 'Generate 5 SEO-Optimized Titles' in the modal to create premium titles (50-65 characters) optimized for search engines using your research data and outline."
>
ALwrity it
</button>
</div>
</div>
</div>
@@ -165,63 +227,163 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
</button>
</div>
{/* Section Information */}
<div style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
📋 Current Outline Summary
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
{/* Generate SEO Titles Button */}
{research && sections.length > 0 && (
<div style={{ marginBottom: '24px' }}>
<button
onClick={handleGenerateSEOTitles}
disabled={isGenerating}
style={{
width: '100%',
padding: '14px 24px',
backgroundColor: isGenerating ? '#9ca3af' : '#1976d2',
color: 'white',
border: 'none',
borderRadius: '12px',
cursor: isGenerating ? 'not-allowed' : 'pointer',
fontSize: '15px',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'all 0.2s ease',
position: 'relative',
overflow: 'hidden'
}}
onMouseEnter={(e) => {
if (!isGenerating) {
e.currentTarget.style.backgroundColor = '#1565c0';
}
}}
onMouseLeave={(e) => {
if (!isGenerating) {
e.currentTarget.style.backgroundColor = '#1976d2';
}
}}
>
{isGenerating ? (
<>
<span></span>
<span>{generationProgress || 'Generating SEO Titles...'}</span>
</>
) : (
<>
<span></span>
<span>Generate 5 SEO-Optimized Titles</span>
</>
)}
</button>
{isGenerating && (
<div style={{
width: '100%',
height: '4px',
backgroundColor: '#e5e7eb',
borderRadius: '2px',
marginTop: '12px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
backgroundColor: '#1976d2',
borderRadius: '2px',
animation: 'pulse 1.5s ease-in-out infinite',
width: '100%'
}} />
</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
</div>
</div>
)}
{isGenerating && generationProgress && (
<p style={{
margin: '8px 0 0 0',
color: '#6b7280',
fontSize: '13px',
textAlign: 'center'
}}>
{generationProgress}
</p>
)}
</div>
{/* Section Details */}
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
<div style={{ display: 'grid', gap: '8px' }}>
{sectionSummary.map((section, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #e0e0e0'
}}>
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
<span>{section.wordCount} words</span>
<span>{section.subheadings} subheadings</span>
<span>{section.keyPoints} key points</span>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Title Options */}
<div style={{ display: 'grid', gap: '24px' }}>
{/* Generated SEO Titles */}
{generatedTitles.length > 0 && (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '16px' }}>
<div style={{
width: '40px',
height: '40px',
backgroundColor: '#dcfce7',
borderRadius: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '18px'
}}>
🎯
</div>
<div>
<h4 style={{ margin: '0 0 4px 0', fontSize: '18px', color: '#1f2937', fontWeight: '600' }}>
SEO-Optimized Titles
</h4>
<p style={{ margin: 0, color: '#6b7280', fontSize: '14px' }}>
Premium titles optimized for search engines (50-65 characters)
</p>
</div>
<span style={{
fontSize: '12px',
backgroundColor: '#16a34a',
color: 'white',
padding: '4px 12px',
borderRadius: '16px',
fontWeight: '500'
}}>
{generatedTitles.length}
</span>
</div>
<div style={{ display: 'grid', gap: '10px' }}>
{generatedTitles.map((title, index) => (
<button
key={`seo-${index}`}
onClick={() => handleTitleSelect(title)}
style={{
width: '100%',
padding: '16px 20px',
border: selectedTitle === title ? '2px solid #16a34a' : '1px solid #e5e7eb',
borderRadius: '12px',
backgroundColor: selectedTitle === title ? '#f0fdf4' : 'white',
cursor: 'pointer',
textAlign: 'left',
fontSize: '15px',
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
e.currentTarget.style.backgroundColor = '#f9fafb';
e.currentTarget.style.borderColor = '#d1d5db';
}
}}
onMouseLeave={(e) => {
if (selectedTitle !== title) {
e.currentTarget.style.backgroundColor = 'white';
e.currentTarget.style.borderColor = '#e5e7eb';
}
}}
title={title}
>
{title}
</button>
))}
</div>
</div>
)}
{/* Research Content Angles */}
{researchTitles.length > 0 && (
<div>
@@ -274,7 +436,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
wordBreak: 'break-word'
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -348,7 +512,9 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
color: '#1f2937',
transition: 'all 0.2s ease',
lineHeight: '1.4',
wordBreak: 'break-word'
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
onMouseEnter={(e) => {
if (selectedTitle !== title) {
@@ -452,6 +618,61 @@ const EnhancedTitleSelector: React.FC<EnhancedTitleSelectorProps> = ({
</div>
</div>
{/* Section Information */}
<div style={{
backgroundColor: '#f8f9fa',
borderRadius: '8px',
padding: '16px',
marginTop: '24px'
}}>
<h4 style={{ margin: '0 0 12px 0', fontSize: '16px', color: '#333' }}>
📋 Current Outline Summary
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '12px' }}>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Sections</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>{sections.length}</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Total Words</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + (section.target_words || 0), 0)}
</div>
</div>
<div>
<div style={{ fontSize: '14px', color: '#666' }}>Subheadings</div>
<div style={{ fontSize: '18px', fontWeight: '600', color: '#333' }}>
{sections.reduce((sum, section) => sum + section.subheadings.length, 0)}
</div>
</div>
</div>
{/* Section Details */}
<div style={{ marginTop: '16px' }}>
<h5 style={{ margin: '0 0 8px 0', fontSize: '14px', color: '#333' }}>Section Details:</h5>
<div style={{ display: 'grid', gap: '8px' }}>
{sectionSummary.map((section, index) => (
<div key={index} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: 'white',
borderRadius: '6px',
border: '1px solid #e0e0e0'
}}>
<span style={{ fontSize: '14px', fontWeight: '500' }}>{section.title}</span>
<div style={{ display: 'flex', gap: '16px', fontSize: '12px', color: '#666' }}>
<span>{section.wordCount} words</span>
<span>{section.subheadings} subheadings</span>
<span>{section.keyPoints} key points</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Modal Footer */}
<div style={{
display: 'flex',

View File

@@ -31,7 +31,11 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
setForceUpdate(prev => prev + 1); // Force re-render
},
onComplete: (result) => {
console.info('[ResearchAction] ✅ Research completed', { hasResult: !!result });
console.info('[ResearchAction] ✅ Research completed (onComplete callback)', {
hasResult: !!result,
resultKeys: result ? Object.keys(result) : [],
status: polling.currentStatus
});
if (result && result.keywords) {
researchCache.cacheResult(
@@ -45,7 +49,10 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
// Reset navigation tracking when research completes
hasNavigatedRef.current = false;
// Call parent callback first
onResearchComplete?.(result);
// Close modal immediately when research completes
setCurrentTaskId(null);
setCurrentMessage('');
setShowProgressModal(false);
@@ -60,26 +67,47 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
}
});
// Close modal when research completes (status becomes 'completed' or polling stops with result)
// Set of statuses that indicate successful completion
const COMPLETED_STATUSES = React.useMemo(
() => new Set(['completed', 'success', 'succeeded', 'finished']),
[]
);
// Close modal when research completes (status becomes a completed state or polling stops with a result)
useEffect(() => {
if (showProgressModal && (
polling.currentStatus === 'completed' ||
(!polling.isPolling && polling.result && polling.currentStatus !== 'failed')
)) {
const normalizedStatus = (polling.currentStatus || '').toLowerCase();
const isCompleted = COMPLETED_STATUSES.has(normalizedStatus);
// Check if we have a result (indicates completion even if status isn't updated yet)
const hasResult = !!polling.result;
// Check if polling stopped and we have a result, or status indicates completion
const shouldClose = showProgressModal && (
isCompleted ||
(hasResult && normalizedStatus !== 'failed') ||
(!polling.isPolling && hasResult && normalizedStatus !== 'failed')
);
if (shouldClose) {
console.info('[ResearchAction] Closing modal - research completed', {
status: polling.currentStatus,
isPolling: polling.isPolling,
hasResult: !!polling.result
hasResult: hasResult,
normalizedStatus: normalizedStatus,
isCompleted: isCompleted
});
// Small delay to show completion message before closing
const timer = setTimeout(() => {
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}, 500);
return () => clearTimeout(timer);
// Close modal immediately when research completes
setShowProgressModal(false);
setCurrentTaskId(null);
setCurrentMessage('');
}
}, [polling.currentStatus, polling.isPolling, polling.result, showProgressModal]);
}, [
COMPLETED_STATUSES,
polling.currentStatus,
polling.isPolling,
polling.result,
showProgressModal
]);
useCopilotActionTyped({
name: 'showResearchForm',
@@ -256,7 +284,7 @@ export const ResearchAction: React.FC<ResearchActionProps> = ({ onResearchComple
<>
{showProgressModal && (
<ResearchProgressModal
open={showProgressModal && polling.currentStatus !== 'completed'}
open={showProgressModal}
title={"Research in progress"}
status={polling.currentStatus}
messages={polling.progressMessages}

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useMemo, useRef } from 'react';
interface ResearchProgressModalProps {
open: boolean;
@@ -9,6 +9,269 @@ interface ResearchProgressModalProps {
onClose: () => void;
}
type Tone = 'info' | 'active' | 'success' | 'warning' | 'error';
type StageState = 'upcoming' | 'active' | 'done' | 'error';
const statusThemes: Record<
string,
{ label: string; description: string; color: string; background: string }
> = {
pending: {
label: 'Queued',
description: 'Preparing the research workflow…',
color: '#1f2937',
background: '#e5e7eb'
},
running: {
label: 'In Progress',
description: 'Gathering sources and extracting insights.',
color: '#1d4ed8',
background: '#dbeafe'
},
completed: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
success: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
succeeded: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
finished: {
label: 'Completed',
description: 'Research results are ready to review.',
color: '#047857',
background: '#d1fae5'
},
failed: {
label: 'Needs Attention',
description: 'We hit an issue while running research.',
color: '#b91c1c',
background: '#fee2e2'
}
};
const toneStyles: Record<Tone, { bg: string; border: string; text: string }> = {
info: { bg: '#f8fafc', border: '#e2e8f0', text: '#0f172a' },
active: { bg: '#eff6ff', border: '#bfdbfe', text: '#1d4ed8' },
success: { bg: '#ecfdf5', border: '#bbf7d0', text: '#047857' },
warning: { bg: '#fff7ed', border: '#fed7aa', text: '#c2410c' },
error: { bg: '#fef2f2', border: '#fecaca', text: '#b91c1c' }
};
const stageDefinitions = [
{
id: 'cache',
label: 'Cache Check',
description: 'Looking for saved research results to speed things up.',
icon: '🗂️',
keywords: ['cache', 'cached', 'stored']
},
{
id: 'discovery',
label: 'Source Discovery',
description: 'Exploring trusted sources across the web.',
icon: '🔎',
keywords: ['search', 'source', 'gather', 'google', 'discover']
},
{
id: 'analysis',
label: 'Insight Extraction',
description: 'Extracting data points, statistics, and quotes.',
icon: '🧠',
keywords: ['analysis', 'analyz', 'extract', 'insight', 'processing']
},
{
id: 'assembly',
label: 'Structuring Findings',
description: 'Packaging insights and preparing summaries.',
icon: '📝',
keywords: ['assembling', 'structuring', 'summary', 'completed', 'ready', 'post-processing']
}
] as const;
type StageId = (typeof stageDefinitions)[number]['id'];
interface MessageMeta {
timestamp: string;
timeLabel: string;
raw: string;
title: string;
subtitle?: string;
icon: string;
tone: Tone;
stage: StageId | null;
}
const completionStatuses = new Set(['completed', 'success', 'succeeded', 'finished']);
const formatTime = (timestamp: string) => {
try {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(new Date(timestamp));
} catch {
return timestamp;
}
};
const inferStage = (text: string): StageId | null => {
const lower = text.toLowerCase();
for (const stage of stageDefinitions) {
if (stage.keywords.some(keyword => lower.includes(keyword))) {
return stage.id;
}
}
return null;
};
const friendlyMappings: Array<{
keywords: string[];
title: string;
subtitle?: string;
icon: string;
tone: Tone;
stage?: StageId;
}> = [
{
keywords: ['checking cache', 'cache'],
title: 'Checking existing research cache',
subtitle: 'Looking for previously generated insights so we can respond instantly.',
icon: '🗂️',
tone: 'info',
stage: 'cache'
},
{
keywords: ['found cached research', 'loading cached'],
title: 'Loaded cached research results',
subtitle: 'Serving saved insights to keep things fast.',
icon: '⚡',
tone: 'success',
stage: 'cache'
},
{
keywords: ['starting research'],
title: 'Launching fresh research',
subtitle: 'Bootstrapping the workflow and validating your request.',
icon: '🚀',
tone: 'active',
stage: 'discovery'
},
{
keywords: ['search', 'query', 'sources', 'web'],
title: 'Collecting authoritative sources',
subtitle: 'Evaluating top-ranked pages, studies, and reports.',
icon: '🔎',
tone: 'active',
stage: 'discovery'
},
{
keywords: ['extracting', 'analyzing', 'analysis', 'insight'],
title: 'Extracting key insights',
subtitle: 'Summarising statistics, trends, and quotes that matter.',
icon: '🧠',
tone: 'active',
stage: 'analysis'
},
{
keywords: ['assembling', 'compiling', 'structuring', 'post-processing'],
title: 'Structuring the research package',
subtitle: 'Organising findings into ready-to-use sections.',
icon: '🧩',
tone: 'info',
stage: 'assembly'
},
{
keywords: ['completed successfully', 'research completed', 'ready'],
title: 'Research completed successfully',
subtitle: 'All insights are ready for the outline phase.',
icon: '✅',
tone: 'success',
stage: 'assembly'
},
{
keywords: ['failed', 'error', 'limit exceeded'],
title: 'Research encountered an issue',
subtitle: 'Review the error message below and try again.',
icon: '⚠️',
tone: 'error'
}
];
const sanitizeTitle = (text: string) => text.replace(/^[^a-zA-Z0-9]+/, '').trim();
const mapMessageToMeta = (message: { timestamp: string; message: string }): MessageMeta => {
const raw = message.message || '';
const lower = raw.toLowerCase();
const mapping = friendlyMappings.find(entry =>
entry.keywords.some(keyword => lower.includes(keyword))
);
if (mapping) {
return {
timestamp: message.timestamp,
timeLabel: formatTime(message.timestamp),
raw,
title: mapping.title,
subtitle: mapping.subtitle,
icon: mapping.icon,
tone: mapping.tone,
stage: mapping.stage ?? inferStage(raw)
};
}
const stage = inferStage(raw);
return {
timestamp: message.timestamp,
timeLabel: formatTime(message.timestamp),
raw,
title: sanitizeTitle(raw) || 'Update received',
icon: '📝',
tone: 'info',
stage
};
};
const stageStateCopy: Record<StageState, { label: string; color: string; background: string; border: string }> = {
upcoming: {
label: 'Pending',
color: '#6b7280',
background: '#f3f4f6',
border: '#e5e7eb'
},
active: {
label: 'In Progress',
color: '#2563eb',
background: '#eff6ff',
border: '#bfdbfe'
},
done: {
label: 'Completed',
color: '#047857',
background: '#ecfdf5',
border: '#bbf7d0'
},
error: {
label: 'Needs Attention',
color: '#b91c1c',
background: '#fee2e2',
border: '#fecaca'
}
};
const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
open,
title = 'Research in progress',
@@ -17,63 +280,176 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
error,
onClose
}) => {
if (!open) return null;
const scrollRef = useRef<HTMLDivElement | null>(null);
const normalizedStatus = (status || '').toLowerCase();
const statusKey = error ? 'failed' : normalizedStatus;
const statusInfo = statusThemes[statusKey] || statusThemes.pending;
const processedMessages = useMemo(() => {
if (!messages || messages.length === 0) {
return [] as MessageMeta[];
}
return messages.map(mapMessageToMeta);
}, [messages]);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [processedMessages.length]);
const latestMessage = processedMessages.length > 0 ? processedMessages[processedMessages.length - 1] : null;
const stagesWithState = useMemo(() => {
const states: StageState[] = stageDefinitions.map(() => 'upcoming');
let highestCompletedIndex = -1;
processedMessages.forEach(meta => {
if (!meta.stage) {
return;
}
const idx = stageDefinitions.findIndex(stage => stage.id === meta.stage);
if (idx === -1) {
return;
}
if (meta.tone === 'error' || /error|failed/i.test(meta.raw)) {
states[idx] = 'error';
} else {
states[idx] = 'done';
if (idx > highestCompletedIndex) {
highestCompletedIndex = idx;
}
}
});
if (!error) {
const firstPending = states.findIndex(state => state === 'upcoming');
if (firstPending !== -1 && !completionStatuses.has(normalizedStatus)) {
states[firstPending] = 'active';
} else if (completionStatuses.has(normalizedStatus)) {
for (let i = 0; i < states.length; i += 1) {
if (states[i] !== 'error') {
states[i] = 'done';
}
}
}
} else if (highestCompletedIndex >= 0) {
states[highestCompletedIndex] = 'error';
}
return stageDefinitions.map((stage, index) => ({
...stage,
state: states[index]
}));
}, [error, normalizedStatus, processedMessages]);
if (!open) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000
}}>
<div style={{
width: '92%',
maxWidth: 900,
maxHeight: '82vh',
background: 'white',
borderRadius: 16,
overflow: 'hidden',
boxShadow: '0 24px 60px rgba(0,0,0,0.3)',
border: '1px solid #e5e7eb'
}}>
{/* Header with background illustration */}
<div style={{
position: 'relative',
padding: '28px 28px 24px 28px',
background: '#f8fafc'
}}>
<div style={{
position: 'absolute',
inset: 0,
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left center',
backgroundSize: '38% auto',
opacity: 0.12
}} />
<div style={{ position: 'relative', zIndex: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="research-progress-title"
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(15, 23, 42, 0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2000,
padding: '24px'
}}
>
<div
style={{
width: '100%',
maxWidth: 940,
maxHeight: '82vh',
background: '#ffffff',
borderRadius: 18,
boxShadow: '0 28px 80px rgba(15, 23, 42, 0.25)',
border: '1px solid #e2e8f0',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<div
style={{
padding: '28px 32px 24px 32px',
background: '#f8fafc',
borderBottom: '1px solid #e2e8f0',
position: 'relative'
}}
>
<div
style={{
position: 'absolute',
inset: 0,
backgroundImage: 'url(/blog-writer-bg.png)',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'left center',
backgroundSize: '35% auto',
opacity: 0.12,
pointerEvents: 'none'
}}
/>
<div
style={{
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 16
}}
>
<div>
<h3 style={{ margin: 0, fontSize: 20, color: '#111827' }}>{title}</h3>
<p style={{ margin: '6px 0 0 0', color: '#6b7280', fontSize: 13 }}>We are gathering sources, extracting insights, and preparing highquality research.</p>
{status && (
<div style={{ marginTop: 8, fontSize: 12, color: '#374151' }}>Status: {status}</div>
)}
<h3 id="research-progress-title" style={{ margin: 0, fontSize: 22, color: '#0f172a' }}>
{title}
</h3>
<p style={{ margin: '8px 0 0 0', color: '#475569', fontSize: 14 }}>
We are gathering sources, extracting insights, and assembling a research brief tailored to your topic.
</p>
<div
style={{
marginTop: 14,
display: 'inline-flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderRadius: 999,
background: statusInfo.background,
color: statusInfo.color,
fontSize: 13,
fontWeight: 600,
border: `1px solid ${statusInfo.color}1A`
}}
>
<span>{statusInfo.label}</span>
<span style={{ fontSize: 12, color: '#475569', fontWeight: 500 }}>{statusInfo.description}</span>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: '1px solid #e5e7eb',
borderRadius: 10,
padding: '8px 12px',
background: '#ffffff',
border: '1px solid #cbd5f5',
borderRadius: 12,
padding: '10px 14px',
cursor: 'pointer',
color: '#374151'
fontSize: 13,
fontWeight: 600,
color: '#1f2937',
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.08)',
transition: 'all 0.2s ease'
}}
>
Close
@@ -81,29 +457,157 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
</div>
</div>
{/* Messages list */}
<div style={{ padding: 20 }}>
<div style={{
border: '1px solid #e5e7eb',
borderRadius: 12,
overflow: 'hidden',
background: '#ffffff'
}}>
<div style={{ maxHeight: '48vh', overflowY: 'auto' }}>
{messages.length === 0 && (
<div style={{ padding: 16, color: '#6b7280', fontSize: 14 }}>Awaiting progress updates</div>
)}
{messages.map((m, idx) => (
<div key={idx} style={{ display: 'flex', gap: 12, padding: '12px 16px', borderTop: idx === 0 ? 'none' : '1px solid #f3f4f6' }}>
<div style={{ color: '#9ca3af', minWidth: 120, fontSize: 12 }}>{new Date(m.timestamp).toLocaleTimeString()}</div>
<div style={{ color: '#374151', fontSize: 14 }}>{m.message}</div>
<div style={{ padding: '24px 32px', overflow: 'auto' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 12,
marginBottom: 20
}}
>
{stagesWithState.map(stage => {
const copy = stageStateCopy[stage.state];
return (
<div
key={stage.id}
style={{
flex: '1 1 180px',
minWidth: 180,
borderRadius: 14,
padding: '14px 16px',
background: copy.background,
border: `1px solid ${copy.border}`,
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.6)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, fontWeight: 600, color: '#0f172a' }}>
<span style={{ fontSize: 22 }}>{stage.icon}</span>
<span>{stage.label}</span>
</div>
<div style={{ marginTop: 6, fontSize: 12.5, color: '#475569' }}>{stage.description}</div>
<div style={{ marginTop: 12, fontSize: 12, fontWeight: 600, color: copy.color }}>{copy.label}</div>
</div>
))}
);
})}
</div>
{latestMessage && (
<div
style={{
borderRadius: 16,
padding: '18px 20px',
border: `1px solid ${toneStyles[latestMessage.tone].border}`,
background: toneStyles[latestMessage.tone].bg,
marginBottom: 20,
boxShadow: '0 4px 16px rgba(15, 23, 42, 0.08)'
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<div style={{ fontSize: 28 }}>{latestMessage.icon}</div>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 16
}}
>
<div style={{ fontSize: 17, fontWeight: 600, color: '#0f172a' }}>{latestMessage.title}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{latestMessage.timeLabel}</div>
</div>
{latestMessage.subtitle && (
<div style={{ marginTop: 6, fontSize: 13.5, color: '#334155' }}>{latestMessage.subtitle}</div>
)}
{latestMessage.raw && (
<div style={{ marginTop: 10, fontSize: 12.5, color: '#64748b' }}>{latestMessage.raw}</div>
)}
</div>
</div>
</div>
)}
<div
style={{
border: '1px solid #e2e8f0',
borderRadius: 16,
padding: '18px 0',
maxHeight: '32vh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
<div
ref={scrollRef}
style={{
overflowY: 'auto',
padding: '0 20px',
display: 'flex',
flexDirection: 'column',
gap: 12
}}
>
{processedMessages.length === 0 && (
<div style={{ padding: '10px 0', color: '#6b7280', fontSize: 14 }}>
Awaiting progress updates
</div>
)}
{processedMessages.map((meta, index) => {
const styles = toneStyles[meta.tone];
return (
<div
key={`${meta.timestamp}-${index}`}
style={{
display: 'flex',
gap: 14,
padding: '12px 14px',
borderRadius: 12,
background: styles.bg,
border: `1px solid ${styles.border}`
}}
>
<div style={{ fontSize: 22 }}>{meta.icon}</div>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
gap: 12
}}
>
<div style={{ fontWeight: 600, color: styles.text, fontSize: 14 }}>{meta.title}</div>
<div style={{ fontSize: 12, color: '#64748b' }}>{meta.timeLabel}</div>
</div>
{meta.subtitle && (
<div style={{ marginTop: 4, fontSize: 13, color: '#475569' }}>{meta.subtitle}</div>
)}
{meta.raw && (
<div style={{ marginTop: 6, fontSize: 12.5, color: '#6b7280' }}>{meta.raw}</div>
)}
</div>
</div>
);
})}
</div>
</div>
{error && (
<div style={{ marginTop: 12, color: '#b91c1c', fontSize: 13 }}>Error: {error}</div>
<div
style={{
marginTop: 18,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid #fecaca',
background: '#fef2f2',
color: '#b91c1c',
fontSize: 13.5
}}
>
Error: {error}
</div>
)}
</div>
</div>
@@ -113,4 +617,3 @@ const ResearchProgressModal: React.FC<ResearchProgressModalProps> = ({
export default ResearchProgressModal;

View File

@@ -191,29 +191,72 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}, [isOpen, blogContent?.length, researchData]);
const runSEOAnalysis = useCallback(async (forceRefresh = false) => {
// Prevent multiple simultaneous calls
if (isAnalyzing && !forceRefresh) {
console.log('⏸️ SEO analysis already in progress, skipping duplicate call');
return;
}
try {
setIsAnalyzing(true);
setError(null);
setProgress(0);
setProgressMessage('Starting SEO analysis...');
setProgressMessage('Checking cache for previous SEO analysis...');
// Cache check
const hash = contentHash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
// Cache check - always check cache first unless force refresh is requested
// Compute hash if not already available
let hash = contentHash;
if (!hash) {
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
// Update state for future use
setContentHash(hash);
}
const cacheKey = getSeoCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO cache', {
cacheKey,
hasHash: !!hash,
forceRefresh,
hashLength: hash?.length,
titleLength: blogTitle?.length,
contentLength: blogContent?.length
});
if (!forceRefresh) {
const cached = typeof window !== 'undefined' ? window.localStorage.getItem(cacheKey) : null;
if (cached) {
const parsed = JSON.parse(cached);
setAnalysisResult(parsed as SEOAnalysisResult);
setIsAnalyzing(false);
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed as SEOAnalysisResult);
try {
const parsed = JSON.parse(cached) as SEOAnalysisResult;
// Validate cached data has required fields
if (parsed && typeof parsed.overall_score === 'number' && parsed.category_scores) {
console.log('✅ Using cached SEO analysis', { cacheKey, overall_score: parsed.overall_score });
setAnalysisResult(parsed);
setIsAnalyzing(false);
setProgress(100);
setProgressMessage('SEO analysis loaded from cache');
// Notify parent that analysis is complete (from cache)
if (onAnalysisComplete) {
onAnalysisComplete(parsed);
}
return;
} else {
console.warn('⚠️ Cached SEO analysis data is invalid, will fetch fresh analysis');
}
} catch (parseError) {
console.warn('⚠️ Failed to parse cached SEO analysis, will fetch fresh analysis', parseError);
// Remove invalid cache entry
if (typeof window !== 'undefined') {
window.localStorage.removeItem(cacheKey);
}
}
return;
} else {
console.log(' No cached SEO analysis found, will fetch from API', { cacheKey });
}
} else {
console.log('🔄 Force refresh requested, skipping cache check');
}
setProgressMessage('Starting SEO analysis...');
// Simulated progress
const progressStages = [
{ progress: 20, message: 'Extracting keywords from research data...' },
@@ -297,14 +340,17 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
setAnalysisResult(convertedResult);
// Save to cache
// Save to cache - use the same cacheKey that was used for checking
try {
const h = hash || (await hashContent(`${blogTitle || ''}\n${blogContent}`));
const key = getSeoCacheKey(h, blogTitle);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(convertedResult));
// Use the same hash and cacheKey from the cache check section
// This ensures consistency between cache check and save
if (typeof window !== 'undefined' && cacheKey) {
window.localStorage.setItem(cacheKey, JSON.stringify(convertedResult));
console.log('💾 SEO analysis cached', { cacheKey, overall_score: convertedResult.overall_score });
}
} catch {}
} catch (cacheError) {
console.warn('⚠️ Failed to cache SEO analysis', cacheError);
}
setIsAnalyzing(false);
@@ -340,21 +386,37 @@ export const SEOAnalysisModal: React.FC<SEOAnalysisModalProps> = ({
}
}, [blogContent, blogTitle, researchData, contentHash, onAnalysisComplete]);
// Precompute hash when modal opens
// Precompute hash when modal opens and trigger cache check
// Use a ref to prevent multiple simultaneous calls
const hasRunAnalysisRef = React.useRef(false);
useEffect(() => {
if (isOpen) {
if (isOpen && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
// After hash is computed, check cache if we don't have analysis result yet
if (!analysisResult) {
// Small delay to ensure hash is set in state
setTimeout(() => {
runSEOAnalysis();
}, 100);
}
})();
} else if (!isOpen) {
// Reset hash and flag when modal closes
setContentHash('');
hasRunAnalysisRef.current = false;
}
}, [isOpen, blogContent, blogTitle]);
}, [isOpen, blogContent, blogTitle, analysisResult, runSEOAnalysis]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !analysisResult) {
if (isOpen && !analysisResult && contentHash && !hasRunAnalysisRef.current) {
hasRunAnalysisRef.current = true;
runSEOAnalysis();
}
}, [isOpen, analysisResult, runSEOAnalysis]);
}, [isOpen, analysisResult, contentHash, runSEOAnalysis]);
const getScoreColor = (score: number) => {
if (score >= 80) return 'success.main';

View File

@@ -146,19 +146,6 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
}
}, [isOpen]);
// Auto-generate metadata when modal opens (only once)
const hasAutoGeneratedRef = React.useRef(false);
useEffect(() => {
if (isOpen && blogContent && !hasAutoGeneratedRef.current) {
hasAutoGeneratedRef.current = true;
generateMetadata(false); // Auto-generate from cache or API
}
if (!isOpen) {
hasAutoGeneratedRef.current = false; // Reset when modal closes
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]); // Only trigger when modal opens
const generateMetadata = useCallback(async (forceRefresh = false) => {
try {
setIsGenerating(true);
@@ -169,10 +156,15 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
console.log('🚀 Starting SEO metadata generation...', { forceRefresh });
// Calculate content hash for caching
const hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(hash);
// Calculate content hash for caching - use existing hash if available
let hash = contentHash;
if (!hash) {
hash = await hashContent(`${blogTitle || ''}\n${blogContent}`);
// Update state for future use
setContentHash(hash);
}
const cacheKey = getMetadataCacheKey(hash, blogTitle);
console.log('🔍 Checking SEO metadata cache', { cacheKey, hasHash: !!hash, forceRefresh });
// Check cache first (unless force refresh)
if (!forceRefresh && typeof window !== 'undefined') {
@@ -180,15 +172,32 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
if (cached) {
try {
const parsed = JSON.parse(cached) as SEOMetadataResult;
console.log('✅ Using cached SEO metadata');
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
return;
// Validate cached data has required fields
if (parsed && parsed.success !== undefined) {
console.log('✅ Using cached SEO metadata', { cacheKey, success: parsed.success });
setMetadataResult(parsed);
setEditableMetadata(parsed);
setIsGenerating(false);
// Notify parent that metadata is available
if (onMetadataGenerated) {
onMetadataGenerated(parsed);
}
return;
} else {
console.warn('⚠️ Cached SEO metadata data is invalid, will fetch fresh metadata');
}
} catch (e) {
console.warn('Failed to parse cached metadata:', e);
console.warn('⚠️ Failed to parse cached SEO metadata, will fetch fresh metadata', e);
// Remove invalid cache entry
if (typeof window !== 'undefined') {
window.localStorage.removeItem(cacheKey);
}
}
} else {
console.log(' No cached SEO metadata found, will fetch from API', { cacheKey });
}
} else {
console.log('🔄 Force refresh requested, skipping cache check');
}
// Make API call to generate metadata
@@ -203,7 +212,43 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
const result = response.data;
console.log('✅ SEO metadata generation response:', result);
if (!result.success) {
// Check if the response indicates a subscription error (even if HTTP status is 200)
if (!result.success && result.error) {
const errorMessage = result.error;
// Check if error message indicates subscription limit (429/402)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription')) {
console.log('SEOMetadataModal: Detected subscription error in response data', {
error: errorMessage,
data: result
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429, // Treat as 429 for subscription error
data: {
error: errorMessage,
message: result.message || errorMessage,
provider: result.provider || 'unknown',
usage_info: result.usage_info || {}
}
}
};
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
setIsGenerating(false);
return;
} else {
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
}
}
// If not a subscription error, throw the error normally
throw new Error(result.error || 'Metadata generation failed');
}
@@ -226,15 +271,51 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
const errorMessage = err?.message || err?.response?.data?.error || '';
// Check HTTP status code first
if (status === 429 || status === 402) {
console.log('SEOMetadataModal: Detected subscription error, triggering global handler', {
console.log('SEOMetadataModal: Detected subscription error (HTTP status), triggering global handler', {
status,
data: err?.response?.data
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsGenerating(false);
return;
} else {
console.warn('SEOMetadataModal: Global subscription error handler did not handle the error');
}
}
// Also check error message for subscription-related errors (in case API returns 200 with error in body)
if (errorMessage.includes('Token limit') ||
errorMessage.includes('limit would be exceeded') ||
errorMessage.includes('usage limit') ||
errorMessage.includes('subscription') ||
errorMessage.includes('429')) {
console.log('SEOMetadataModal: Detected subscription error (error message), triggering global handler', {
errorMessage,
err
});
// Create a mock error object with subscription error data
const mockError = {
response: {
status: 429,
data: {
error: errorMessage,
message: errorMessage,
provider: err?.response?.data?.provider || 'unknown',
usage_info: err?.response?.data?.usage_info || {}
}
}
};
const handled = await triggerSubscriptionError(mockError);
if (handled) {
console.log('SEOMetadataModal: Global subscription error handler triggered successfully (from error message)');
setIsGenerating(false);
return;
} else {
@@ -247,7 +328,34 @@ export const SEOMetadataModal: React.FC<SEOMetadataModalProps> = ({
} finally {
setIsGenerating(false);
}
}, [blogContent, blogTitle, researchData, outline, seoAnalysis]);
}, [blogContent, blogTitle, researchData, outline, seoAnalysis, contentHash, onMetadataGenerated]);
// Precompute hash when modal opens and trigger cache check
useEffect(() => {
if (isOpen) {
(async () => {
const h = await hashContent(`${blogTitle || ''}\n${blogContent}`);
setContentHash(h);
// After hash is computed, check cache if we don't have metadata result yet
if (!metadataResult) {
// Small delay to ensure hash is set in state
setTimeout(() => {
generateMetadata(false);
}, 100);
}
})();
} else {
// Reset hash when modal closes
setContentHash('');
}
}, [isOpen, blogContent, blogTitle, metadataResult, generateMetadata]);
// Fallback: if modal opens and hash is already computed, check cache immediately
useEffect(() => {
if (isOpen && !metadataResult && contentHash) {
generateMetadata(false);
}
}, [isOpen, metadataResult, contentHash, generateMetadata]);
const handleTabChange = (event: React.SyntheticEvent, newValue: string) => {
setTabValue(newValue);

View File

@@ -123,7 +123,10 @@ export const useSuggestions = ({
});
} else if (outline.length > 0 && outlineConfirmed) {
// Outline confirmed, focus on content generation and optimization
if (hasContent && !contentConfirmed) {
// Follow the same pattern as research/outline phases - show suggestions based on state
// Don't block on hasContent check - let the actions handle validation
if (!contentConfirmed) {
// Content exists but not confirmed yet - show options to work with content
items.push({
title: '🔄 ReWrite Blog',
message: 'I want to rewrite my blog with different approach, tone, or focus'
@@ -136,7 +139,8 @@ export const useSuggestions = ({
title: 'Next: Run SEO Analysis',
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
});
} else if (hasContent && contentConfirmed) {
} else {
// Content confirmed - show SEO workflow suggestions
if (!seoAnalysis) {
// Prompt to run SEO analysis first
items.push({
@@ -189,22 +193,6 @@ export const useSuggestions = ({
});
}
}
} else {
// No content yet, but outline is confirmed - show content generation options
if (hasContent) {
// Content exists but not confirmed - show confirmation and SEO options
items.push({
title: 'Next: Run SEO Analysis',
message: 'Please analyze the blog content for SEO. Run the analyzeSEO action right away and do not ask for confirmation.'
});
items.push({
title: '📊 Content Analysis',
message: 'Analyze the flow and quality of my blog content to get improvement suggestions'
});
} else {
// No content at all - show generation option (only if no content exists)
items.push({ title: '📝 Generate all sections', message: 'Generate all sections of my blog post' });
}
}
}

View File

@@ -1,9 +1,9 @@
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { createTheme, ThemeProvider, Paper, IconButton, TextField, Tooltip, CircularProgress, Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Box, Divider } from '@mui/material';
import {
AutoAwesome as AutoAwesomeIcon,
} from '@mui/icons-material';
import { BlogOutlineSection, BlogResearchResponse } from '../../../services/blogWriterApi';
import { BlogOutlineSection, BlogResearchResponse, blogWriterApi } from '../../../services/blogWriterApi';
import BlogSection from './BlogSection';
// Helper to create a consistent theme
@@ -48,10 +48,14 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
sectionImages = {}
}) => {
const [blogTitle, setBlogTitle] = useState(initialTitle || 'Your Amazing Blog Title');
const [introduction, setIntroduction] = useState('Click "Generate Introduction" to create a compelling opening for your blog post based on your content and research.');
const [sections, setSections] = useState<any[]>([]);
const [isTitleLoading, setIsTitleLoading] = useState(false);
const [isIntroductionLoading, setIsIntroductionLoading] = useState(false);
const [expandedSections, setExpandedSections] = useState<Set<any>>(new Set());
const [showTitleModal, setShowTitleModal] = useState(false);
const [showIntroductionModal, setShowIntroductionModal] = useState(false);
const [generatedIntroductions, setGeneratedIntroductions] = useState<string[]>([]);
// Initialize sections from outline or use parent sections
useEffect(() => {
@@ -74,6 +78,61 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
}
}, [outline, parentSections]);
// Update sections when parentSections content changes (e.g., after SEO recommendations are applied)
// This effect specifically watches for content changes in parentSections and updates the corresponding sections
// Use a ref to track the previous parentSections content to detect actual content changes
const prevParentSectionsRef = useRef<string>('');
const prevContinuityRefreshRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (!parentSections || !outline || outline.length === 0) return;
// Create a stringified version of parentSections for comparison
const parentSectionsString = JSON.stringify(parentSections);
const continuityRefreshChanged = continuityRefresh !== prevContinuityRefreshRef.current;
// Update if content changed OR continuityRefresh changed (forced refresh)
if (parentSectionsString === prevParentSectionsRef.current && !continuityRefreshChanged) {
return; // No changes detected
}
prevParentSectionsRef.current = parentSectionsString;
prevContinuityRefreshRef.current = continuityRefresh;
setSections(prevSections => {
// Update sections with new content from parentSections
const updatedSections = prevSections.map(section => {
// Try multiple ID formats to match sections (string, number, or stringified number)
const sectionIdStr = String(section.id);
const parentContent = parentSections[section.id] ||
parentSections[sectionIdStr] ||
parentSections[Number(section.id)];
// Update if parent has content for this section ID and it's different
if (parentContent !== undefined && parentContent !== section.content) {
console.log(`[BlogEditor] Updating section ${section.id} with new content (length: ${parentContent.length})`);
return {
...section,
content: parentContent
};
}
return section;
});
// Check if any sections were actually updated
const hasUpdates = updatedSections.some((section, index) =>
section.content !== prevSections[index]?.content
);
// Notify parent component of content update if changes were made
if (onContentUpdate && hasUpdates) {
onContentUpdate(updatedSections);
}
return updatedSections;
});
}, [parentSections, outline, continuityRefresh, onContentUpdate]);
// Initialize title from parent when provided
useEffect(() => {
if (initialTitle && initialTitle.trim().length > 0) {
@@ -91,6 +150,51 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
setShowTitleModal(false);
}, []);
const handleGenerateIntroductions = useCallback(async () => {
if (!research || !outline.length || isIntroductionLoading) {
return;
}
setIsIntroductionLoading(true);
try {
const keywordAnalysis = research.keyword_analysis || {};
const primaryKeywords = keywordAnalysis.primary || [];
const searchIntent = keywordAnalysis.search_intent || 'informational';
// Build sections_content from current sections
const sectionsContent: Record<string, string> = {};
sections.forEach(section => {
if (section.content) {
sectionsContent[section.id] = section.content;
}
});
const result = await blogWriterApi.generateIntroductions({
blog_title: blogTitle,
research,
outline,
sections_content: sectionsContent,
primary_keywords: primaryKeywords,
search_intent: searchIntent
});
if (result.success && result.introductions) {
setGeneratedIntroductions(result.introductions);
setShowIntroductionModal(true);
}
} catch (error) {
console.error('Failed to generate introductions:', error);
alert('Failed to generate introductions. Please try again.');
} finally {
setIsIntroductionLoading(false);
}
}, [research, outline, sections, blogTitle, isIntroductionLoading]);
const handleIntroductionSelect = useCallback((selectedIntroduction: string) => {
setIntroduction(selectedIntroduction);
setShowIntroductionModal(false);
}, []);
const toggleSectionExpansion = useCallback((sectionId: any) => {
setExpandedSections(prev => {
const newSet = new Set(prev);
@@ -139,9 +243,37 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Tooltip>
</div>
</div>
<p className="mt-3 text-gray-500 text-sm">
This is where your blog's subtitle or a brief one-line description will appear. It's editable too!
</p>
<div className="mt-3 group/intro">
<div className="flex items-start gap-2">
<p
className="flex-1 text-gray-600 text-sm leading-relaxed cursor-pointer hover:bg-gray-50 p-2 rounded-md transition-colors duration-200"
onClick={() => {
const newIntro = prompt('Edit introduction:', introduction);
if (newIntro !== null && newIntro.trim()) {
setIntroduction(newIntro.trim());
}
}}
title="Click to edit introduction"
>
{introduction}
</p>
<div className="opacity-0 group-hover/intro:opacity-100 transition-opacity duration-300">
<Tooltip title="✨ Generate Introduction">
<IconButton
onClick={handleGenerateIntroductions}
disabled={isIntroductionLoading || !research || !outline.length}
size="small"
>
{isIntroductionLoading ? (
<CircularProgress size={20} />
) : (
<AutoAwesomeIcon className="text-blue-500" fontSize="small"/>
)}
</IconButton>
</Tooltip>
</div>
</div>
</div>
<Divider sx={{ mt: 3, opacity: 0.3 }} />
</div>
<div>
@@ -301,6 +433,71 @@ const BlogEditor: React.FC<BlogEditorProps> = ({
</Button>
</DialogActions>
</Dialog>
{/* Introduction Selection Modal */}
<Dialog
open={showIntroductionModal}
onClose={() => setShowIntroductionModal(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold' }}>
Choose Your Blog Introduction
</Typography>
<Typography variant="body2" sx={{ mt: 1, color: 'text.secondary' }}>
Select one of the AI-generated introductions below. Each offers a different approach to hooking your readers.
</Typography>
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{generatedIntroductions.map((intro, index) => (
<Box
key={index}
sx={{
mb: 3,
p: 2,
border: '1px solid',
borderColor: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main',
borderRadius: 2,
'&:hover': {
backgroundColor: 'action.hover',
},
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => handleIntroductionSelect(intro)}
>
<Typography
variant="subtitle2"
sx={{
fontWeight: 'bold',
mb: 1,
color: index === 0 ? 'primary.main' : index === 1 ? 'secondary.main' : 'success.main'
}}
>
{index === 0 ? '📌 Option 1: Problem-Focused' : index === 1 ? '✨ Option 2: Benefit-Focused' : '📊 Option 3: Story/Statistic-Focused'}
</Typography>
<Typography
variant="body1"
sx={{
color: 'text.primary',
lineHeight: 1.7,
whiteSpace: 'pre-wrap'
}}
>
{intro}
</Typography>
</Box>
))}
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowIntroductionModal(false)}>
Cancel
</Button>
</DialogActions>
</Dialog>
</div>
</ThemeProvider>
);

View File

@@ -340,7 +340,7 @@ const MainDashboard: React.FC = () => {
<AnalyticsInsights />
{/* Billing & Usage Dashboard */}
<EnhancedBillingDashboard />
<EnhancedBillingDashboard terminalTheme={true} />
</Box>
</Box>

View File

@@ -55,9 +55,10 @@ export const usePlatformConnections = () => {
try {
// Store current page URL BEFORE redirecting (critical for proper redirect back)
// This ensures we can redirect back to the correct page (e.g., Blog Writer) after OAuth
// Only store if not already set (allows WixConnectModal to override if needed)
// WixConnectModal will always override when connecting from Blog Writer
const currentUrl = window.location.href;
try {
// Only store if not already set (allows WixConnectModal to override if needed)
if (!sessionStorage.getItem('wix_oauth_redirect')) {
sessionStorage.setItem('wix_oauth_redirect', currentUrl);
console.log('[Wix OAuth] Stored redirect URL:', currentUrl);

View File

@@ -0,0 +1,430 @@
/**
* Tasks Needing Intervention Component
* Displays tasks that have been marked for human intervention with actionable information.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Alert,
Button,
Chip,
Collapse,
IconButton,
Tooltip,
CircularProgress
} from '@mui/material';
import {
Warning as WarningIcon,
Error as ErrorIcon,
Refresh as RefreshIcon,
ExpandMore as ExpandMoreIcon,
ExpandLess as ExpandLessIcon,
PlayArrow as PlayArrowIcon,
Info as InfoIcon
} from '@mui/icons-material';
import { styled } from '@mui/material/styles';
import { apiClient } from '../../api/client';
import { TerminalTypography, terminalColors } from './terminalTheme';
const InterventionContainer = styled(Box)({
backgroundColor: 'rgba(26, 26, 26, 0.8)',
border: '2px solid #ff9800',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
});
const TaskCard = styled(Box)({
backgroundColor: 'rgba(10, 10, 10, 0.6)',
border: '1px solid #ff9800',
borderRadius: '6px',
padding: '12px',
marginBottom: '12px',
'&:last-child': {
marginBottom: 0,
},
});
const ActionButton = styled(Button)({
backgroundColor: 'rgba(0, 255, 0, 0.1)',
color: '#00ff00',
border: '1px solid #00ff00',
fontFamily: 'inherit',
fontSize: '0.875rem',
padding: '6px 16px',
textTransform: 'none',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.2)',
boxShadow: '0 0 10px rgba(0, 255, 0, 0.3)',
},
'&:disabled': {
backgroundColor: 'rgba(0, 68, 0, 0.3)',
color: '#004400',
borderColor: '#004400',
}
});
const StatusChip = styled(Chip)(({ severity }: { severity: 'error' | 'warning' }) => ({
backgroundColor: severity === 'error' ? 'rgba(244, 67, 54, 0.2)' : 'rgba(255, 152, 0, 0.2)',
color: severity === 'error' ? '#f44336' : '#ff9800',
border: `1px solid ${severity === 'error' ? '#f44336' : '#ff9800'}`,
fontFamily: 'inherit',
fontSize: '0.75rem',
fontWeight: 'bold',
}));
interface TaskNeedingIntervention {
task_id: number;
task_type: string;
user_id: string;
platform?: string;
website_url?: string;
failure_pattern: {
consecutive_failures: number;
recent_failures: number;
failure_reason: string;
last_failure_time: string | null;
error_patterns: string[];
};
failure_reason: string | null;
last_failure: string | null;
}
interface TasksNeedingInterventionProps {
userId: string;
}
const TasksNeedingIntervention: React.FC<TasksNeedingInterventionProps> = ({ userId }) => {
const [tasks, setTasks] = useState<TaskNeedingIntervention[]>([]);
const [loading, setLoading] = useState(true);
const [expandedTasks, setExpandedTasks] = useState<Set<number>>(new Set());
const [triggeringTasks, setTriggeringTasks] = useState<Set<number>>(new Set());
const fetchTasks = async () => {
try {
setLoading(true);
const response = await apiClient.get<{
success: boolean;
tasks: TaskNeedingIntervention[];
count: number;
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
if (response.data.success) {
setTasks(response.data.tasks || []);
}
} catch (error) {
console.error('Error fetching tasks needing intervention:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTasks();
// Refresh every 2 minutes
const interval = setInterval(fetchTasks, 120000);
return () => clearInterval(interval);
}, [userId]);
const toggleExpand = (taskId: number) => {
const newExpanded = new Set(expandedTasks);
if (newExpanded.has(taskId)) {
newExpanded.delete(taskId);
} else {
newExpanded.add(taskId);
}
setExpandedTasks(newExpanded);
};
const handleManualTrigger = async (task: TaskNeedingIntervention) => {
try {
setTriggeringTasks(prev => new Set(prev).add(task.task_id));
// Determine task type for API
let taskType = task.task_type;
if (task.task_type.includes('_insights')) {
// Extract platform from task_type (e.g., "gsc_insights" -> "gsc_insights")
taskType = task.task_type;
}
await apiClient.post(`/api/scheduler/tasks/${taskType}/${task.task_id}/manual-trigger`);
// Show success toast
showToast('Task triggered successfully. It will run shortly.', 'success');
// Refresh the list after a short delay
setTimeout(() => {
fetchTasks();
}, 2000);
} catch (error: any) {
console.error('Error triggering task:', error);
showToast(
error.response?.data?.detail || 'Failed to trigger task. Please try again.',
'error'
);
} finally {
setTriggeringTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.task_id);
return newSet;
});
}
};
const getTaskDisplayName = (task: TaskNeedingIntervention): string => {
if (task.task_type === 'oauth_token_monitoring') {
return `OAuth ${task.platform?.toUpperCase() || 'Unknown'}`;
} else if (task.task_type === 'website_analysis') {
const url = task.website_url || 'Unknown';
return `Website Analysis (${url.length > 40 ? url.substring(0, 40) + '...' : url})`;
} else if (task.task_type.includes('_insights')) {
return `${task.platform?.toUpperCase() || 'Unknown'} Insights`;
}
return task.task_type;
};
const getFailureReasonDisplay = (reason: string): { label: string; severity: 'error' | 'warning'; action: string } => {
switch (reason) {
case 'api_limit':
return {
label: 'API Limit Exceeded',
severity: 'error',
action: 'Your API quota has been exceeded. Wait for quota reset or upgrade your plan, then manually trigger the task.'
};
case 'auth_error':
return {
label: 'Authentication Error',
severity: 'warning',
action: 'Your credentials may have expired. Please reconnect the platform in onboarding, then manually trigger the task.'
};
case 'network_error':
return {
label: 'Network Error',
severity: 'warning',
action: 'Network connectivity issues detected. Check your connection and manually trigger the task when resolved.'
};
case 'config_error':
return {
label: 'Configuration Error',
severity: 'warning',
action: 'Task configuration is invalid. Please check task settings and manually trigger after fixing.'
};
default:
return {
label: 'Unknown Error',
severity: 'error',
action: 'An unexpected error occurred. Review the error details below and manually trigger after resolving the issue.'
};
}
};
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'Unknown';
try {
return new Date(dateString).toLocaleString();
} catch {
return dateString;
}
};
if (loading) {
return (
<InterventionContainer>
<Box display="flex" alignItems="center" gap={2}>
<CircularProgress size={20} sx={{ color: '#ff9800' }} />
<TerminalTypography variant="body2" sx={{ color: '#ff9800' }}>
Loading tasks needing intervention...
</TerminalTypography>
</Box>
</InterventionContainer>
);
}
if (tasks.length === 0) {
return null; // Don't show section if no tasks need intervention
}
return (
<InterventionContainer>
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom={2}>
<Box display="flex" alignItems="center" gap={1}>
<WarningIcon sx={{ color: '#ff9800', fontSize: '24px' }} />
<TerminalTypography variant="h6" sx={{ color: '#ff9800', fontWeight: 'bold' }}>
Tasks Needing Intervention ({tasks.length})
</TerminalTypography>
</Box>
<Tooltip title="Refresh">
<IconButton
onClick={fetchTasks}
sx={{
color: '#ff9800',
border: '1px solid #ff9800',
'&:hover': { backgroundColor: 'rgba(255, 152, 0, 0.1)' }
}}
size="small"
>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<TerminalTypography variant="body2" sx={{ color: '#ff9800', opacity: 0.8, marginBottom: 2 }}>
These tasks have failed repeatedly and require manual intervention. Review the details and take appropriate action.
</TerminalTypography>
{tasks.map((task) => {
const reasonInfo = getFailureReasonDisplay(task.failure_pattern.failure_reason);
const isExpanded = expandedTasks.has(task.task_id);
const isTriggering = triggeringTasks.has(task.task_id);
return (
<TaskCard key={task.task_id}>
<Box display="flex" alignItems="flex-start" justifyContent="space-between" gap={2}>
<Box flex={1}>
<Box display="flex" alignItems="center" gap={1} marginBottom={1}>
<TerminalTypography variant="subtitle1" sx={{ color: '#ff9800', fontWeight: 'bold' }}>
{getTaskDisplayName(task)}
</TerminalTypography>
<StatusChip
label={reasonInfo.label}
severity={reasonInfo.severity}
size="small"
/>
<Chip
label={`${task.failure_pattern.consecutive_failures} consecutive failures`}
size="small"
sx={{
backgroundColor: 'rgba(244, 67, 54, 0.2)',
color: '#f44336',
border: '1px solid #f44336',
fontFamily: 'inherit',
fontSize: '0.7rem',
}}
/>
</Box>
<TerminalTypography variant="body2" sx={{ color: '#ff9800', opacity: 0.9, marginBottom: 1 }}>
<InfoIcon sx={{ fontSize: '14px', verticalAlign: 'middle', marginRight: 0.5 }} />
{reasonInfo.action}
</TerminalTypography>
<Box display="flex" alignItems="center" gap={2} marginTop={1}>
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.7 }}>
Last failure: {formatDate(task.last_failure)}
</TerminalTypography>
<IconButton
onClick={() => toggleExpand(task.task_id)}
size="small"
sx={{ color: '#ff9800' }}
>
{isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
<Collapse in={isExpanded}>
<Box marginTop={2} padding={2} sx={{ backgroundColor: 'rgba(0, 0, 0, 0.3)', borderRadius: '4px' }}>
<TerminalTypography variant="caption" sx={{ color: '#ff9800', display: 'block', marginBottom: 1 }}>
<strong>Failure Details:</strong>
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
Consecutive failures: {task.failure_pattern.consecutive_failures}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
Recent failures (7 days): {task.failure_pattern.recent_failures}
</TerminalTypography>
{task.failure_reason && (
<TerminalTypography variant="caption" sx={{ color: '#ff9800', opacity: 0.8, display: 'block', marginBottom: 1 }}>
Error: {task.failure_reason.substring(0, 200)}
{task.failure_reason.length > 200 ? '...' : ''}
</TerminalTypography>
)}
{task.failure_pattern.error_patterns.length > 0 && (
<Box marginTop={1}>
<TerminalTypography variant="caption" sx={{ color: '#ff9800', display: 'block', marginBottom: 0.5 }}>
<strong>Error Patterns:</strong>
</TerminalTypography>
{task.failure_pattern.error_patterns.map((pattern, idx) => (
<TerminalTypography
key={idx}
variant="caption"
sx={{ color: '#ff9800', opacity: 0.7, display: 'block', fontFamily: 'monospace', fontSize: '0.7rem' }}
>
{pattern}
</TerminalTypography>
))}
</Box>
)}
</Box>
</Collapse>
</Box>
<Box display="flex" flexDirection="column" gap={1}>
<ActionButton
variant="outlined"
startIcon={isTriggering ? <CircularProgress size={16} sx={{ color: '#00ff00' }} /> : <PlayArrowIcon />}
onClick={() => handleManualTrigger(task)}
disabled={isTriggering}
size="small"
>
{isTriggering ? 'Triggering...' : 'Trigger Now'}
</ActionButton>
</Box>
</Box>
</TaskCard>
);
})}
</InterventionContainer>
);
};
// Toast notification helper
function showToast(message: string, type: 'success' | 'error' | 'info' = 'info') {
const toast = document.createElement('div');
const bgColors = {
error: '#f44336',
warning: '#ff9800',
info: '#2196f3',
success: '#4caf50'
};
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
font-size: 14px;
z-index: 10000;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s ease;
background-color: ${bgColors[type] || bgColors.info};
word-wrap: break-word;
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
const duration = type === 'error' ? 7000 : 5000;
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, duration);
}
export default TasksNeedingIntervention;

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Box, Paper, Stepper, Step, StepLabel, StepButton, Typography, IconButton, Tooltip } from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { StoryPhase } from '../../hooks/useStoryWriterPhaseNavigation';
interface PhaseNavigationProps {
phases: StoryPhase[];
currentPhase: string;
onPhaseClick: (phaseId: string) => void;
onReset?: () => void;
}
export const PhaseNavigation: React.FC<PhaseNavigationProps> = ({
phases,
currentPhase,
onPhaseClick,
onReset,
}) => {
const activeStep = phases.findIndex((p) => p.id === currentPhase);
const handleReset = () => {
if (window.confirm('Are you sure you want to restart? This will clear all your story data and start from the beginning.')) {
if (onReset) {
onReset();
}
}
};
return (
<Paper
sx={{
p: 3,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
}}
>
{onReset && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Tooltip title="Restart Story (Clear all data and start from beginning)">
<IconButton
onClick={handleReset}
sx={{
color: '#5D4037',
'&:hover': {
backgroundColor: '#E8E5D3',
color: '#1A1611',
},
}}
size="small"
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
)}
<Stepper activeStep={activeStep} alternativeLabel>
{phases.map((phase) => (
<Step key={phase.id} completed={phase.completed} disabled={phase.disabled}>
<StepButton
onClick={() => !phase.disabled && onPhaseClick(phase.id)}
disabled={phase.disabled}
sx={{
'& .MuiStepLabel-root': {
cursor: phase.disabled ? 'not-allowed' : 'pointer',
},
}}
>
<StepLabel
StepIconComponent={() => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: phase.current
? 'primary.main'
: phase.completed
? 'success.main'
: phase.disabled
? 'grey.300'
: 'grey.200',
color: phase.current || phase.completed ? 'white' : 'text.secondary',
fontSize: '1.2rem',
fontWeight: phase.current ? 600 : 400,
}}
>
{phase.icon}
</Box>
)}
>
<Typography
variant="body2"
sx={{
fontWeight: phase.current ? 600 : 400,
color: phase.disabled ? '#9E9E9E' : '#2C2416', // Dark brown text
}}
>
{phase.name}
</Typography>
<Typography
variant="caption"
sx={{
color: phase.disabled ? '#9E9E9E' : '#5D4037', // Medium brown for secondary text
fontSize: '0.7rem',
}}
>
{phase.description}
</Typography>
</StepLabel>
</StepButton>
</Step>
))}
</Stepper>
</Paper>
);
};
export default PhaseNavigation;

View File

@@ -0,0 +1,360 @@
import React, { useState } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Alert,
Divider,
CircularProgress,
LinearProgress,
} from '@mui/material';
import VideoLibraryIcon from '@mui/icons-material/VideoLibrary';
import DownloadIcon from '@mui/icons-material/Download';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
interface StoryExportProps {
state: ReturnType<typeof useStoryWriterState>;
}
const StoryExport: React.FC<StoryExportProps> = ({ state }) => {
const [isGeneratingVideo, setIsGeneratingVideo] = useState(false);
const [videoProgress, setVideoProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const handleCopyToClipboard = () => {
if (state.storyContent) {
navigator.clipboard.writeText(state.storyContent);
}
};
const handleDownload = () => {
if (state.storyContent) {
const blob = new Blob([state.storyContent], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `story-${Date.now()}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
};
const handleGenerateVideo = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
if (!state.sceneImages || state.sceneImages.size === 0) {
setError('Please generate images for scenes first');
return;
}
if (!state.sceneAudio || state.sceneAudio.size === 0) {
setError('Please generate audio for scenes first');
return;
}
setIsGeneratingVideo(true);
setError(null);
setVideoProgress(0);
try {
// Prepare image and audio URLs in scene order
const imageUrls: string[] = [];
const audioUrls: string[] = [];
const scenes = state.outlineScenes;
for (const scene of scenes) {
const sceneNumber = scene.scene_number || scenes.indexOf(scene) + 1;
const imageUrl = state.sceneImages?.get(sceneNumber);
const audioUrl = state.sceneAudio?.get(sceneNumber);
if (imageUrl && audioUrl) {
imageUrls.push(imageUrl);
audioUrls.push(audioUrl);
} else {
throw new Error(`Missing image or audio for scene ${sceneNumber}`);
}
}
if (imageUrls.length !== scenes.length || audioUrls.length !== scenes.length) {
throw new Error('Number of images and audio files must match number of scenes');
}
// Generate video
const response = await storyWriterApi.generateStoryVideo({
scenes: scenes,
image_urls: imageUrls,
audio_urls: audioUrls,
story_title: state.storySetting || 'Story',
fps: state.videoFps,
transition_duration: state.videoTransitionDuration,
});
if (response.success && response.video) {
state.setStoryVideo(response.video.video_url);
state.setError(null);
setVideoProgress(100);
} else {
throw new Error('Failed to generate video');
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate video';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingVideo(false);
}
};
const handleDownloadVideo = () => {
if (state.storyVideo) {
const videoUrl = storyWriterApi.getVideoUrl(state.storyVideo);
const a = document.createElement('a');
a.href = videoUrl;
a.download = `story-video-${Date.now()}.mp4`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
return (
<Paper
sx={{
p: 4,
mt: 2,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
}}
>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
Export Story
</Typography>
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
Your story is complete! You can copy it to clipboard or download it as a text file.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{!state.storyContent ? (
<Alert severity="info">
No story content available. Please complete the writing phase first.
</Alert>
) : (
<>
{/* Story Summary */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Story Summary
</Typography>
<Box
sx={{
p: 2,
borderRadius: 1,
backgroundColor: '#FAF9F6', // Slightly lighter cream for summary box
}}
>
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
<strong>Setting:</strong> {state.storySetting || 'N/A'}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
<strong>Characters:</strong> {state.characters || 'N/A'}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#2C2416' }}>
<strong>Style:</strong> {state.writingStyle} | <strong>Tone:</strong> {state.storyTone}
</Typography>
<Typography variant="body2" sx={{ color: '#2C2416' }}>
<strong>POV:</strong> {state.narrativePOV} | <strong>Audience:</strong> {state.audienceAgeGroup}
</Typography>
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Premise */}
{state.premise && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Premise
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={state.premise}
InputProps={{ readOnly: true }}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
},
'& .MuiInputBase-input': {
color: '#1A1611',
},
}}
/>
</Box>
)}
{/* Outline */}
{state.outline && (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Outline
</Typography>
<TextField
fullWidth
multiline
rows={6}
value={state.outline}
InputProps={{ readOnly: true }}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
},
'& .MuiInputBase-input': {
color: '#1A1611',
},
}}
/>
</Box>
)}
{/* Story Content */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Complete Story
</Typography>
<TextField
fullWidth
multiline
rows={20}
value={state.storyContent}
InputProps={{ readOnly: true }}
sx={{
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
},
'& .MuiInputBase-input': {
color: '#1A1611',
},
}}
/>
</Box>
{/* Video Generation */}
{state.isOutlineStructured && state.outlineScenes && (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom sx={{ color: '#1A1611' }}>
Video Generation
</Typography>
<Alert severity="info" sx={{ mb: 2 }}>
Generate a video from your story scenes with images and audio narration.
{(!state.sceneImages || state.sceneImages.size === 0) && ' Generate images first.'}
{(!state.sceneAudio || state.sceneAudio.size === 0) && ' Generate audio first.'}
</Alert>
{isGeneratingVideo && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={videoProgress} sx={{ mb: 1 }} />
<Typography variant="body2" sx={{ color: '#5D4037' }}>
Generating video... {videoProgress}%
</Typography>
</Box>
)}
{state.storyVideo && (
<Box sx={{ mb: 2 }}>
<video
controls
src={storyWriterApi.getVideoUrl(state.storyVideo)}
style={{ width: '100%', maxHeight: '500px' }}
>
Your browser does not support the video element.
</video>
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
Generated story video
</Typography>
</Box>
)}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Button
variant="outlined"
startIcon={<VideoLibraryIcon />}
onClick={handleGenerateVideo}
disabled={
isGeneratingVideo ||
!state.outlineScenes ||
!state.sceneImages ||
state.sceneImages.size === 0 ||
!state.sceneAudio ||
state.sceneAudio.size === 0
}
>
{isGeneratingVideo ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Video...
</>
) : (
'Generate Video'
)}
</Button>
{state.storyVideo && (
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={handleDownloadVideo}
>
Download Video
</Button>
)}
</Box>
</Box>
)}
<Divider sx={{ my: 3 }} />
{/* Export Actions */}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Button variant="outlined" onClick={handleCopyToClipboard}>
Copy to Clipboard
</Button>
<Button variant="contained" onClick={handleDownload}>
Download as Text File
</Button>
</Box>
</>
)}
</Paper>
);
};
export default StoryExport;

View File

@@ -0,0 +1,970 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Alert,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Grid,
Card,
CardMedia,
CardContent,
} from '@mui/material';
import GlobalStyles from '@mui/material/GlobalStyles';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ImageIcon from '@mui/icons-material/Image';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import { motion, AnimatePresence } from 'framer-motion';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi, StoryScene } from '../../../services/storyWriterApi';
import { aiApiClient } from '../../../api/client';
const MotionBox = motion(Box);
// Define cubic bezier easing arrays as const to preserve tuple types
const easeInOut = [0.22, 0.61, 0.36, 1] as const;
const easeOut = [0.4, 0, 1, 1] as const;
const leftPageVariants = {
enter: (direction: number) => ({
rotateY: direction === 0 ? 0 : direction > 0 ? -20 : 20,
x: direction === 0 ? 0 : direction > 0 ? -80 : 80,
opacity: direction === 0 ? 1 : 0,
transformOrigin: 'center',
}),
center: {
rotateY: 0,
x: 0,
opacity: 1,
transformOrigin: 'center',
transition: { duration: 0.55, ease: easeInOut },
},
exit: (direction: number) => ({
rotateY: direction === 0 ? 0 : direction > 0 ? 15 : -15,
x: direction === 0 ? 0 : direction > 0 ? 60 : -60,
opacity: direction === 0 ? 1 : 0,
transformOrigin: 'center',
transition: { duration: 0.4, ease: easeOut },
}),
};
const rightPageVariants = {
enter: (direction: number) => ({
rotateY: direction === 0 ? 0 : direction > 0 ? 25 : -25,
x: direction === 0 ? 0 : direction > 0 ? 110 : -110,
opacity: direction === 0 ? 1 : 0,
transformOrigin: direction >= 0 ? 'right center' : 'left center',
}),
center: {
rotateY: 0,
x: 0,
opacity: 1,
transformOrigin: 'center',
transition: { duration: 0.55, ease: easeInOut },
},
exit: (direction: number) => ({
rotateY: direction === 0 ? 0 : direction > 0 ? -25 : 25,
x: direction === 0 ? 0 : direction > 0 ? -90 : 90,
opacity: direction === 0 ? 1 : 0,
transformOrigin: direction >= 0 ? 'left center' : 'right center',
transition: { duration: 0.4, ease: easeOut },
}),
};
interface StoryOutlineProps {
state: ReturnType<typeof useStoryWriterState>;
onNext: () => void;
}
const StoryOutline: React.FC<StoryOutlineProps> = ({ state, onNext }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [isGeneratingImages, setIsGeneratingImages] = useState(false);
const [isGeneratingAudio, setIsGeneratingAudio] = useState(false);
const [error, setError] = useState<string | null>(null);
const [currentSceneIndex, setCurrentSceneIndex] = useState(0);
const [pageDirection, setPageDirection] = useState(0);
const [imageLoadError, setImageLoadError] = useState<Set<number>>(new Set());
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
// Use state from hook instead of local state
const sceneImages = state.sceneImages || new Map<number, string>();
const sceneAudio = state.sceneAudio || new Map<number, string>();
const scenes = state.outlineScenes || [];
const hasScenes = state.isOutlineStructured && scenes.length > 0;
useEffect(() => {
if (hasScenes) {
setCurrentSceneIndex(0);
setPageDirection(0);
}
}, [hasScenes]);
const currentScene = hasScenes ? scenes[currentSceneIndex] : null;
const canGoPrev = currentSceneIndex > 0;
const canGoNext = hasScenes ? currentSceneIndex < scenes.length - 1 : false;
// Get the current scene's image URL
const currentSceneNumber = currentScene?.scene_number || currentSceneIndex + 1;
const currentSceneImageUrl = sceneImages.get(currentSceneNumber);
const hasImageLoadError = imageLoadError.has(currentSceneNumber);
// Fetch image as blob with authentication
useEffect(() => {
if (!currentSceneImageUrl || hasImageLoadError || imageBlobUrls.has(currentSceneNumber)) {
return;
}
const loadImage = async () => {
try {
// Use relative URL path directly (aiApiClient will add base URL and auth)
const imageUrl = currentSceneImageUrl.startsWith('/')
? currentSceneImageUrl
: `/${currentSceneImageUrl}`;
// Use aiApiClient to get authenticated response with blob
const response = await aiApiClient.get(imageUrl, {
responseType: 'blob',
});
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);
setImageBlobUrls((prev) => {
const next = new Map(prev);
next.set(currentSceneNumber, blobUrl);
return next;
});
} catch (err) {
console.error('Failed to load image:', err);
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
}
};
loadImage();
}, [currentSceneNumber, currentSceneImageUrl, hasImageLoadError]);
// Cleanup blob URLs when component unmounts or scenes change
useEffect(() => {
return () => {
// Revoke all blob URLs on unmount
imageBlobUrls.forEach((blobUrl) => {
URL.revokeObjectURL(blobUrl);
});
};
}, []);
const currentSceneImageFullUrl = imageBlobUrls.get(currentSceneNumber) || null;
// Reset image load error when scene changes
useEffect(() => {
setImageLoadError((prev) => {
const next = new Set(prev);
next.delete(currentSceneNumber);
return next;
});
}, [currentSceneNumber]);
const handlePrevScene = () => {
if (canGoPrev) {
setPageDirection(-1);
setCurrentSceneIndex((prev) => prev - 1);
}
};
const handleNextScene = () => {
if (canGoNext) {
setPageDirection(1);
setCurrentSceneIndex((prev) => prev + 1);
}
};
const handleGenerateOutline = async () => {
if (!state.premise) {
setError('Please generate a premise first');
return;
}
setIsGenerating(true);
setError(null);
try {
const request = state.getRequest();
const response = await storyWriterApi.generateOutline(state.premise, request);
if (response.success && response.outline) {
// Handle structured outline (scenes) or plain text outline
if (response.is_structured && Array.isArray(response.outline)) {
// Structured outline with scenes
const scenes = response.outline as StoryScene[];
state.setOutlineScenes(scenes);
state.setIsOutlineStructured(true);
// Also store as formatted text for backward compatibility
const formattedOutline = scenes.map((scene, idx) =>
`Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`
).join('\n\n');
state.setOutline(formattedOutline);
} else {
// Plain text outline
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
state.setOutlineScenes(null);
state.setIsOutlineStructured(false);
}
state.setError(null);
} else {
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGenerating(false);
}
};
const handleContinue = () => {
if (state.outline || state.outlineScenes) {
onNext();
}
};
const handleGenerateImages = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
setIsGeneratingImages(true);
setError(null);
try {
const response = await storyWriterApi.generateSceneImages({
scenes: state.outlineScenes,
provider: state.imageProvider || undefined,
width: state.imageWidth,
height: state.imageHeight,
model: state.imageModel || undefined,
});
if (response.success && response.images) {
// Store image URLs by scene number
const imagesMap = new Map<number, string>();
response.images.forEach((image) => {
if (image.image_url && !image.error) {
imagesMap.set(image.scene_number, image.image_url);
}
});
state.setSceneImages(imagesMap);
state.setError(null);
} else {
throw new Error('Failed to generate images');
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate images';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingImages(false);
}
};
const handleGenerateAudio = async () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
setError('Please generate a structured outline first');
return;
}
setIsGeneratingAudio(true);
setError(null);
try {
const response = await storyWriterApi.generateSceneAudio({
scenes: state.outlineScenes,
provider: state.audioProvider,
lang: state.audioLang,
slow: state.audioSlow,
rate: state.audioRate,
});
if (response.success && response.audio_files) {
// Store audio URLs by scene number
const audioMap = new Map<number, string>();
response.audio_files.forEach((audio) => {
if (audio.audio_url && !audio.error) {
audioMap.set(audio.scene_number, audio.audio_url);
}
});
state.setSceneAudio(audioMap);
state.setError(null);
} else {
throw new Error('Failed to generate audio');
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate audio';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingAudio(false);
}
};
// Render structured scenes
const renderStructuredScenes = () => {
if (!state.outlineScenes || state.outlineScenes.length === 0) {
return null;
}
return (
<Box sx={{ mb: 3 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 2, color: '#1A1611' }}>
Story Scenes ({state.outlineScenes.length} scenes)
</Typography>
{state.outlineScenes.map((scene: StoryScene, index: number) => (
<Accordion
key={index}
sx={{
mb: 2,
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
'&:before': {
display: 'none', // Remove default border
},
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Scene {scene.scene_number || index + 1}: {scene.title}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
<strong>Description:</strong>
</Typography>
<Typography variant="body1" sx={{ mb: 2, color: '#2C2416' }}>
{scene.description}
</Typography>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
<strong>Image Prompt:</strong>
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={scene.image_prompt}
disabled
variant="outlined"
size="small"
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
},
'& .MuiInputBase-input': {
color: '#1A1611',
},
}}
/>
{sceneImages && sceneImages.has(scene.scene_number || index + 1) && (
<Card
sx={{
mt: 2,
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
}}
>
<CardMedia
component="img"
height="200"
image={storyWriterApi.getImageUrl(sceneImages.get(scene.scene_number || index + 1) || '')}
alt={`Scene ${scene.scene_number || index + 1}: ${scene.title}`}
sx={{ objectFit: 'contain' }}
/>
<CardContent>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Generated image for Scene {scene.scene_number || index + 1}
</Typography>
</CardContent>
</Card>
)}
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
<strong>Audio Narration:</strong>
</Typography>
<TextField
fullWidth
multiline
rows={3}
value={scene.audio_narration}
disabled
variant="outlined"
size="small"
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
},
'& .MuiInputBase-input': {
color: '#1A1611',
},
}}
/>
{sceneAudio && sceneAudio.has(scene.scene_number || index + 1) && (
<Box sx={{ mt: 2 }}>
<audio
controls
src={storyWriterApi.getAudioUrl(sceneAudio.get(scene.scene_number || index + 1) || '')}
style={{ width: '100%' }}
>
Your browser does not support the audio element.
</audio>
<Typography variant="caption" sx={{ display: 'block', mt: 1, color: '#5D4037' }}>
Generated audio for Scene {scene.scene_number || index + 1}
</Typography>
</Box>
)}
</Grid>
{scene.character_descriptions && scene.character_descriptions.length > 0 && (
<Grid item xs={12}>
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
<strong>Characters:</strong>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{scene.character_descriptions.map((char, idx) => (
<Chip key={idx} label={char} size="small" />
))}
</Box>
</Grid>
)}
{scene.key_events && scene.key_events.length > 0 && (
<Grid item xs={12}>
<Typography variant="body2" sx={{ color: '#5D4037' }} gutterBottom>
<strong>Key Events:</strong>
</Typography>
<Box component="ul" sx={{ pl: 2, mb: 0 }}>
{scene.key_events.map((event, idx) => (
<li key={idx}>
<Typography variant="body2" sx={{ color: '#2C2416' }}>{event}</Typography>
</li>
))}
</Box>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
))}
</Box>
);
};
return (
<Paper
sx={{
p: 4,
mt: 2,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
}}
>
<GlobalStyles
styles={{
'.tw-shadow-book': {
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
},
'.tw-rounded-book': {
borderRadius: '20px',
},
'.tw-page-accent': {
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
},
}}
/>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
Story Outline
</Typography>
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
Generate and review your story outline based on the premise. You can regenerate it or proceed to writing.
</Typography>
{state.isOutlineStructured && (
<Alert severity="info" sx={{ mb: 3 }}>
Structured outline with {state.outlineScenes?.length || 0} scenes generated. Each scene includes image prompts and audio narration.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{!state.premise && (
<Alert severity="warning" sx={{ mb: 3 }}>
Please generate a premise first in the Setup phase.
</Alert>
)}
{(state.outline || state.outlineScenes) ? (
<>
{hasScenes ? (
<>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'center' }}>
<Box
className="tw-shadow-book tw-rounded-book"
sx={{
position: 'relative',
width: '100%',
maxWidth: '100%',
minHeight: 520,
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
borderRadius: '20px',
overflow: 'hidden',
boxShadow: '0 36px 80px rgba(45, 30, 15, 0.35)',
background: 'linear-gradient(120deg, #fff9ef 0%, #f5e1c7 45%, #fff9ef 100%)',
border: '1px solid rgba(120, 90, 60, 0.28)',
transform: 'perspective(2200px) rotateX(2deg)',
mx: 'auto',
'&::after': {
content: '""',
position: 'absolute',
inset: '-10px -24px 28px',
background:
'radial-gradient(circle at 25% 20%, rgba(255,255,255,0.45) 0%, rgba(255,255,255,0) 42%), radial-gradient(circle at 75% 82%, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0) 46%)',
filter: 'blur(20px)',
zIndex: -2,
},
}}
>
{/* Book spine */}
<Box
sx={{
position: 'absolute',
top: 0,
bottom: 0,
left: '50%',
width: '2px',
background: 'linear-gradient(180deg, rgba(120, 90, 60, 0.5) 0%, rgba(120, 90, 60, 0.08) 100%)',
transform: 'translateX(-50%)',
zIndex: 2,
}}
/>
<AnimatePresence initial={false} custom={pageDirection}>
{/* Single container wrapping both pages for page turn animation */}
<MotionBox
key={`pages-${currentSceneIndex}`}
custom={pageDirection}
variants={{
enter: () => ({
opacity: 0,
}),
center: {
opacity: 1,
},
exit: () => ({
opacity: 0,
}),
}}
initial="enter"
animate="center"
exit="exit"
sx={{
display: 'flex',
width: '100%',
height: '100%',
}}
>
{/* Left page */}
<MotionBox
key={`meta-${currentSceneIndex}`}
role="button"
aria-label="Previous scene"
onClick={handlePrevScene}
custom={pageDirection}
variants={leftPageVariants}
initial="enter"
animate="center"
exit="exit"
sx={{
flexBasis: { xs: '100%', md: '48%' },
maxWidth: { xs: '100%', md: '48%' },
padding: { xs: 3, md: 4, lg: 5 },
pr: { xs: 3, md: 5, lg: 6 },
borderRight: '1px solid rgba(120, 90, 60, 0.18)',
cursor: canGoPrev ? 'pointer' : 'default',
background:
'linear-gradient(100deg, rgba(255,255,255,0.82) 0%, rgba(250,240,225,0.95) 50%, rgba(242,226,204,0.9) 100%)',
boxShadow: 'inset -18px 0 30px rgba(160, 120, 90, 0.18)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
display: 'flex',
flexDirection: 'column',
'&:hover': canGoPrev
? {
transform: 'translateX(-4px) rotate(-0.3deg)',
boxShadow: 'inset -24px 0 50px rgba(145, 110, 72, 0.25)',
}
: undefined,
'&::before': {
content: '""',
position: 'absolute',
top: 18,
bottom: 18,
right: '-12px',
width: 24,
background:
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
filter: 'blur(5px)',
opacity: 0.8,
},
}}
>
<Box sx={{ flex: '0 0 auto' }}>
<Typography
variant="overline"
sx={{ color: '#7a5335', letterSpacing: 4, fontWeight: 600, display: 'block' }}
>
Scene {currentScene?.scene_number || currentSceneIndex + 1} of {scenes.length}
</Typography>
<Typography
variant="h4"
sx={{
mt: 1,
color: '#2C2416',
fontFamily: `'Playfair Display', serif`,
fontWeight: 600,
lineHeight: 1.2,
pr: 2,
}}
>
{currentScene?.title}
</Typography>
</Box>
<Box
sx={{
flex: '1 1 auto',
overflowY: 'auto',
mt: 3,
display: 'grid',
gridTemplateRows: currentSceneImageFullUrl ? 'auto 1fr auto auto' : 'auto auto auto 1fr',
alignContent: 'start',
gap: 3,
}}
>
<Box>
{currentSceneImageFullUrl ? (
<>
<Typography
variant="subtitle2"
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1.5 }}
>
Scene Illustration
</Typography>
<Box
sx={{
width: '100%',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 8px 20px rgba(0, 0, 0, 0.18), 0 4px 8px rgba(0, 0, 0, 0.12)',
border: '3px solid rgba(120, 90, 60, 0.25)',
backgroundColor: '#fff',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
'&:hover': {
transform: 'translateY(-4px) scale(1.01)',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.25), 0 6px 12px rgba(0, 0, 0, 0.18)',
},
}}
>
<Box
component="img"
src={currentSceneImageFullUrl}
alt={currentScene?.title || `Scene ${currentSceneNumber} illustration`}
sx={{
width: '100%',
height: 'auto',
display: 'block',
objectFit: 'contain',
minHeight: '300px',
maxHeight: '500px',
}}
onError={() => {
// Mark this scene's image as failed to load
setImageLoadError((prev) => new Set(prev).add(currentSceneNumber));
}}
/>
</Box>
</>
) : (
<>
<Typography
variant="subtitle2"
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
>
Image Prompt
</Typography>
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
{currentScene?.image_prompt}
</Typography>
</>
)}
</Box>
<Box>
<Typography
variant="subtitle2"
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
>
Audio Narration
</Typography>
<Typography variant="body2" sx={{ color: '#3f3224', lineHeight: 1.7 }}>
{currentScene?.audio_narration}
</Typography>
</Box>
{currentScene?.character_descriptions && currentScene?.character_descriptions.length > 0 && (
<Box>
<Typography
variant="subtitle2"
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
>
Characters
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5 }}>
{currentScene.character_descriptions.map((char: string, idx: number) => (
<Chip
key={idx}
label={char}
size="small"
sx={{
background: 'linear-gradient(120deg, #f9e6c8, #f2d8b4)',
color: '#5a3922',
fontWeight: 500,
border: '1px solid rgba(120, 90, 60, 0.35)',
}}
/>
))}
</Box>
</Box>
)}
{currentScene?.key_events && currentScene?.key_events.length > 0 && (
<Box>
<Typography
variant="subtitle2"
sx={{ color: '#7a5335', textTransform: 'uppercase', letterSpacing: 1, mb: 1 }}
>
Key Events
</Typography>
<Box component="ul" sx={{ pl: 2.5, color: '#3f3224', mb: 0, lineHeight: 1.7 }}>
{currentScene.key_events.map((event: string, idx: number) => (
<li key={idx}>
<Typography variant="body2">{event}</Typography>
</li>
))}
</Box>
</Box>
)}
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<Typography variant="caption" sx={{ color: '#7a5335' }}>
Click to turn back
</Typography>
<Typography variant="caption" sx={{ color: '#a37b55' }}>
{canGoPrev ? '← Previous scene' : 'Start of outline'}
</Typography>
</Box>
</MotionBox>
{/* Right page */}
<MotionBox
key={`story-${currentSceneIndex}`}
role="button"
aria-label="Next scene"
onClick={handleNextScene}
custom={pageDirection}
variants={rightPageVariants}
initial="enter"
animate="center"
exit="exit"
sx={{
flexBasis: { xs: '100%', md: '52%' },
maxWidth: { xs: '100%', md: '52%' },
padding: { xs: 3, md: 4, lg: 5 },
pl: { xs: 3, md: 5, lg: 6 },
cursor: canGoNext ? 'pointer' : 'default',
background:
'linear-gradient(260deg, rgba(255,255,255,0.88) 0%, rgba(249,236,215,0.96) 45%, rgba(243,226,206,0.92) 100%)',
boxShadow: 'inset 18px 0 30px rgba(160, 120, 90, 0.18)',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
display: 'flex',
flexDirection: 'column',
'&:hover': canGoNext
? {
transform: 'translateX(4px) rotate(0.3deg)',
boxShadow: 'inset 24px 0 50px rgba(145, 110, 72, 0.25)',
}
: undefined,
'&::before': {
content: '""',
position: 'absolute',
top: 18,
bottom: 18,
left: '-12px',
width: 24,
background:
'linear-gradient(180deg, rgba(220,190,150,0.25) 0%, rgba(200,160,120,0) 50%, rgba(220,190,150,0.25) 100%)',
filter: 'blur(5px)',
opacity: 0.8,
},
}}
>
<Box sx={{ flex: 1, overflowY: 'auto' }}>
<Typography
variant="body1"
sx={{
color: '#2C2416',
fontSize: '1.08rem',
lineHeight: 1.9,
fontFamily: `'Merriweather', serif`,
whiteSpace: 'pre-line',
textAlign: 'justify',
textJustify: 'inter-word',
textIndent: '2em',
hyphens: 'auto',
pr: { xs: 0, md: 1.5 },
}}
>
{currentScene?.description}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 1 }}>
<Typography variant="caption" sx={{ color: '#7a5335' }}>
Click to turn page
</Typography>
<Typography variant="caption" sx={{ color: '#a37b55' }}>
{canGoNext ? 'Next scene →' : 'End of outline'}
</Typography>
</Box>
</MotionBox>
</MotionBox>
</AnimatePresence>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 3 }}>
<Typography variant="caption" sx={{ color: '#7a5335' }}>
Page {currentSceneIndex + 1} of {scenes.length}
</Typography>
</Box>
</>
) : (
<TextField
fullWidth
multiline
rows={12}
value={state.outline || ''}
onChange={(e) => state.setOutline(e.target.value)}
label="Story Outline"
sx={{ mb: 3 }}
/>
)}
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Button
variant="outlined"
onClick={handleGenerateOutline}
disabled={isGenerating || !state.premise}
>
{isGenerating ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Regenerating...
</>
) : (
'Regenerate Outline'
)}
</Button>
{state.isOutlineStructured && state.outlineScenes && (
<>
<Button
variant="outlined"
startIcon={<ImageIcon />}
onClick={handleGenerateImages}
disabled={isGeneratingImages || !state.outlineScenes || state.outlineScenes.length === 0}
>
{isGeneratingImages ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Images...
</>
) : (
'Generate Images'
)}
</Button>
<Button
variant="outlined"
startIcon={<VolumeUpIcon />}
onClick={handleGenerateAudio}
disabled={isGeneratingAudio || !state.outlineScenes || state.outlineScenes.length === 0}
>
{isGeneratingAudio ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Audio...
</>
) : (
'Generate Audio'
)}
</Button>
</>
)}
<Button
variant="contained"
onClick={handleContinue}
disabled={(!state.outline && !state.outlineScenes) || isGenerating || isGeneratingImages || isGeneratingAudio}
>
Continue to Writing
</Button>
</Box>
</>
) : (
<Box>
<Alert severity="info" sx={{ mb: 3 }}>
{state.premise
? 'Generating outline... If this message persists, please return to Setup and try again.'
: 'Please generate a premise first.'}
</Alert>
</Box>
)}
</Paper>
);
};
export default StoryOutline;

View File

@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Alert,
CircularProgress,
} from '@mui/material';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
interface StoryPremiseProps {
state: ReturnType<typeof useStoryWriterState>;
onNext: () => void;
}
const StoryPremise: React.FC<StoryPremiseProps> = ({ state, onNext }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleRegenerate = async () => {
setIsGenerating(true);
setError(null);
try {
const request = state.getRequest();
const response = await storyWriterApi.generatePremise(request);
if (response.success && response.premise) {
state.setPremise(response.premise);
state.setError(null);
} else {
throw new Error(response.premise || 'Failed to generate premise');
}
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGenerating(false);
}
};
const handleContinue = () => {
if (state.premise) {
onNext();
}
};
return (
<Paper sx={{ p: 4, mt: 2 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600 }}>
Story Premise
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Review and refine your story premise. You can regenerate it or proceed to create the outline.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{state.premise ? (
<>
<TextField
fullWidth
multiline
rows={8}
value={state.premise}
onChange={(e) => state.setPremise(e.target.value)}
label="Story Premise"
sx={{ mb: 3 }}
/>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button
variant="outlined"
onClick={handleRegenerate}
disabled={isGenerating}
>
{isGenerating ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Regenerating...
</>
) : (
'Regenerate Premise'
)}
</Button>
<Button
variant="contained"
onClick={handleContinue}
disabled={!state.premise || isGenerating}
>
Continue to Outline
</Button>
</Box>
</>
) : (
<Alert severity="info" sx={{ mb: 3 }}>
No premise generated yet. Please go back to Setup and generate a premise first.
</Alert>
)}
</Paper>
);
};
export default StoryPremise;

View File

@@ -0,0 +1,499 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Typography,
Alert,
Box,
CircularProgress,
RadioGroup,
Radio,
Card,
CardContent,
Tooltip,
IconButton,
InputAdornment,
} from '@mui/material';
import { InfoOutlined } from '@mui/icons-material';
import { storyWriterApi, StorySetupOption } from '../../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../../api/client';
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
import { STORY_IDEA_PLACEHOLDERS } from './constants';
import { textFieldStyles, cardStyles } from './styles';
import {
WRITING_STYLES,
STORY_TONES,
NARRATIVE_POVS,
AUDIENCE_AGE_GROUPS,
CONTENT_RATINGS,
ENDING_PREFERENCES,
} from './constants';
import { CustomValuesSetters } from './types';
interface AIStorySetupModalProps {
open: boolean;
onClose: () => void;
state: ReturnType<typeof useStoryWriterState>;
customValuesSetters: CustomValuesSetters;
}
export const AIStorySetupModal: React.FC<AIStorySetupModalProps> = ({
open,
onClose,
state,
customValuesSetters,
}) => {
const [storyIdea, setStoryIdea] = useState('');
const [isGeneratingSetup, setIsGeneratingSetup] = useState(false);
const [setupOptions, setSetupOptions] = useState<StorySetupOption[]>([]);
const [selectedOption, setSelectedOption] = useState<number | null>(null);
const [setupError, setSetupError] = useState<string | null>(null);
const [placeholderIndex, setPlaceholderIndex] = useState(0);
const [currentPlaceholder, setCurrentPlaceholder] = useState('');
const typingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const charIndexRef = useRef(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Rotating placeholder effect for story idea textarea
useEffect(() => {
// Cleanup function
const cleanup = () => {
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
// Stop all effects if modal is closed or user has entered text
if (!open || storyIdea.trim() !== '') {
cleanup();
setCurrentPlaceholder('');
charIndexRef.current = 0;
return cleanup;
}
// Start typing animation for current placeholder
const placeholder = STORY_IDEA_PLACEHOLDERS[placeholderIndex];
charIndexRef.current = 0;
setCurrentPlaceholder('');
// Type out characters one by one
typingIntervalRef.current = setInterval(() => {
// Check if we should stop
if (storyIdea.trim() !== '' || !open) {
cleanup();
setCurrentPlaceholder('');
return;
}
// Continue typing
if (charIndexRef.current < placeholder.length) {
setCurrentPlaceholder(placeholder.substring(0, charIndexRef.current + 1));
charIndexRef.current += 1;
} else {
// Finished typing current placeholder
cleanup();
// Wait 4 seconds then move to next placeholder
timeoutRef.current = setTimeout(() => {
if (storyIdea.trim() === '' && open) {
setPlaceholderIndex((prev) => (prev + 1) % STORY_IDEA_PLACEHOLDERS.length);
}
}, 4000);
}
}, 30);
return cleanup;
}, [open, placeholderIndex, storyIdea]);
const handleGenerateSetup = async () => {
if (!storyIdea.trim()) {
setSetupError('Please enter a story idea');
return;
}
setIsGeneratingSetup(true);
setSetupError(null);
try {
const response = await storyWriterApi.generateStorySetup({
story_idea: storyIdea,
});
if (response.success && response.options && response.options.length === 3) {
setSetupOptions(response.options);
// Extract custom values from all options and add them to custom values lists
const newCustomWritingStyles = new Set<string>();
const newCustomStoryTones = new Set<string>();
const newCustomNarrativePOVs = new Set<string>();
const newCustomAudienceAgeGroups = new Set<string>();
const newCustomContentRatings = new Set<string>();
const newCustomEndingPreferences = new Set<string>();
response.options.forEach((option) => {
// Check if values are custom (not in predefined lists)
if (!WRITING_STYLES.includes(option.writing_style)) {
newCustomWritingStyles.add(option.writing_style);
}
if (!STORY_TONES.includes(option.story_tone)) {
newCustomStoryTones.add(option.story_tone);
}
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
newCustomNarrativePOVs.add(option.narrative_pov);
}
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
newCustomAudienceAgeGroups.add(option.audience_age_group);
}
if (!CONTENT_RATINGS.includes(option.content_rating)) {
newCustomContentRatings.add(option.content_rating);
}
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
newCustomEndingPreferences.add(option.ending_preference);
}
});
// Update custom values state (merge with existing)
customValuesSetters.setCustomWritingStyles((prev) =>
[...prev, ...Array.from(newCustomWritingStyles)].filter((v, i, arr) => arr.indexOf(v) === i)
);
customValuesSetters.setCustomStoryTones((prev) =>
[...prev, ...Array.from(newCustomStoryTones)].filter((v, i, arr) => arr.indexOf(v) === i)
);
customValuesSetters.setCustomNarrativePOVs((prev) =>
[...prev, ...Array.from(newCustomNarrativePOVs)].filter((v, i, arr) => arr.indexOf(v) === i)
);
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
[...prev, ...Array.from(newCustomAudienceAgeGroups)].filter((v, i, arr) => arr.indexOf(v) === i)
);
customValuesSetters.setCustomContentRatings((prev) =>
[...prev, ...Array.from(newCustomContentRatings)].filter((v, i, arr) => arr.indexOf(v) === i)
);
customValuesSetters.setCustomEndingPreferences((prev) =>
[...prev, ...Array.from(newCustomEndingPreferences)].filter((v, i, arr) => arr.indexOf(v) === i)
);
} else {
throw new Error('Failed to generate story setup options');
}
} catch (err: any) {
console.error('Story setup generation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('StorySetup: Detected subscription error, triggering global handler', {
status,
data: err?.response?.data,
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('StorySetup: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsGeneratingSetup(false);
return;
} else {
console.warn('StorySetup: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story setup options';
setSetupError(errorMessage);
} finally {
setIsGeneratingSetup(false);
}
};
const handleSelectOption = (index: number) => {
setSelectedOption(index);
};
const handleApplyOption = () => {
if (selectedOption === null || !setupOptions[selectedOption]) {
setSetupError('Please select an option');
return;
}
const option = setupOptions[selectedOption];
// Extract and add custom values to dropdowns if they don't exist
if (!WRITING_STYLES.includes(option.writing_style)) {
customValuesSetters.setCustomWritingStyles((prev) =>
prev.includes(option.writing_style) ? prev : [...prev, option.writing_style]
);
}
if (!STORY_TONES.includes(option.story_tone)) {
customValuesSetters.setCustomStoryTones((prev) =>
prev.includes(option.story_tone) ? prev : [...prev, option.story_tone]
);
}
if (!NARRATIVE_POVS.includes(option.narrative_pov)) {
customValuesSetters.setCustomNarrativePOVs((prev) =>
prev.includes(option.narrative_pov) ? prev : [...prev, option.narrative_pov]
);
}
if (!AUDIENCE_AGE_GROUPS.includes(option.audience_age_group)) {
customValuesSetters.setCustomAudienceAgeGroups((prev) =>
prev.includes(option.audience_age_group) ? prev : [...prev, option.audience_age_group]
);
}
if (!CONTENT_RATINGS.includes(option.content_rating)) {
customValuesSetters.setCustomContentRatings((prev) =>
prev.includes(option.content_rating) ? prev : [...prev, option.content_rating]
);
}
if (!ENDING_PREFERENCES.includes(option.ending_preference)) {
customValuesSetters.setCustomEndingPreferences((prev) =>
prev.includes(option.ending_preference) ? prev : [...prev, option.ending_preference]
);
}
// Apply the selected option to the form
state.setPersona(option.persona);
state.setStorySetting(option.story_setting);
state.setCharacters(option.character_input);
state.setPlotElements(option.plot_elements);
state.setWritingStyle(option.writing_style);
state.setStoryTone(option.story_tone);
state.setNarrativePOV(option.narrative_pov);
// Normalize audience_age_group value (migrate old format if needed, but preserve custom values)
const normalizedAgeGroup =
option.audience_age_group === 'Adults'
? 'Adults (18+)'
: option.audience_age_group === 'Children'
? 'Children (5-12)'
: option.audience_age_group === 'Young Adults'
? 'Young Adults (13-17)'
: option.audience_age_group;
state.setAudienceAgeGroup(normalizedAgeGroup);
state.setContentRating(option.content_rating);
state.setEndingPreference(option.ending_preference);
// Apply story length if provided
if (option.story_length) {
state.setStoryLength(option.story_length);
}
// Apply premise if provided
if (option.premise) {
state.setPremise(option.premise);
}
// Apply image/video/audio settings if provided
if (option.image_provider !== undefined) {
state.setImageProvider(option.image_provider || null);
}
if (option.image_width !== undefined) {
state.setImageWidth(option.image_width);
}
if (option.image_height !== undefined) {
state.setImageHeight(option.image_height);
}
if (option.image_model !== undefined) {
state.setImageModel(option.image_model || null);
}
if (option.video_fps !== undefined) {
state.setVideoFps(option.video_fps);
}
if (option.video_transition_duration !== undefined) {
state.setVideoTransitionDuration(option.video_transition_duration);
}
if (option.audio_provider !== undefined) {
state.setAudioProvider(option.audio_provider);
}
if (option.audio_lang !== undefined) {
state.setAudioLang(option.audio_lang);
}
if (option.audio_slow !== undefined) {
state.setAudioSlow(option.audio_slow);
}
if (option.audio_rate !== undefined) {
state.setAudioRate(option.audio_rate);
}
// Close modal
onClose();
};
const handleClose = () => {
setStoryIdea('');
setSetupOptions([]);
setSelectedOption(null);
setSetupError(null);
setPlaceholderIndex(0);
setCurrentPlaceholder('');
charIndexRef.current = 0;
// Cleanup intervals
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
onClose();
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogTitle>Generate Story Setup With Alwrity AI</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
Enter your story idea or basic information. The more details you provide, the better story setups will be generated.
</Typography>
{setupError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setSetupError(null)}>
{setupError}
</Alert>
)}
<TextField
fullWidth
multiline
rows={6}
label="Story Idea"
placeholder={currentPlaceholder || "Enter your story idea, characters, setting, plot elements, or any other relevant information..."}
value={storyIdea}
onChange={(e) => setStoryIdea(e.target.value)}
sx={{ ...textFieldStyles, mb: 3 }}
helperText="Provide as much detail as possible. Include characters, setting, plot, themes, or any story elements you want to explore."
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Story Idea Input
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
Enter your story idea or concept. The more details you provide, the better the AI can generate tailored story setup options. Include:
</Typography>
<Typography variant="body2" component="div">
Main characters and their roles
<br />
Setting and time period
<br />
Key plot points or conflicts
<br />
Themes or messages
<br />
Genre or style preferences
<br />
Any specific story elements you want
</Typography>
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
Watch the placeholder examples cycle through for inspiration!
</Typography>
</Box>
}
arrow
placement="top"
>
<IconButton size="small" edge="end">
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
{isGeneratingSetup && (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', py: 3 }}>
<CircularProgress size={24} sx={{ mr: 2 }} />
<Typography sx={{ color: '#2C2416' }}>Generating story setup options...</Typography>
</Box>
)}
{setupOptions.length > 0 && (
<Box>
<Typography variant="subtitle1" sx={{ mb: 2, fontWeight: 600, color: '#1A1611' }}>
Select one of the following options:
</Typography>
<RadioGroup
value={selectedOption !== null ? selectedOption.toString() : ''}
onChange={(e) => handleSelectOption(Number(e.target.value))}
>
{setupOptions.map((option, index) => (
<Card
key={index}
sx={{
mb: 2,
...cardStyles,
border: selectedOption === index ? 2 : 1,
borderColor: selectedOption === index ? 'primary.main' : 'divider',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.main',
},
}}
onClick={() => handleSelectOption(index)}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Radio value={index} checked={selectedOption === index} />
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1, color: '#1A1611' }}>
Option {index + 1}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Persona:</strong> {option.persona}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Setting:</strong> {option.story_setting}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Characters:</strong> {option.character_input}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Plot Elements:</strong> {option.plot_elements}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Style:</strong> {option.writing_style} | <strong>Tone:</strong> {option.story_tone} | <strong>POV:</strong> {option.narrative_pov}
</Typography>
<Typography variant="body2" sx={{ mb: 1, color: '#5D4037' }}>
<strong>Audience:</strong> {option.audience_age_group} | <strong>Rating:</strong> {option.content_rating} | <strong>Ending:</strong> {option.ending_preference}
</Typography>
<Typography variant="body2" sx={{ mt: 1, fontStyle: 'italic', color: '#5D4037' }}>
<strong>Reasoning:</strong> {option.reasoning}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
))}
</RadioGroup>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
{setupOptions.length === 0 ? (
<Button
onClick={handleGenerateSetup}
disabled={!storyIdea.trim() || isGeneratingSetup}
variant="contained"
>
{isGeneratingSetup ? 'Generating...' : 'Generate Options'}
</Button>
) : (
<Button onClick={handleApplyOption} disabled={selectedOption === null} variant="contained">
Apply Selected Option
</Button>
)}
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Grid, Typography, Box, FormControlLabel, Checkbox } from '@mui/material';
import { SectionProps } from './types';
export const FeatureCheckboxesSection: React.FC<SectionProps> = ({ state }) => {
return (
<Grid item xs={12}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
Story Features
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={state.enableExplainer}
onChange={(e) => state.setEnableExplainer(e.target.checked)}
/>
}
label="Explainer"
/>
<FormControlLabel
control={
<Checkbox
checked={state.enableIllustration}
onChange={(e) => state.setEnableIllustration(e.target.checked)}
/>
}
label="Illustration"
/>
<FormControlLabel
control={
<Checkbox
checked={state.enableVideoNarration}
onChange={(e) => state.setEnableVideoNarration(e.target.checked)}
/>
}
label="Story Video & Narration"
/>
</Box>
</Grid>
);
};

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { TextField, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
import { InfoOutlined } from '@mui/icons-material';
interface TooltipContent {
title: string;
description: string;
examples?: string[];
}
interface FormFieldWithTooltipProps {
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
placeholder?: string;
helperText?: string;
required?: boolean;
multiline?: boolean;
rows?: number;
type?: string;
tooltip: TooltipContent;
sx?: any;
inputProps?: any;
}
export const FormFieldWithTooltip: React.FC<FormFieldWithTooltipProps> = ({
label,
value,
onChange,
placeholder,
helperText,
required = false,
multiline = false,
rows,
type,
tooltip,
sx,
inputProps,
}) => {
return (
<TextField
fullWidth
label={label}
value={value}
onChange={onChange}
placeholder={placeholder}
helperText={helperText}
required={required}
multiline={multiline}
rows={rows}
type={type}
sx={sx}
InputProps={{
...inputProps,
endAdornment: (
<InputAdornment position="end">
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
</Typography>
{tooltip.examples && tooltip.examples.length > 0 && (
<>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
Examples:
</Typography>
<Typography variant="body2" component="div">
{tooltip.examples.map((example, index) => (
<React.Fragment key={index}>
{example}
{index < tooltip.examples!.length - 1 && <br />}
</React.Fragment>
))}
</Typography>
</>
)}
</Box>
}
arrow
placement="top"
>
<IconButton size="small" edge="end">
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
/>
);
};

View File

@@ -0,0 +1,245 @@
import React from 'react';
import {
Box,
Typography,
Accordion,
AccordionSummary,
AccordionDetails,
Grid,
TextField,
MenuItem,
FormControlLabel,
Checkbox,
Slider,
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { SectionProps } from './types';
import { textFieldStyles, accordionStyles } from './styles';
import { IMAGE_PROVIDERS, AUDIO_PROVIDERS, COMMON_IMAGE_SIZES } from './constants';
export const GenerationSettingsSection: React.FC<SectionProps> = ({ state }) => {
return (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 2, fontWeight: 600 }}>
Generation Settings
</Typography>
<Typography variant="body2" sx={{ mb: 3, color: '#5D4037' }}>
Configure image, video, and audio generation options for your story.
</Typography>
{/* Image Generation Settings */}
<Accordion sx={accordionStyles}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Image Generation Settings
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
select
label="Image Provider"
value={state.imageProvider || ''}
onChange={(e) => state.setImageProvider(e.target.value || null)}
helperText="Select the image generation provider. Leave as 'Auto' to use the default."
sx={textFieldStyles}
>
{IMAGE_PROVIDERS.map((provider) => (
<MenuItem key={provider.value} value={provider.value}>
{provider.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
select
label="Image Size"
value={`${state.imageWidth}x${state.imageHeight}`}
onChange={(e) => {
const [width, height] = e.target.value.split('x').map(Number);
state.setImageWidth(width);
state.setImageHeight(height);
}}
helperText="Select a common image size or set custom dimensions below."
sx={textFieldStyles}
>
{COMMON_IMAGE_SIZES.map((size) => (
<MenuItem key={`${size.width}x${size.height}`} value={`${size.width}x${size.height}`}>
{size.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Image Width"
value={state.imageWidth}
onChange={(e) => state.setImageWidth(Number(e.target.value))}
inputProps={{ min: 256, max: 2048, step: 64 }}
helperText="Image width in pixels (256-2048)"
sx={textFieldStyles}
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Image Height"
value={state.imageHeight}
onChange={(e) => state.setImageHeight(Number(e.target.value))}
inputProps={{ min: 256, max: 2048, step: 64 }}
helperText="Image height in pixels (256-2048)"
sx={textFieldStyles}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Image Model (Optional)"
value={state.imageModel || ''}
onChange={(e) => state.setImageModel(e.target.value || null)}
placeholder="Leave empty to use default model"
helperText="Specific model to use for image generation (optional)"
sx={textFieldStyles}
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Video Generation Settings */}
<Accordion sx={accordionStyles}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Video Generation Settings
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
type="number"
label="Frames Per Second (FPS)"
value={state.videoFps}
onChange={(e) => state.setVideoFps(Number(e.target.value))}
inputProps={{ min: 15, max: 60, step: 1 }}
helperText="Video frame rate (15-60 fps). Higher values create smoother video but larger files."
sx={textFieldStyles}
/>
</Grid>
<Grid item xs={12} md={6}>
<Box>
<Typography variant="body2" gutterBottom>
Transition Duration: {state.videoTransitionDuration.toFixed(1)}s
</Typography>
<Slider
value={state.videoTransitionDuration}
onChange={(_, value) => state.setVideoTransitionDuration(value as number)}
min={0}
max={2}
step={0.1}
marks={[
{ value: 0, label: '0s' },
{ value: 1, label: '1s' },
{ value: 2, label: '2s' },
]}
valueLabelDisplay="auto"
/>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Duration of transitions between scenes in seconds
</Typography>
</Box>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Audio Generation Settings */}
<Accordion sx={accordionStyles}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, color: '#1A1611' }}>
Audio Generation Settings
</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
select
label="Audio Provider"
value={state.audioProvider}
onChange={(e) => state.setAudioProvider(e.target.value)}
helperText="Text-to-speech provider for narration"
sx={textFieldStyles}
>
{AUDIO_PROVIDERS.map((provider) => (
<MenuItem key={provider.value} value={provider.value}>
{provider.label}
</MenuItem>
))}
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Language Code"
value={state.audioLang}
onChange={(e) => state.setAudioLang(e.target.value)}
placeholder="en"
helperText="Language code for text-to-speech (e.g., 'en' for English, 'es' for Spanish)"
sx={textFieldStyles}
/>
</Grid>
{state.audioProvider === 'gtts' && (
<Grid item xs={12} md={6}>
<FormControlLabel
control={
<Checkbox
checked={state.audioSlow}
onChange={(e) => state.setAudioSlow(e.target.checked)}
/>
}
label="Slow Speech (gTTS only)"
/>
</Grid>
)}
{state.audioProvider === 'pyttsx3' && (
<Grid item xs={12} md={6}>
<Box>
<Typography variant="body2" gutterBottom>
Speech Rate: {state.audioRate} words/min
</Typography>
<Slider
value={state.audioRate}
onChange={(_, value) => state.setAudioRate(value as number)}
min={50}
max={300}
step={10}
marks={[
{ value: 50, label: '50' },
{ value: 150, label: '150' },
{ value: 300, label: '300' },
]}
valueLabelDisplay="auto"
/>
<Typography variant="caption" sx={{ color: '#5D4037' }}>
Speech rate in words per minute (pyttsx3 only)
</Typography>
</Box>
</Grid>
)}
</Grid>
</AccordionDetails>
</Accordion>
</Box>
);
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { TextField, MenuItem, Tooltip, IconButton, InputAdornment, Box, Typography } from '@mui/material';
import { InfoOutlined } from '@mui/icons-material';
interface TooltipContent {
title: string;
description: string;
examples?: Array<{ label: string; description: string }>;
}
interface SelectFieldWithTooltipProps {
label: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
helperText?: string;
options: string[];
customValues?: string[];
tooltip: TooltipContent;
sx?: any;
}
export const SelectFieldWithTooltip: React.FC<SelectFieldWithTooltipProps> = ({
label,
value,
onChange,
helperText,
options,
customValues = [],
tooltip,
sx,
}) => {
const allOptions = [...options, ...customValues];
const isCustom = (option: string) => customValues.includes(option);
return (
<TextField
fullWidth
select
label={label}
value={value}
onChange={onChange}
helperText={helperText}
sx={sx}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<Tooltip
title={
<Box>
<Typography variant="body2" sx={{ fontWeight: 600, mb: 0.5 }}>
{tooltip.title}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{tooltip.description}
</Typography>
{tooltip.examples && tooltip.examples.length > 0 && (
<>
<Typography variant="body2" component="div">
{tooltip.examples.map((example, index) => (
<React.Fragment key={index}>
<strong>{example.label}</strong>: {example.description}
{index < tooltip.examples!.length - 1 && <br />}
</React.Fragment>
))}
</Typography>
</>
)}
</Box>
}
arrow
placement="top"
>
<IconButton size="small" edge="end">
<InfoOutlined fontSize="small" />
</IconButton>
</Tooltip>
</InputAdornment>
),
}}
>
{allOptions.map((option) => (
<MenuItem key={option} value={option}>
{option}
{isCustom(option) && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main', fontStyle: 'italic' }}>
(AI Generated)
</Typography>
)}
</MenuItem>
))}
</TextField>
);
};

View File

@@ -0,0 +1,191 @@
import React from 'react';
import { Grid } from '@mui/material';
import { SelectFieldWithTooltip } from './SelectFieldWithTooltip';
import { SectionProps } from './types';
import {
WRITING_STYLES,
STORY_TONES,
NARRATIVE_POVS,
AUDIENCE_AGE_GROUPS,
CONTENT_RATINGS,
ENDING_PREFERENCES,
STORY_LENGTHS,
} from './constants';
interface StoryConfigurationSectionProps extends SectionProps {
normalizedAudienceAgeGroup: string;
}
export const StoryConfigurationSection: React.FC<StoryConfigurationSectionProps> = ({
state,
customValues,
textFieldStyles,
normalizedAudienceAgeGroup,
}) => {
return (
<>
{/* Writing Style */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Writing Style"
value={state.writingStyle}
onChange={(e) => state.setWritingStyle(e.target.value)}
helperText="Choose the narrative style and prose approach"
options={WRITING_STYLES}
customValues={customValues.customWritingStyles}
sx={textFieldStyles}
tooltip={{
title: 'Writing Style',
description: 'Select the narrative style that best fits your story. This affects sentence structure, vocabulary, and overall prose approach.',
examples: [
{ label: 'Formal', description: 'Structured, academic, precise language' },
{ label: 'Casual', description: 'Conversational, relaxed, everyday language' },
{ label: 'Poetic', description: 'Lyrical, metaphorical, rich imagery' },
{ label: 'Humorous', description: 'Witty, playful, comedic tone' },
{ label: 'Narrative', description: 'Traditional storytelling style' },
],
}}
/>
</Grid>
{/* Story Tone */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Story Tone"
value={state.storyTone}
onChange={(e) => state.setStoryTone(e.target.value)}
helperText="Set the emotional atmosphere and mood of your story"
options={STORY_TONES}
customValues={customValues.customStoryTones}
sx={textFieldStyles}
tooltip={{
title: 'Story Tone',
description: 'The tone determines the emotional atmosphere and overall mood of your story. It affects how readers feel while reading.',
examples: [
{ label: 'Dark', description: 'Serious, grim, somber atmosphere' },
{ label: 'Uplifting', description: 'Positive, hopeful, inspiring' },
{ label: 'Suspenseful', description: 'Tense, thrilling, edge-of-seat' },
{ label: 'Whimsical', description: 'Playful, fanciful, lighthearted' },
{ label: 'Mysterious', description: 'Enigmatic, puzzling, intriguing' },
],
}}
/>
</Grid>
{/* Narrative POV */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Narrative Point of View"
value={state.narrativePOV}
onChange={(e) => state.setNarrativePOV(e.target.value)}
helperText="Choose the perspective from which the story is told"
options={NARRATIVE_POVS}
customValues={customValues.customNarrativePOVs}
sx={textFieldStyles}
tooltip={{
title: 'Narrative Point of View',
description: "Select the perspective from which your story is narrated. This determines how much readers know about characters and events.",
examples: [
{ label: 'First Person', description: '"I" perspective, limited to one character\'s thoughts' },
{ label: 'Third Person Limited', description: '"He/She" perspective, follows one character closely' },
{ label: 'Third Person Omniscient', description: '"He/She" perspective, knows all characters\' thoughts' },
],
}}
/>
</Grid>
{/* Audience Age Group */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Audience Age Group"
value={normalizedAudienceAgeGroup}
onChange={(e) => state.setAudienceAgeGroup(e.target.value)}
helperText="Target age group for your story"
options={AUDIENCE_AGE_GROUPS}
customValues={customValues.customAudienceAgeGroups}
sx={textFieldStyles}
tooltip={{
title: 'Audience Age Group',
description: 'Select the primary target age group. This affects language complexity, themes, and content appropriateness.',
examples: [
{ label: 'Children (5-12)', description: 'Simple language, clear themes, age-appropriate content' },
{ label: 'Young Adults (13-17)', description: 'Moderate complexity, coming-of-age themes' },
{ label: 'Adults (18+)', description: 'Complex themes, mature content allowed' },
{ label: 'All Ages', description: 'Universal appeal, family-friendly' },
],
}}
/>
</Grid>
{/* Content Rating */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Content Rating"
value={state.contentRating}
onChange={(e) => state.setContentRating(e.target.value)}
helperText="Set the content rating based on themes and material"
options={CONTENT_RATINGS}
customValues={customValues.customContentRatings}
sx={textFieldStyles}
tooltip={{
title: 'Content Rating',
description: 'Select the appropriate content rating based on themes, language, violence, and mature content in your story.',
examples: [
{ label: 'G', description: 'General audience, all ages appropriate' },
{ label: 'PG', description: 'Parental guidance suggested, mild themes' },
{ label: 'PG-13', description: 'Parents strongly cautioned, some mature content' },
{ label: 'R', description: 'Restricted, mature themes and content' },
],
}}
/>
</Grid>
{/* Ending Preference */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Ending Preference"
value={state.endingPreference}
onChange={(e) => state.setEndingPreference(e.target.value)}
helperText="Choose how you want your story to conclude"
options={ENDING_PREFERENCES}
customValues={customValues.customEndingPreferences}
sx={textFieldStyles}
tooltip={{
title: 'Ending Preference',
description: 'Select the type of ending you want for your story. This guides the resolution and final emotional impact.',
examples: [
{ label: 'Happy', description: 'Positive resolution, characters succeed' },
{ label: 'Tragic', description: 'Sad or bittersweet conclusion' },
{ label: 'Cliffhanger', description: 'Open ending, sequel potential' },
{ label: 'Twist', description: 'Unexpected revelation or turn' },
{ label: 'Open-ended', description: 'Ambiguous, reader interpretation' },
{ label: 'Bittersweet', description: 'Mixed emotions, realistic outcome' },
],
}}
/>
</Grid>
{/* Story Length */}
<Grid item xs={12} md={6}>
<SelectFieldWithTooltip
label="Story Length"
value={state.storyLength}
onChange={(e) => state.setStoryLength(e.target.value)}
helperText="Choose the target length for your story"
options={STORY_LENGTHS}
sx={textFieldStyles}
tooltip={{
title: 'Story Length',
description: 'Select the target length for your story. This controls how detailed and extensive the generated story will be.',
examples: [
{ label: 'Short (>1000 words)', description: 'Brief, concise story' },
{ label: 'Medium (>5000 words)', description: 'Standard length story with good detail' },
{ label: 'Long (>10000 words)', description: 'Extended, detailed story with rich development' },
],
}}
/>
</Grid>
</>
);
};

View File

@@ -0,0 +1,151 @@
import React from 'react';
import { Grid, TextField, Button, Box, CircularProgress } from '@mui/material';
import { FormFieldWithTooltip } from './FormFieldWithTooltip';
import { SectionProps } from './types';
interface StoryParametersSectionProps extends SectionProps {
isRegeneratingPremise: boolean;
onRegeneratePremise: () => void;
}
export const StoryParametersSection: React.FC<StoryParametersSectionProps> = ({
state,
textFieldStyles,
isRegeneratingPremise,
onRegeneratePremise,
}) => {
return (
<>
{/* Persona */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Persona"
value={state.persona}
onChange={(e) => state.setPersona(e.target.value)}
placeholder="Describe the author persona (e.g., 'A fantasy writer who loves intricate world-building')"
helperText="Define the author's voice, style, and perspective that will guide the story's narrative"
required
multiline
rows={2}
sx={textFieldStyles}
tooltip={{
title: 'Persona',
description: "The persona defines the author's voice and writing style. This shapes how the story is told, the language used, and the overall narrative approach.",
examples: [
"A fantasy writer who loves intricate world-building and epic quests",
"A mystery novelist who specializes in psychological thrillers",
"A science fiction author who explores existential themes",
],
}}
/>
</Grid>
{/* Story Setting */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Story Setting"
value={state.storySetting}
onChange={(e) => state.setStorySetting(e.target.value)}
placeholder="Describe the setting (e.g., 'A medieval kingdom with magic')"
helperText="Define the time, place, and environment where your story takes place"
required
multiline
rows={2}
sx={textFieldStyles}
tooltip={{
title: 'Story Setting',
description: 'The setting establishes the world, time period, and physical environment of your story. Include details about geography, culture, technology, and any unique elements.',
examples: [
"A medieval kingdom with magic and dragons",
"A cyberpunk city in 2087 where corporations rule",
"A small coastal town in the 1950s with a dark secret",
],
}}
/>
</Grid>
{/* Characters */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Characters"
value={state.characters}
onChange={(e) => state.setCharacters(e.target.value)}
placeholder="Describe the main characters (e.g., 'A young wizard apprentice and her mentor')"
helperText="Describe the main characters, their roles, relationships, and key traits"
required
multiline
rows={2}
sx={textFieldStyles}
tooltip={{
title: 'Characters',
description: "Define your main characters, their roles in the story, relationships with each other, and key personality traits or backgrounds that drive the narrative.",
examples: [
"A young wizard apprentice and her wise mentor",
"A detective with amnesia and a mysterious informant",
"A retired space explorer and their estranged daughter",
],
}}
/>
</Grid>
{/* Plot Elements */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Plot Elements"
value={state.plotElements}
onChange={(e) => state.setPlotElements(e.target.value)}
placeholder="Describe key plot elements (e.g., 'A quest to find a lost artifact, betrayal, redemption')"
helperText="Outline the main events, conflicts, themes, and story arcs that drive the narrative"
required
multiline
rows={3}
sx={textFieldStyles}
tooltip={{
title: 'Plot Elements',
description: 'Describe the key events, conflicts, themes, and story arcs. Include main challenges, obstacles, and the central conflict that drives your story forward.',
examples: [
"A quest to find a lost artifact, betrayal, redemption",
"A murder mystery, conspiracy, memory loss",
"Return to a changed world, uncovering hidden truths, rebellion",
],
}}
/>
</Grid>
{/* Premise */}
<Grid item xs={12}>
<FormFieldWithTooltip
label="Story Premise"
value={state.premise || ''}
onChange={(e) => state.setPremise(e.target.value)}
placeholder="Enter or generate a brief premise for your story (1-2 sentences)"
helperText="A brief summary of your story concept (1-2 sentences). This will be used to generate the story outline."
multiline
rows={3}
sx={textFieldStyles}
tooltip={{
title: 'Story Premise',
description: 'The premise is a brief summary (1-2 sentences) that captures the core concept of your story. It should describe who, where, and what the main challenge or adventure is. This will be used to generate the detailed story outline.',
examples: [
"A young wizard must find a lost artifact to save her kingdom from darkness.",
"A detective with amnesia must solve a murder mystery to uncover their own past.",
"A retired space explorer returns to Earth to discover it has changed beyond recognition.",
],
}}
/>
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
<Button
variant="outlined"
size="small"
onClick={onRegeneratePremise}
disabled={isRegeneratingPremise || !state.persona || !state.storySetting || !state.characters || !state.plotElements}
startIcon={isRegeneratingPremise ? <CircularProgress size={16} /> : null}
>
{isRegeneratingPremise ? 'Regenerating...' : 'Regenerate Premise'}
</Button>
</Box>
</Grid>
</>
);
};

View File

@@ -0,0 +1,79 @@
// Story setup constants
export const WRITING_STYLES = [
'Formal',
'Casual',
'Poetic',
'Humorous',
'Academic',
'Journalistic',
'Narrative',
];
export const STORY_TONES = [
'Dark',
'Uplifting',
'Suspenseful',
'Whimsical',
'Melancholic',
'Mysterious',
'Romantic',
'Adventurous',
];
export const NARRATIVE_POVS = [
'First Person',
'Third Person Limited',
'Third Person Omniscient',
];
export const AUDIENCE_AGE_GROUPS = [
'Children (5-12)',
'Young Adults (13-17)',
'Adults (18+)',
'All Ages',
];
export const CONTENT_RATINGS = ['G', 'PG', 'PG-13', 'R'];
export const ENDING_PREFERENCES = [
'Happy',
'Tragic',
'Cliffhanger',
'Twist',
'Open-ended',
'Bittersweet',
];
export const STORY_LENGTHS = [
'Short (>1000 words)',
'Medium (>5000 words)',
'Long (>10000 words)',
];
export const IMAGE_PROVIDERS = [
{ value: '', label: 'Auto (Default)' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'huggingface', label: 'HuggingFace' },
{ value: 'stability', label: 'Stability AI' },
];
export const AUDIO_PROVIDERS = [
{ value: 'gtts', label: 'Google TTS (gTTS)' },
{ value: 'pyttsx3', label: 'pyttsx3' },
];
export const COMMON_IMAGE_SIZES = [
{ width: 512, height: 512, label: '512x512 (Square)' },
{ width: 768, height: 768, label: '768x768 (Square)' },
{ width: 1024, height: 1024, label: '1024x1024 (Square)' },
{ width: 1024, height: 768, label: '1024x768 (Landscape)' },
{ width: 768, height: 1024, label: '768x1024 (Portrait)' },
];
export const STORY_IDEA_PLACEHOLDERS = [
"A young wizard discovers a magical artifact in an ancient forest. The artifact holds the power to restore balance to a dying realm, but it comes with a terrible cost. The wizard must choose between saving the world and losing everything they hold dear.",
"In a cyberpunk future where memories can be bought and sold, a detective with no past must solve a murder that threatens to expose a conspiracy spanning decades. The deeper they dig, the more they realize their own memories might have been stolen.",
"A retired space explorer returns to their home planet after 50 years, only to find it has been transformed into a utopian society that erases all traces of the past. They must uncover the truth about what happened while avoiding the watchful eyes of the perfect world they helped create.",
];

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect } from 'react';
import { Paper, Typography, Box, Button, Alert, Grid, CircularProgress } from '@mui/material';
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
import { storyWriterApi, StoryScene } from '../../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../../api/client';
import { StoryParametersSection } from './StoryParametersSection';
import { StoryConfigurationSection } from './StoryConfigurationSection';
import { FeatureCheckboxesSection } from './FeatureCheckboxesSection';
import { GenerationSettingsSection } from './GenerationSettingsSection';
import { AIStorySetupModal } from './AIStorySetupModal';
import { textFieldStyles, paperStyles } from './styles';
import { AUDIENCE_AGE_GROUPS } from './constants';
import { StorySetupProps, CustomValuesState, CustomValuesSetters } from './types';
const StorySetup: React.FC<StorySetupProps> = ({ state, onNext }) => {
const [isRegeneratingPremise, setIsRegeneratingPremise] = useState(false);
const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
// Track custom values from AI-generated options
const [customWritingStyles, setCustomWritingStyles] = useState<string[]>([]);
const [customStoryTones, setCustomStoryTones] = useState<string[]>([]);
const [customNarrativePOVs, setCustomNarrativePOVs] = useState<string[]>([]);
const [customAudienceAgeGroups, setCustomAudienceAgeGroups] = useState<string[]>([]);
const [customContentRatings, setCustomContentRatings] = useState<string[]>([]);
const [customEndingPreferences, setCustomEndingPreferences] = useState<string[]>([]);
const customValues: CustomValuesState = {
customWritingStyles,
customStoryTones,
customNarrativePOVs,
customAudienceAgeGroups,
customContentRatings,
customEndingPreferences,
};
const handleGenerateOutlineAndProceed = async () => {
if (!state.premise) {
setError('Please generate a premise before generating the outline');
return;
}
setIsGeneratingOutline(true);
setError(null);
try {
const request = state.getRequest();
const response = await storyWriterApi.generateOutline(state.premise, request);
if (response.success && response.outline) {
if (response.is_structured && Array.isArray(response.outline)) {
const scenes = response.outline as StoryScene[];
state.setOutlineScenes(scenes);
state.setIsOutlineStructured(true);
const formattedOutline = scenes
.map((scene, idx) => `Scene ${scene.scene_number || idx + 1}: ${scene.title}\n${scene.description}`)
.join('\n\n');
state.setOutline(formattedOutline);
} else {
state.setOutline(typeof response.outline === 'string' ? response.outline : String(response.outline));
state.setOutlineScenes(null);
state.setIsOutlineStructured(false);
}
state.setError(null);
onNext();
} else {
throw new Error(typeof response.outline === 'string' ? response.outline : 'Failed to generate outline');
}
} catch (err: any) {
const status = err?.response?.status;
if (status === 429 || status === 402) {
const handled = await triggerSubscriptionError(err);
if (handled) {
setIsGeneratingOutline(false);
return;
}
}
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate outline';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGeneratingOutline(false);
}
};
const customValuesSetters: CustomValuesSetters = {
setCustomWritingStyles,
setCustomStoryTones,
setCustomNarrativePOVs,
setCustomAudienceAgeGroups,
setCustomContentRatings,
setCustomEndingPreferences,
};
// Get normalized audienceAgeGroup value (fallback to default if invalid, but preserve custom values)
const allAudienceAgeGroups = [...AUDIENCE_AGE_GROUPS, ...customAudienceAgeGroups];
const normalizedAudienceAgeGroup = allAudienceAgeGroups.includes(state.audienceAgeGroup)
? state.audienceAgeGroup
: state.audienceAgeGroup === 'Adults'
? 'Adults (18+)'
: state.audienceAgeGroup === 'Children'
? 'Children (5-12)'
: state.audienceAgeGroup === 'Young Adults'
? 'Young Adults (13-17)'
: state.audienceAgeGroup || 'Adults (18+)'; // Preserve custom values instead of defaulting
// Fix invalid audienceAgeGroup values on mount and when state changes (but preserve custom values)
useEffect(() => {
// Only normalize if it's an old format value, not a custom value
if (
state.audienceAgeGroup &&
state.audienceAgeGroup !== normalizedAudienceAgeGroup &&
!allAudienceAgeGroups.includes(state.audienceAgeGroup) &&
(state.audienceAgeGroup === 'Adults' ||
state.audienceAgeGroup === 'Children' ||
state.audienceAgeGroup === 'Young Adults')
) {
state.setAudienceAgeGroup(normalizedAudienceAgeGroup);
}
}, [state.audienceAgeGroup, normalizedAudienceAgeGroup, state.setAudienceAgeGroup, allAudienceAgeGroups]);
const handleRegeneratePremise = async () => {
// Validate required fields
if (!state.persona || !state.storySetting || !state.characters || !state.plotElements) {
setError('Please fill in all required fields (Persona, Setting, Characters, Plot Elements)');
return;
}
setIsRegeneratingPremise(true);
setError(null);
try {
const request = state.getRequest();
const response = await storyWriterApi.generatePremise(request);
if (response.success && response.premise) {
state.setPremise(response.premise);
state.setError(null);
} else {
throw new Error(response.premise || 'Failed to generate premise');
}
} catch (err: any) {
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('StorySetup: Detected subscription error in regenerate premise, triggering global handler', {
status,
data: err?.response?.data,
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('StorySetup: Global subscription error handler triggered successfully');
setIsRegeneratingPremise(false);
return;
} else {
console.warn('StorySetup: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate premise';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsRegeneratingPremise(false);
}
};
return (
<Paper sx={paperStyles}>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
Story Setup
</Typography>
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037' }}>
Configure your story parameters and premise. Fill in the required fields and click "Next: Generate Outline" to continue.
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* AI Story Setup Button */}
<Box sx={{ mb: 4 }}>
<Button variant="outlined" color="primary" size="large" onClick={() => setIsModalOpen(true)} sx={{ mb: 2 }}>
Generate Story Setup With Alwrity AI
</Button>
</Box>
<Grid container spacing={3}>
{/* Story Parameters Section */}
<StoryParametersSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
isRegeneratingPremise={isRegeneratingPremise}
onRegeneratePremise={handleRegeneratePremise}
/>
{/* Story Configuration Section */}
<StoryConfigurationSection
state={state}
customValues={customValues}
textFieldStyles={textFieldStyles}
normalizedAudienceAgeGroup={normalizedAudienceAgeGroup}
/>
{/* Feature Checkboxes Section */}
<FeatureCheckboxesSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
</Grid>
{/* Generation Settings Section */}
<GenerationSettingsSection state={state} customValues={customValues} textFieldStyles={textFieldStyles} />
{/* Generate Button */}
<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end', gap: 2 }}>
<Button
variant="contained"
size="large"
onClick={handleGenerateOutlineAndProceed}
disabled={
!state.persona ||
!state.storySetting ||
!state.characters ||
!state.plotElements ||
!state.premise ||
isGeneratingOutline
}
sx={{ minWidth: 200 }}
>
{isGeneratingOutline ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating Outline...
</>
) : (
'Generate Outline'
)}
</Button>
</Box>
{/* AI Story Setup Modal */}
<AIStorySetupModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
state={state}
customValuesSetters={customValuesSetters}
/>
</Paper>
);
};
export default StorySetup;

View File

@@ -0,0 +1,82 @@
// Shared styles for Story Setup components
export const textFieldStyles = {
'& .MuiOutlinedInput-root': {
backgroundColor: '#FFFFFF',
color: '#1A1611',
'& fieldset': {
borderColor: '#8D6E63',
borderWidth: '1.5px',
},
'&:hover fieldset': {
borderColor: '#5D4037',
},
'&.Mui-focused fieldset': {
borderColor: '#3E2723',
borderWidth: '2px',
},
},
'& .MuiInputLabel-root': {
color: '#3E2723',
fontWeight: 500,
'&.Mui-focused': {
color: '#1A1611',
fontWeight: 600,
},
'&.Mui-required': {
'&::after': {
color: '#D32F2F',
},
},
},
'& .MuiFormHelperText-root': {
color: '#5D4037',
fontSize: '0.875rem',
fontWeight: 400,
marginTop: '4px',
},
'& .MuiInputBase-input': {
color: '#1A1611',
'&::placeholder': {
color: '#8D6E63',
opacity: 0.7,
},
},
'& .MuiSelect-select': {
color: '#1A1611',
},
'& .MuiMenuItem-root': {
color: '#1A1611',
'&:hover': {
backgroundColor: '#F7F3E9',
},
'&.Mui-selected': {
backgroundColor: '#E8E5D3',
'&:hover': {
backgroundColor: '#E8E5D3',
},
},
},
};
export const paperStyles = {
p: 4,
mt: 2,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
};
export const accordionStyles = {
mb: 2,
backgroundColor: '#FAF9F6', // Slightly lighter cream for accordions
'&:before': {
display: 'none', // Remove default border
},
};
export const cardStyles = {
backgroundColor: '#FAF9F6', // Slightly lighter cream for cards
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.08)',
};

View File

@@ -0,0 +1,33 @@
// Type definitions for Story Setup components
import { useStoryWriterState } from '../../../../hooks/useStoryWriterState';
export interface StorySetupProps {
state: ReturnType<typeof useStoryWriterState>;
onNext: () => void;
}
export interface CustomValuesState {
customWritingStyles: string[];
customStoryTones: string[];
customNarrativePOVs: string[];
customAudienceAgeGroups: string[];
customContentRatings: string[];
customEndingPreferences: string[];
}
export interface CustomValuesSetters {
setCustomWritingStyles: React.Dispatch<React.SetStateAction<string[]>>;
setCustomStoryTones: React.Dispatch<React.SetStateAction<string[]>>;
setCustomNarrativePOVs: React.Dispatch<React.SetStateAction<string[]>>;
setCustomAudienceAgeGroups: React.Dispatch<React.SetStateAction<string[]>>;
setCustomContentRatings: React.Dispatch<React.SetStateAction<string[]>>;
setCustomEndingPreferences: React.Dispatch<React.SetStateAction<string[]>>;
}
export interface SectionProps {
state: ReturnType<typeof useStoryWriterState>;
customValues: CustomValuesState;
textFieldStyles: any;
}

View File

@@ -0,0 +1,292 @@
import React, { useState } from 'react';
import {
Box,
Paper,
Typography,
Button,
TextField,
Alert,
CircularProgress,
} from '@mui/material';
import { useStoryWriterState } from '../../../hooks/useStoryWriterState';
import { storyWriterApi } from '../../../services/storyWriterApi';
import { triggerSubscriptionError } from '../../../api/client';
interface StoryWritingProps {
state: ReturnType<typeof useStoryWriterState>;
onNext: () => void;
}
// Helper function to check if story is short
const isShortStory = (storyLength: string | null | undefined): boolean => {
if (!storyLength) return false;
const storyLengthLower = storyLength.toLowerCase();
return storyLengthLower.includes('short') || storyLengthLower.includes('1000');
};
const StoryWriting: React.FC<StoryWritingProps> = ({ state, onNext }) => {
const [isGenerating, setIsGenerating] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleGenerateStart = async () => {
if (!state.premise || (!state.outline && !state.outlineScenes)) {
setError('Please generate a premise and outline first');
return;
}
setIsGenerating(true);
setError(null);
try {
const request = state.getRequest();
// Use structured scenes if available, otherwise use text outline
const outline = state.isOutlineStructured && state.outlineScenes
? state.outlineScenes
: (state.outline || '');
const response = await storyWriterApi.generateStoryStart(
state.premise,
outline,
request
);
if (response.success && response.story) {
state.setStoryContent(response.story);
state.setIsComplete(response.is_complete);
state.setError(null);
} else {
throw new Error(response.story || 'Failed to generate story');
}
} catch (err: any) {
console.error('Story start generation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('StoryWriting: Detected subscription error, triggering global handler', {
status,
data: err?.response?.data
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('StoryWriting: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsGenerating(false);
return;
} else {
console.warn('StoryWriting: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
const errorMessage = err.response?.data?.detail || err.message || 'Failed to generate story';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsGenerating(false);
}
};
const handleContinue = async () => {
if (!state.premise || (!state.outline && !state.outlineScenes) || !state.storyContent) {
setError('Please generate story content first');
return;
}
setIsContinuing(true);
setError(null);
try {
const request = state.getRequest();
// Use structured scenes if available, otherwise use text outline
const outline = state.isOutlineStructured && state.outlineScenes
? state.outlineScenes
: (state.outline || '');
const continueRequest = {
...request,
premise: state.premise,
outline: outline,
story_text: state.storyContent,
};
const response = await storyWriterApi.continueStory(continueRequest);
if (response.success && response.continuation) {
// Check if continuation is IAMDONE marker
const isDone = response.is_complete || /IAMDONE/i.test(response.continuation);
// Strip IAMDONE marker if present for cleaner display
const cleanContinuation = response.continuation.replace(/IAMDONE/gi, '').trim();
// Only append continuation if it's not just IAMDONE or empty
if (cleanContinuation) {
state.setStoryContent((state.storyContent || '') + '\n\n' + cleanContinuation);
}
// Set completion status
state.setIsComplete(isDone);
// If story is complete, show success message
if (isDone) {
console.log('Story is complete. Word count target reached.');
}
state.setError(null);
} else {
throw new Error(response.continuation || 'Failed to continue story');
}
} catch (err: any) {
console.error('Story continuation failed:', err);
// Check if this is a subscription error (429/402) and trigger global subscription modal
const status = err?.response?.status;
if (status === 429 || status === 402) {
console.log('StoryWriting: Detected subscription error in continuation, triggering global handler', {
status,
data: err?.response?.data
});
const handled = await triggerSubscriptionError(err);
if (handled) {
console.log('StoryWriting: Global subscription error handler triggered successfully');
// Don't set local error - let the global modal handle it
setIsContinuing(false);
return;
} else {
console.warn('StoryWriting: Global subscription error handler did not handle the error');
}
}
// For non-subscription errors, show local error message
const errorMessage = err.response?.data?.detail || err.message || 'Failed to continue story';
setError(errorMessage);
state.setError(errorMessage);
} finally {
setIsContinuing(false);
}
};
const handleContinueToExport = () => {
if (state.storyContent && state.isComplete) {
onNext();
}
};
return (
<Paper
sx={{
p: 4,
mt: 2,
backgroundColor: '#F7F3E9', // Warm cream/parchment color
color: '#2C2416', // Dark brown text for readability
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08)',
}}
>
<Typography variant="h5" gutterBottom sx={{ mb: 3, fontWeight: 600, color: '#1A1611' }}>
Story Writing
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: '#5D4037' }}>
Generate your story content. You can generate the starting section and continue writing until the story is complete.
</Typography>
{state.storyContent && (
<Typography variant="body2" sx={{ mb: 4, color: '#5D4037', fontStyle: 'italic' }}>
Current word count: {state.storyContent.split(/\s+/).filter(word => word.length > 0).length} words
{state.storyLength && (
<> (Target: {state.storyLength.includes('1000') ? '>1000' : state.storyLength.includes('5000') ? '>5000' : '>10000'} words)</>
)}
</Typography>
)}
{error && (
<Alert severity="error" sx={{ mb: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{(!state.premise || (!state.outline && !state.outlineScenes)) && (
<Alert severity="warning" sx={{ mb: 3 }}>
Please generate a premise and outline first.
</Alert>
)}
{state.storyContent ? (
<>
<TextField
fullWidth
multiline
rows={20}
value={state.storyContent}
onChange={(e) => state.setStoryContent(e.target.value)}
label="Story Content"
sx={{ mb: 3 }}
/>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', flexWrap: 'wrap', alignItems: 'center' }}>
{/* Only show Continue Writing button for medium/long stories that are not complete */}
{!state.isComplete && !isShortStory(state.storyLength) && (
<Button
variant="outlined"
onClick={handleContinue}
disabled={isContinuing || !state.storyContent}
>
{isContinuing ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Continuing...
</>
) : (
'Continue Writing'
)}
</Button>
)}
{/* Show completion message if story is complete */}
{state.isComplete && (
<Alert severity="success" sx={{ flex: 1, minWidth: '200px' }}>
Story is complete! You can proceed to export.
</Alert>
)}
{/* Show info message for short stories that are not complete yet */}
{!state.isComplete && isShortStory(state.storyLength) && (
<Alert severity="info" sx={{ flex: 1, minWidth: '200px' }}>
Short stories are generated in one call. If the story is incomplete, please regenerate it.
</Alert>
)}
<Button
variant="contained"
onClick={handleContinueToExport}
disabled={!state.storyContent || !state.isComplete}
>
Continue to Export
</Button>
</Box>
</>
) : (
<Box>
<Alert severity="info" sx={{ mb: 3 }}>
{state.premise && (state.outline || state.outlineScenes)
? 'Click "Generate Story" to start writing your story.'
: 'Please generate a premise and outline first.'}
</Alert>
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant="contained"
onClick={handleGenerateStart}
disabled={isGenerating || !state.premise || (!state.outline && !state.outlineScenes)}
>
{isGenerating ? (
<>
<CircularProgress size={20} sx={{ mr: 1 }} />
Generating...
</>
) : (
'Generate Story'
)}
</Button>
</Box>
</Box>
)}
</Paper>
);
};
export default StoryWriting;

View File

@@ -0,0 +1,120 @@
import React from 'react';
import { Box, Container, Typography, useTheme } from '@mui/material';
import { useStoryWriterState } from '../../hooks/useStoryWriterState';
import { useStoryWriterPhaseNavigation } from '../../hooks/useStoryWriterPhaseNavigation';
import StorySetup from './Phases/StorySetup';
import StoryOutline from './Phases/StoryOutline';
import StoryWriting from './Phases/StoryWriting';
import StoryExport from './Phases/StoryExport';
import PhaseNavigation from './PhaseNavigation';
export const StoryWriter: React.FC = () => {
const theme = useTheme();
// State management
const state = useStoryWriterState();
// Phase navigation
const {
phases,
currentPhase,
navigateToPhase,
} = useStoryWriterPhaseNavigation({
hasPremise: !!state.premise,
hasOutline: !!state.outline,
hasStoryContent: !!state.storyContent,
isComplete: state.isComplete,
});
// Reset handler
const handleReset = () => {
// Reset story state (this also clears localStorage)
state.resetState();
// Simplest approach: reload the page to ensure a clean slate
if (typeof window !== 'undefined') {
window.location.reload();
}
};
// Render phase content
const renderPhaseContent = () => {
switch (currentPhase) {
case 'setup':
return <StorySetup state={state} onNext={() => navigateToPhase('outline')} />;
case 'outline':
return <StoryOutline state={state} onNext={() => navigateToPhase('writing')} />;
case 'writing':
return <StoryWriting state={state} onNext={() => navigateToPhase('export')} />;
case 'export':
return <StoryExport state={state} />;
default:
return <StorySetup state={state} onNext={() => navigateToPhase('outline')} />;
}
};
return (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
padding: theme.spacing(4),
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'url("data:image/svg+xml,%3Csvg width="80" height="80" viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg"%3E%3Cg fill="none" fill-rule="evenodd"%3E%3Cg fill="%23ffffff" fill-opacity="0.03"%3E%3Ccircle cx="40" cy="40" r="3"/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")',
pointerEvents: 'none',
},
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '600px',
height: '600px',
background: 'radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%)',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 0,
},
}}
>
<Container
maxWidth="xl"
sx={{
position: 'relative',
zIndex: 1,
}}
>
{/* Header */}
<Box sx={{ mb: 4 }}>
<Typography variant="h3" component="h1" gutterBottom>
Story Writer
</Typography>
<Typography variant="body1" color="text.secondary">
Create compelling stories with AI assistance
</Typography>
</Box>
{/* Phase Navigation */}
<PhaseNavigation
phases={phases}
currentPhase={currentPhase}
onPhaseClick={navigateToPhase}
onReset={handleReset}
/>
{/* Phase Content */}
<Box sx={{ mt: 4 }}>
{renderPhaseContent()}
</Box>
</Container>
</Box>
);
};
export default StoryWriter;

View File

@@ -0,0 +1,2 @@
export { default as StoryWriter } from './StoryWriter';
export { default as PhaseNavigation } from './PhaseNavigation';

View File

@@ -58,16 +58,49 @@ const WixCallbackPage: React.FC = () => {
}
} catch {}
// Fallback redirect for same-tab flow - check if we have a stored redirect URL
const redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
let redirectUrl = sessionStorage.getItem('wix_oauth_redirect');
console.log('[Wix Callback] Checking redirect URL:', redirectUrl);
if (redirectUrl) {
// Normalize the redirect URL to use the current origin if it's different
// This handles cases where localhost redirect URL is used but callback is on ngrok (or vice versa)
try {
const urlObj = new URL(redirectUrl);
const currentOrigin = window.location.origin;
// If the stored redirect URL has a different origin, update it to current origin
// This ensures the redirect works regardless of localhost vs ngrok
if (urlObj.origin !== currentOrigin) {
redirectUrl = `${currentOrigin}${urlObj.pathname}${urlObj.hash}${urlObj.search}`;
console.log('[Wix Callback] Normalized redirect URL to current origin:', {
original: sessionStorage.getItem('wix_oauth_redirect'),
normalized: redirectUrl,
currentOrigin
});
}
} catch (e) {
console.warn('[Wix Callback] Failed to normalize redirect URL, using as-is:', e);
}
console.log('[Wix Callback] Redirecting to stored URL:', redirectUrl);
sessionStorage.removeItem('wix_oauth_redirect');
// Use replace to avoid adding to history
window.location.replace(redirectUrl);
} else {
// Default to onboarding if no redirect URL stored
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
window.location.replace('/onboarding?step=5&wix_connected=true');
// Check if we're coming from blog writer by checking referrer or other indicators
// If we can't determine the source, default to blog writer publish phase
const referrer = document.referrer;
const isFromBlogWriter = referrer.includes('/blog-writer') ||
window.location.search.includes('from=blog-writer');
if (isFromBlogWriter) {
console.log('[Wix Callback] Detected blog writer context, redirecting to blog writer publish phase');
window.location.replace('/blog-writer#publish');
} else {
// Default to onboarding if no redirect URL stored and not from blog writer
console.warn('[Wix Callback] No redirect URL found, defaulting to onboarding');
window.location.replace('/onboarding?step=5&wix_connected=true');
}
}
} catch (e: any) {
setError(e?.message || 'OAuth callback failed');

View File

@@ -28,15 +28,33 @@ import {
calculateUsagePercentage
} from '../../services/billingService';
// Terminal Theme
import {
TerminalCard,
TerminalCardContent,
TerminalTypography,
TerminalChip,
TerminalChipSuccess,
TerminalChipError,
TerminalChipWarning,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
interface BillingOverviewProps {
usageStats: UsageStats;
onRefresh: () => void;
terminalTheme?: boolean;
}
const BillingOverview: React.FC<BillingOverviewProps> = ({
usageStats,
onRefresh
onRefresh,
terminalTheme = false
}) => {
// Conditional component selection based on terminal theme
const CardComponent = terminalTheme ? TerminalCard : Card;
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
// Debug logs removed to reduce console noise
const costUsagePercentage = calculateUsagePercentage(
@@ -47,9 +65,53 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
// Debug logs removed to reduce console noise
const getStatusChip = () => {
const status = usageStats.usage_status;
const status: string = usageStats.usage_status;
const icon = getUsageStatusIcon(status);
// Helper function to format status label
const formatStatusLabel = (statusStr: string): string => {
return statusStr.charAt(0).toUpperCase() + statusStr.slice(1).replace('_', ' ');
};
if (terminalTheme) {
if (status === 'active') {
return (
<TerminalChipSuccess
icon={<span>{icon}</span>}
label={formatStatusLabel(status)}
size="small"
sx={{ fontWeight: 'bold' }}
/>
);
} else if (status === 'warning') {
return (
<TerminalChipWarning
icon={<span>{icon}</span>}
label={formatStatusLabel(status)}
size="small"
sx={{ fontWeight: 'bold' }}
/>
);
} else if (status === 'limit_reached') {
return (
<TerminalChipError
icon={<span>{icon}</span>}
label={formatStatusLabel(status)}
size="small"
sx={{ fontWeight: 'bold' }}
/>
);
}
return (
<TerminalChip
icon={<span>{icon}</span>}
label={formatStatusLabel(status)}
size="small"
sx={{ fontWeight: 'bold' }}
/>
);
}
let chipColor: 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' = 'default';
if (status === 'active') chipColor = 'success';
else if (status === 'warning') chipColor = 'warning';
@@ -58,7 +120,7 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
return (
<Chip
icon={<span>{icon}</span>}
label={status.charAt(0).toUpperCase() + status.slice(1).replace('_', ' ')}
label={formatStatusLabel(status)}
color={chipColor}
size="small"
sx={{ fontWeight: 'bold' }}
@@ -66,6 +128,25 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
);
};
const cardStyles = terminalTheme
? {
height: '100%',
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 3,
position: 'relative' as const,
overflow: 'hidden' as const
}
: {
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
position: 'relative' as const,
overflow: 'hidden' as const
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -73,34 +154,24 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
transition={{ duration: 0.4 }}
whileHover={{ scale: 1.02 }}
>
<Card
sx={{
height: '100%',
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
position: 'relative',
overflow: 'hidden'
}}
>
<CardComponent sx={cardStyles}>
{/* Header */}
<CardContent sx={{ pb: 1 }}>
<CardContentComponent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
<DollarSign size={20} />
<TypographyComponent variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1, fontWeight: 'bold' }}>
<DollarSign size={20} color={terminalTheme ? terminalColors.text : undefined} />
Billing Overview
</Typography>
</TypographyComponent>
<Tooltip title="View your current billing status, usage metrics, and subscription plan details">
<Info size={16} color="rgba(255,255,255,0.7)" />
<Info size={16} color={terminalTheme ? terminalColors.textSecondary : "rgba(255,255,255,0.7)"} />
</Tooltip>
<Tooltip title="Refresh data">
<IconButton
size="small"
onClick={onRefresh}
sx={{
color: 'text.secondary',
'&:hover': { color: 'primary.main' }
color: terminalTheme ? terminalColors.text : 'text.secondary',
'&:hover': { color: terminalTheme ? terminalColors.secondary : 'primary.main' }
}}
>
<RefreshCw size={16} />
@@ -112,9 +183,9 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
<Box sx={{ mb: 3 }}>
{getStatusChip()}
</Box>
</CardContent>
</CardContentComponent>
<CardContent sx={{ pt: 0 }}>
<CardContentComponent sx={{ pt: 0 }}>
{/* Current Cost */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<motion.div
@@ -122,20 +193,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
animate={{ scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<Typography
<TypographyComponent
variant="h3"
sx={{
fontWeight: 'bold',
color: '#ffffff',
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)',
mb: 1
}}
>
{formatCurrency(usageStats.total_cost)}
</Typography>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.8)' }}>
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
Total Cost This Month
</Typography>
</TypographyComponent>
</motion.div>
</Box>
@@ -143,34 +214,34 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
<Box sx={{ mb: 3 }}>
<Tooltip title="Total number of API requests made this billing period">
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
API Calls
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{formatNumber(usageStats.total_calls)}
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
<Tooltip title="Total tokens processed across all API providers (input + output tokens)">
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
Tokens Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{formatNumber(usageStats.total_tokens)}
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
<Tooltip title="Average response time for API requests in the last 24 hours">
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="body2" sx={{ color: 'rgba(255,255,255,0.7)' }}>
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
Avg Response Time
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'bold', color: '#ffffff' }}>
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{usageStats.avg_response_time.toFixed(0)}ms
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
</Box>
@@ -179,12 +250,12 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
{usageStats.limits.limits.monthly_cost > 0 && (
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
<TypographyComponent variant="body2" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
Monthly Cost Limit
</Typography>
<Typography variant="body2" fontWeight="bold">
</TypographyComponent>
<TypographyComponent variant="body2" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{formatPercentage(costUsagePercentage)}
</Typography>
</TypographyComponent>
</Box>
<LinearProgress
variant="determinate"
@@ -192,17 +263,20 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
sx={{
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.1)',
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
backgroundColor: costUsagePercentage > 80 ? '#ef4444' :
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e',
backgroundColor: terminalTheme
? (costUsagePercentage > 80 ? terminalColors.error :
costUsagePercentage > 60 ? terminalColors.warning : terminalColors.success)
: (costUsagePercentage > 80 ? '#ef4444' :
costUsagePercentage > 60 ? '#f59e0b' : '#22c55e'),
borderRadius: 4,
}
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5, display: 'block' }}>
<TypographyComponent variant="caption" sx={{ mt: 0.5, display: 'block', color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
{formatCurrency(usageStats.total_cost)} of {formatCurrency(usageStats.limits.limits.monthly_cost)} limit
</Typography>
</TypographyComponent>
</Box>
)}
@@ -210,69 +284,73 @@ const BillingOverview: React.FC<BillingOverviewProps> = ({
<Box
sx={{
p: 2,
backgroundColor: 'rgba(255,255,255,0.05)',
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.05)',
borderRadius: 2,
border: '1px solid rgba(255,255,255,0.1)'
border: terminalTheme ? `1px solid ${terminalColors.border}` : '1px solid rgba(255,255,255,0.1)'
}}
>
<Typography variant="body2" sx={{ mb: 1, color: 'rgba(255,255,255,0.8)' }}>
<TypographyComponent variant="body2" sx={{ mb: 1, color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)' }}>
Current Plan
</Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', mb: 1, color: '#ffffff' }}>
</TypographyComponent>
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', mb: 1, color: terminalTheme ? terminalColors.text : '#ffffff' }}>
{usageStats.limits.plan_name}
</Typography>
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.7)' }}>
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
{usageStats.limits.tier.charAt(0).toUpperCase() + usageStats.limits.tier.slice(1)} Tier
</Typography>
</TypographyComponent>
</Box>
{/* Quick Stats */}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-around' }}>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'primary.main' }}>
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : 'primary.main' }}>
{usageStats.usage_percentages.gemini_calls.toFixed(0)}%
</Typography>
<Typography variant="caption" color="text.secondary">
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
Gemini Usage
</Typography>
</TypographyComponent>
</Box>
<Box sx={{ textAlign: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: 'secondary.main' }}>
<TypographyComponent variant="h6" sx={{ fontWeight: 'bold', color: terminalTheme ? terminalColors.text : 'secondary.main' }}>
{usageStats.error_rate.toFixed(1)}%
</Typography>
<Typography variant="caption" color="text.secondary">
</TypographyComponent>
<TypographyComponent variant="caption" sx={{ color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)' }}>
Error Rate
</Typography>
</TypographyComponent>
</Box>
</Box>
</CardContent>
</CardContentComponent>
{/* Decorative Elements */}
<Box
sx={{
position: 'absolute',
top: -50,
right: -50,
width: 100,
height: 100,
background: 'radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none'
}}
/>
<Box
sx={{
position: 'absolute',
bottom: -30,
left: -30,
width: 60,
height: 60,
background: 'radial-gradient(circle, rgba(118, 75, 162, 0.1) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none'
}}
/>
</Card>
{/* Decorative Elements - only show in non-terminal theme */}
{!terminalTheme && (
<>
<Box
sx={{
position: 'absolute',
top: -50,
right: -50,
width: 100,
height: 100,
background: 'radial-gradient(circle, rgba(102, 126, 234, 0.1) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none'
}}
/>
<Box
sx={{
position: 'absolute',
bottom: -30,
left: -30,
width: 60,
height: 60,
background: 'radial-gradient(circle, rgba(118, 75, 162, 0.1) 0%, transparent 70%)',
borderRadius: '50%',
pointerEvents: 'none'
}}
/>
</>
)}
</CardComponent>
</motion.div>
);
};

View File

@@ -25,18 +25,36 @@ import { SystemHealth } from '../../types/monitoring';
import { billingService } from '../../services/billingService';
import { monitoringService } from '../../services/monitoringService';
import { onApiEvent } from '../../utils/apiEvents';
import { showToastNotification } from '../../utils/toastNotifications';
// Terminal Theme
import {
TerminalCard,
TerminalCardContent,
TerminalTypography,
TerminalChip,
TerminalChipError,
TerminalChipWarning,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
interface CompactBillingDashboardProps {
userId?: string;
terminalTheme?: boolean;
}
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId }) => {
const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
// Conditional component selection based on terminal theme
const CardComponent = terminalTheme ? TerminalCard : Card;
const CardContentComponent = terminalTheme ? TerminalCardContent : CardContent;
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
const ChipComponent = terminalTheme ? TerminalChip : Chip;
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = async () => {
const fetchData = async (showSuccessToast: boolean = false) => {
try {
setLoading(true);
setError(null);
@@ -48,8 +66,21 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
setDashboardData(billingData);
setSystemHealth(healthData);
// Show success toast only if explicitly requested (user-initiated refresh)
if (showSuccessToast && billingData && healthData) {
showToastNotification(
'Billing data refreshed successfully',
'success',
{ duration: 3000 }
);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch data');
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch data';
setError(errorMessage);
// Always show error toast for failures
showToastNotification(errorMessage, 'error', { duration: 5000 });
} finally {
setLoading(false);
}
@@ -80,35 +111,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
const formatNumber = (num: number) => num.toLocaleString();
if (loading && !dashboardData) {
const loadingCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<Card sx={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
}}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Typography sx={{ color: 'rgba(255,255,255,0.8)' }}>Loading billing data...</Typography>
</CardContent>
</Card>
<CardComponent sx={loadingCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.8)' }}>
Loading billing data...
</TypographyComponent>
</CardContentComponent>
</CardComponent>
);
}
if (error) {
const errorCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.error}`,
borderRadius: 3
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
};
return (
<Card sx={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3
}}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Typography sx={{ color: '#ff6b6b' }}>Error: {error}</Typography>
<IconButton onClick={fetchData} sx={{ mt: 1 }}>
<CardComponent sx={errorCardStyles}>
<CardContentComponent sx={{ textAlign: 'center', py: 4 }}>
<TypographyComponent sx={{ color: terminalTheme ? terminalColors.error : '#ff6b6b' }}>
Error: {error}
</TypographyComponent>
<IconButton onClick={() => fetchData(true)} sx={{ mt: 1, color: terminalTheme ? terminalColors.text : 'inherit' }}>
<RefreshCw size={16} />
</IconButton>
</CardContent>
</Card>
</CardContentComponent>
</CardComponent>
);
}
@@ -116,36 +167,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
const { current_usage, limits, alerts } = dashboardData;
const mainCardStyles = terminalTheme
? {
backgroundColor: terminalColors.background,
border: `1px solid ${terminalColors.border}`,
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 0 15px rgba(0, 255, 0, 0.2)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: `linear-gradient(90deg, transparent, ${terminalColors.border}, transparent)`,
zIndex: 1
}
}
: {
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
backdropFilter: 'blur(15px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 4,
position: 'relative' as const,
overflow: 'hidden' as const,
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
zIndex: 1
}
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<Card
sx={{
background: 'linear-gradient(135deg, rgba(255,255,255,0.12) 0%, rgba(255,255,255,0.08) 100%)',
backdropFilter: 'blur(15px)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 4,
position: 'relative',
overflow: 'hidden',
boxShadow: '0 8px 32px rgba(0,0,0,0.2), 0 0 0 1px rgba(255,255,255,0.1)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '1px',
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
zIndex: 1
}
}}
>
<CardComponent sx={mainCardStyles}>
{/* Header - Removed to save space */}
<CardContent sx={{ pt: 2 }}>
<CardContentComponent sx={{ pt: 2 }}>
{/* Compact Overview */}
<Grid container spacing={2} sx={{ mb: 2 }}>
{/* Total Cost */}
@@ -170,46 +240,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
textAlign: 'center',
p: 2.5,
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(74, 222, 128, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
border: '1px solid rgba(74, 222, 128, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
zIndex: 1
}
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(74, 222, 128, 0.12) 0%, rgba(34, 197, 94, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(74, 222, 128, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(74, 222, 128, 0.2)',
border: '1px solid rgba(74, 222, 128, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #4ade80, #22c55e)',
zIndex: 1
}
})
}}>
<Typography variant="h5" sx={{
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: '#ffffff',
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{formatCurrency(current_usage.total_cost)}
</Typography>
<Typography variant="body2" sx={{
color: 'rgba(255,255,255,0.9)',
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
Total Cost
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
@@ -236,46 +331,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
textAlign: 'center',
p: 2.5,
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(59, 130, 246, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
border: '1px solid rgba(59, 130, 246, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
zIndex: 1
}
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(37, 99, 235, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(59, 130, 246, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
border: '1px solid rgba(59, 130, 246, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #3b82f6, #2563eb)',
zIndex: 1
}
})
}}>
<Typography variant="h5" sx={{
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: '#ffffff',
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{formatNumber(current_usage.total_calls)}
</Typography>
<Typography variant="body2" sx={{
color: 'rgba(255,255,255,0.9)',
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
API Calls
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
@@ -302,46 +422,71 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
textAlign: 'center',
p: 2.5,
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(168, 85, 247, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
border: '1px solid rgba(168, 85, 247, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
zIndex: 1
}
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${terminalColors.border}40`,
borderColor: terminalColors.secondary
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.border,
zIndex: 1
}
} : {
background: 'linear-gradient(135deg, rgba(168, 85, 247, 0.12) 0%, rgba(147, 51, 234, 0.08) 100%)',
borderRadius: 3,
border: '1px solid rgba(168, 85, 247, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(168, 85, 247, 0.2)',
border: '1px solid rgba(168, 85, 247, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #a855f7, #9333ea)',
zIndex: 1
}
})
}}>
<Typography variant="h5" sx={{
<TypographyComponent variant="h5" sx={{
fontWeight: 800,
color: '#ffffff',
textShadow: '0 2px 8px rgba(0,0,0,0.4)',
color: terminalTheme ? terminalColors.text : '#ffffff',
textShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.4)',
mb: 0.5
}}>
{(current_usage.total_tokens / 1000).toFixed(1)}k
</Typography>
<Typography variant="body2" sx={{
color: 'rgba(255,255,255,0.9)',
</TypographyComponent>
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
Tokens
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
@@ -371,59 +516,89 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
textAlign: 'center',
p: 2.5,
background: systemHealth?.status === 'healthy'
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
borderRadius: 3,
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.25)'
: '1px solid rgba(239, 68, 68, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: systemHealth?.status === 'healthy'
? '0 8px 25px rgba(34, 197, 94, 0.2)'
: '0 8px 25px rgba(239, 68, 68, 0.2)',
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}`,
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: `0 0 15px ${systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error}40`,
borderColor: systemHealth?.status === 'healthy' ? terminalColors.secondary : terminalColors.error
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error,
zIndex: 1
}
} : {
background: systemHealth?.status === 'healthy'
? 'linear-gradient(90deg, #22c55e, #16a34a)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
zIndex: 1
}
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.12) 0%, rgba(22, 163, 74, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(239, 68, 68, 0.12) 0%, rgba(220, 38, 38, 0.08) 100%)',
borderRadius: 3,
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.25)'
: '1px solid rgba(239, 68, 68, 0.25)',
position: 'relative',
overflow: 'hidden',
cursor: 'help',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: systemHealth?.status === 'healthy'
? '0 8px 25px rgba(34, 197, 94, 0.2)'
: '0 8px 25px rgba(239, 68, 68, 0.2)',
border: systemHealth?.status === 'healthy'
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)'
},
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: systemHealth?.status === 'healthy'
? 'linear-gradient(90deg, #22c55e, #16a34a)'
: 'linear-gradient(90deg, #ef4444, #dc2626)',
zIndex: 1
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5, mb: 0.5 }}>
<CheckCircle size={18} color={systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'} />
<Typography variant="body1" sx={{
color: systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b',
<CheckCircle size={18} color={terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b')
} />
<TypographyComponent variant="body1" sx={{
color: terminalTheme
? (systemHealth?.status === 'healthy' ? terminalColors.success : terminalColors.error)
: (systemHealth?.status === 'healthy' ? '#4ade80' : '#ff6b6b'),
fontWeight: 700,
textTransform: 'capitalize',
textShadow: '0 2px 4px rgba(0,0,0,0.3)'
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{systemHealth?.status || 'Unknown'}
</Typography>
</TypographyComponent>
</Box>
<Typography variant="body2" sx={{
color: 'rgba(255,255,255,0.9)',
<TypographyComponent variant="body2" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.9)',
fontWeight: 500,
textTransform: 'uppercase',
letterSpacing: '0.5px',
fontSize: '0.75rem'
}}>
System Health
</Typography>
</TypographyComponent>
</Box>
</Tooltip>
</Grid>
@@ -435,40 +610,46 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
mb: 3,
p: 2.5,
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)'
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.border}`
} : {
background: 'linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.04) 100%)',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)'
})
}}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box>
<Typography variant="subtitle2" sx={{
color: '#ffffff',
<TypographyComponent variant="subtitle2" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 600,
mb: 0.5
}}>
Monthly Budget Usage
</Typography>
<Typography variant="caption" sx={{
color: 'rgba(255,255,255,0.7)',
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
Track your AI spending against monthly limits
</Typography>
</TypographyComponent>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" sx={{
color: '#ffffff',
<TypographyComponent variant="h6" sx={{
color: terminalTheme ? terminalColors.text : '#ffffff',
fontWeight: 'bold',
textShadow: '0 2px 4px rgba(0,0,0,0.3)'
textShadow: terminalTheme ? 'none' : '0 2px 4px rgba(0,0,0,0.3)'
}}>
{formatCurrency(current_usage.total_cost)}
</Typography>
<Typography variant="caption" sx={{
color: 'rgba(255,255,255,0.7)',
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
display: 'block'
}}>
of {formatCurrency(limits.limits.monthly_cost)}
</Typography>
</TypographyComponent>
</Box>
</Box>
<LinearProgress
@@ -477,21 +658,29 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
sx={{
height: 8,
borderRadius: 4,
backgroundColor: 'rgba(255,255,255,0.1)',
backgroundColor: terminalTheme ? terminalColors.backgroundLight : 'rgba(255,255,255,0.1)',
'& .MuiLinearProgress-bar': {
background: current_usage.total_cost / limits.limits.monthly_cost > 0.8
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? 'linear-gradient(90deg, #ffa726, #ff9800)'
: 'linear-gradient(90deg, #4ade80, #22c55e)',
background: terminalTheme
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8
? terminalColors.error
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? terminalColors.warning
: terminalColors.success)
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8
? 'linear-gradient(90deg, #ff6b6b, #ff5252)'
: current_usage.total_cost / limits.limits.monthly_cost > 0.6
? 'linear-gradient(90deg, #ffa726, #ff9800)'
: 'linear-gradient(90deg, #4ade80, #22c55e)'),
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0,0,0,0.2)'
boxShadow: terminalTheme ? 'none' : '0 2px 8px rgba(0,0,0,0.2)'
}
}}
/>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="caption" sx={{
color: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)',
<TypographyComponent variant="caption" sx={{
color: terminalTheme
? (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? terminalColors.error : terminalColors.textSecondary)
: (current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? '#ff6b6b' : 'rgba(255,255,255,0.7)'),
fontWeight: current_usage.total_cost / limits.limits.monthly_cost > 0.8 ? 600 : 400
}}>
{current_usage.total_cost / limits.limits.monthly_cost > 0.8
@@ -500,13 +689,13 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
? '⚡ Moderate usage'
: '✅ Within budget'
}
</Typography>
<Typography variant="caption" sx={{
color: 'rgba(255,255,255,0.7)',
</TypographyComponent>
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.7)',
fontWeight: 500
}}>
{((current_usage.total_cost / limits.limits.monthly_cost) * 100).toFixed(1)}% used
</Typography>
</TypographyComponent>
</Box>
</Box>
)}
@@ -516,38 +705,55 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
<Box sx={{
mb: 3,
p: 2.5,
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
borderRadius: 3,
border: '1px solid rgba(255, 107, 107, 0.2)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
borderRadius: '3px 3px 0 0'
}
...(terminalTheme ? {
backgroundColor: terminalColors.backgroundLight,
borderRadius: 3,
border: `1px solid ${terminalColors.error}`,
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: terminalColors.error,
borderRadius: '3px 3px 0 0'
}
} : {
background: 'linear-gradient(135deg, rgba(255, 107, 107, 0.1) 0%, rgba(239, 68, 68, 0.05) 100%)',
borderRadius: 3,
border: '1px solid rgba(255, 107, 107, 0.2)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: 'linear-gradient(90deg, #ff6b6b, #ef4444)',
borderRadius: '3px 3px 0 0'
}
})
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<AlertTriangle size={18} color="#ff6b6b" />
<Typography variant="subtitle2" sx={{
<AlertTriangle size={18} color={terminalTheme ? terminalColors.error : "#ff6b6b"} />
<TypographyComponent variant="subtitle2" sx={{
fontWeight: 700,
color: '#ff6b6b',
textShadow: '0 1px 2px rgba(0,0,0,0.3)'
color: terminalTheme ? terminalColors.error : '#ff6b6b',
textShadow: terminalTheme ? 'none' : '0 1px 2px rgba(0,0,0,0.3)'
}}>
System Alerts ({alerts.length})
</Typography>
</TypographyComponent>
</Box>
<Typography variant="caption" sx={{
color: 'rgba(255,255,255,0.8)',
<TypographyComponent variant="caption" sx={{
color: terminalTheme ? terminalColors.textSecondary : 'rgba(255,255,255,0.8)',
display: 'block',
mb: 2
}}>
Important notifications requiring your attention
</Typography>
</TypographyComponent>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{alerts.slice(0, 3).map((alert) => (
<Tooltip
@@ -565,42 +771,65 @@ const CompactBillingDashboard: React.FC<CompactBillingDashboardProps> = ({ userI
arrow
placement="top"
>
<Chip
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
backgroundColor: 'rgba(255, 107, 107, 0.2)',
color: '#ff6b6b',
border: '1px solid rgba(255, 107, 107, 0.3)',
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(255, 107, 107, 0.3)',
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
{terminalTheme ? (
<TerminalChipError
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
fontWeight: 500,
'&:hover': {
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
) : (
<Chip
label={alert.title}
size="small"
icon={<AlertTriangle size={14} />}
sx={{
backgroundColor: 'rgba(255, 107, 107, 0.2)',
color: '#ff6b6b',
border: '1px solid rgba(255, 107, 107, 0.3)',
fontWeight: 500,
'&:hover': {
backgroundColor: 'rgba(255, 107, 107, 0.3)',
transform: 'translateY(-1px)'
},
transition: 'all 0.2s ease'
}}
/>
)}
</Tooltip>
))}
{alerts.length > 3 && (
<Chip
label={`+${alerts.length - 3} more`}
size="small"
sx={{
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(255,255,255,0.2)',
fontWeight: 500
}}
/>
terminalTheme ? (
<TerminalChip
label={`+${alerts.length - 3} more`}
size="small"
sx={{ fontWeight: 500 }}
/>
) : (
<Chip
label={`+${alerts.length - 3} more`}
size="small"
sx={{
backgroundColor: 'rgba(255,255,255,0.1)',
color: 'rgba(255,255,255,0.8)',
border: '1px solid rgba(255,255,255,0.2)',
fontWeight: 500
}}
/>
)
)}
</Box>
</Box>
)}
</CardContent>
</Card>
</CardContentComponent>
</CardComponent>
</motion.div>
);
};

View File

@@ -24,6 +24,7 @@ import {
import { billingService } from '../../services/billingService';
import { monitoringService } from '../../services/monitoringService';
import { onApiEvent } from '../../utils/apiEvents';
import { showToastNotification } from '../../utils/toastNotifications';
// Types
import { DashboardData } from '../../types/billing';
@@ -38,20 +39,31 @@ import UsageTrends from './UsageTrends';
import UsageAlerts from './UsageAlerts';
import ComprehensiveAPIBreakdown from './ComprehensiveAPIBreakdown';
// Terminal Theme
import {
TerminalTypography,
TerminalAlert,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
interface EnhancedBillingDashboardProps {
userId?: string;
terminalTheme?: boolean;
}
type ViewMode = 'compact' | 'detailed';
const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ userId }) => {
const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ userId, terminalTheme = false }) => {
// Conditional component selection based on terminal theme
const TypographyComponent = terminalTheme ? TerminalTypography : Typography;
const AlertComponent = terminalTheme ? TerminalAlert : Alert;
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>('compact');
const fetchDashboardData = async () => {
const fetchDashboardData = async (showSuccessToast: boolean = false) => {
try {
const [billingData, healthData] = await Promise.all([
billingService.getDashboardData(),
@@ -59,8 +71,21 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
]);
setDashboardData(billingData);
setSystemHealth(healthData);
// Show success toast only if explicitly requested (user-initiated refresh)
if (showSuccessToast && billingData && healthData) {
showToastNotification(
'Billing data refreshed successfully',
'success',
{ duration: 3000 }
);
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to fetch dashboard data');
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch dashboard data';
setError(errorMessage);
// Always show error toast for failures
showToastNotification(errorMessage, 'error', { duration: 5000 });
} finally {
setLoading(false);
}
@@ -95,6 +120,29 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
return () => document.removeEventListener('visibilitychange', onVisible);
}, []);
// Listen for billing refresh requests (e.g., when subscription limits are exceeded)
useEffect(() => {
const handleBillingRefresh = () => {
console.log('EnhancedBillingDashboard: Billing refresh requested, refreshing data...');
// Use a fresh call to fetchDashboardData to ensure we get latest data
Promise.all([billingService.getDashboardData(), monitoringService.getSystemHealth()])
.then(([billingData, healthData]) => {
setDashboardData(billingData);
setSystemHealth(healthData);
})
.catch((error) => {
const errorMessage = error instanceof Error ? error.message : 'Failed to refresh billing data';
setError(errorMessage);
console.error('Error refreshing billing data:', error);
});
};
window.addEventListener('billing-refresh-requested', handleBillingRefresh);
return () => {
window.removeEventListener('billing-refresh-requested', handleBillingRefresh);
};
}, []); // Empty deps - handler doesn't depend on component state
const handleViewModeChange = (
event: React.MouseEvent<HTMLElement>,
newViewMode: ViewMode | null,
@@ -117,9 +165,9 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
if (error) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Alert severity="error" sx={{ mb: 3 }}>
<AlertComponent severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
</AlertComponent>
</Container>
);
}
@@ -127,9 +175,9 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
if (!dashboardData) {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Alert severity="warning">
<AlertComponent severity="warning">
No billing data available. Please check your subscription status.
</Alert>
</AlertComponent>
</Container>
);
}
@@ -146,17 +194,17 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography
<TypographyComponent
variant="h4"
sx={{
fontWeight: 800,
mb: 1.5,
fontSize: '1.1rem',
color: 'rgba(255,255,255,0.95)',
color: terminalTheme ? terminalColors.text : 'rgba(255,255,255,0.95)',
}}
>
Billing & Usage Dashboard
</Typography>
</TypographyComponent>
<Tooltip
title={
<Box>
@@ -231,7 +279,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
<Tooltip title="Refresh billing data">
<IconButton
size="small"
onClick={fetchDashboardData}
onClick={() => fetchDashboardData(true)}
disabled={loading}
sx={{
color: 'rgba(255,255,255,0.7)',
@@ -305,7 +353,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
>
<CompactBillingDashboard userId={userId} />
<CompactBillingDashboard userId={userId} terminalTheme={terminalTheme} />
</motion.div>
) : (
<motion.div
@@ -321,6 +369,7 @@ const EnhancedBillingDashboard: React.FC<EnhancedBillingDashboardProps> = ({ use
<BillingOverview
usageStats={dashboardData.current_usage}
onRefresh={fetchDashboardData}
terminalTheme={terminalTheme}
/>
</Grid>

View File

@@ -0,0 +1,467 @@
/**
* Subscription Renewal History Component
* Displays historical subscription renewals with details about plan changes, renewals, and usage.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Tooltip,
CircularProgress,
Chip,
Typography,
} from '@mui/material';
import {
Refresh as RefreshIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
Add as AddIcon,
} from '@mui/icons-material';
import { RefreshCw } from 'lucide-react'; // Use lucide-react for header icon
import { billingService } from '../../services/billingService';
import { SubscriptionRenewal, RenewalHistoryResponse } from '../../types/billing';
import {
TerminalPaper,
TerminalTypography,
TerminalTableCell,
TerminalTableRow,
TerminalAlert,
TerminalChip,
TerminalChipSuccess,
TerminalChipWarning,
TerminalChipError,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
import { showToastNotification } from '../../utils/toastNotifications';
interface SubscriptionRenewalHistoryProps {
userId?: string;
terminalTheme?: boolean;
initialLimit?: number;
}
const SubscriptionRenewalHistory: React.FC<SubscriptionRenewalHistoryProps> = ({
userId,
terminalTheme = false,
initialLimit = 20
}) => {
const [renewals, setRenewals] = useState<SubscriptionRenewal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
const [totalCount, setTotalCount] = useState(0);
const fetchRenewals = async () => {
try {
setLoading(true);
setError(null);
const response: RenewalHistoryResponse = await billingService.getRenewalHistory(
userId,
rowsPerPage,
page * rowsPerPage
);
setRenewals(response.renewals || []);
setTotalCount(response.total_count || 0);
// Don't show success toast on automatic refresh - only show errors
} catch (err: any) {
const errorMessage = err.message || 'Failed to fetch renewal history';
setError(errorMessage);
console.error('Error fetching renewal history:', err);
// Show error toast
showToastNotification(errorMessage, 'error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchRenewals();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, rowsPerPage, userId]);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return 'Invalid Date';
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const getRenewalTypeIcon = (type: string): React.ReactElement | undefined => {
switch (type) {
case 'upgrade':
return <TrendingUpIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.success : '#4ade80' }} />;
case 'downgrade':
return <TrendingDownIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.warning : '#f59e0b' }} />;
case 'renewal':
return <RefreshIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.primary : '#3b82f6' }} />;
case 'new':
return <AddIcon fontSize="small" sx={{ color: terminalTheme ? terminalColors.primary : '#3b82f6' }} />;
default:
return undefined;
}
};
const getRenewalTypeChip = (type: string) => {
const ChipComponent = terminalTheme ? TerminalChip : Chip;
const chipStyles = {
upgrade: { backgroundColor: 'rgba(34, 197, 94, 0.2)', color: '#22c55e' },
downgrade: { backgroundColor: 'rgba(245, 158, 11, 0.2)', color: '#f59e0b' },
renewal: { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#3b82f6' },
new: { backgroundColor: 'rgba(59, 130, 246, 0.2)', color: '#3b82f6' },
};
const chipStyle = chipStyles[type as keyof typeof chipStyles] || chipStyles.renewal;
const label = type.charAt(0).toUpperCase() + type.slice(1);
const icon = getRenewalTypeIcon(type);
if (terminalTheme) {
const TerminalChipComponent = type === 'upgrade' ? TerminalChipSuccess :
type === 'downgrade' ? TerminalChipWarning :
TerminalChip;
return (
<TerminalChipComponent
label={label}
size="small"
icon={icon}
sx={{ fontWeight: 500 }}
/>
);
}
return (
<Chip
label={label}
size="small"
icon={icon}
sx={{
...chipStyle,
fontWeight: 500,
border: `1px solid ${chipStyle.color}40`
}}
/>
);
};
// Conditional component selection based on terminal theme
const PaperComponent = terminalTheme ? TerminalPaper : Box;
// Alert component wrapper for terminal theme
const AlertWrapper: React.FC<{ children: React.ReactNode; severity?: 'error' | 'info' | 'warning'; sx?: any }> = ({ children, severity = 'info', sx }) => {
if (terminalTheme) {
return (
<TerminalAlert severity={severity} sx={sx}>
{children}
</TerminalAlert>
);
}
return (
<Box sx={{
p: 2,
backgroundColor: severity === 'error' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(59, 130, 246, 0.1)',
borderRadius: 2,
color: severity === 'error' ? '#ef4444' : '#3b82f6',
...sx
}}>
{children}
</Box>
);
};
return (
<PaperComponent sx={{ p: 3, mt: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{terminalTheme ? (
<RefreshCw size={20} color={terminalColors.primary} />
) : (
<RefreshCw size={20} />
)}
{terminalTheme ? (
<TerminalTypography variant="h6" sx={{ fontWeight: 'bold' }}>
Subscription Renewal History
</TerminalTypography>
) : (
<Typography variant="h6" sx={{ fontWeight: 'bold' }}>
Subscription Renewal History
</Typography>
)}
</Box>
<Tooltip title="Refresh Renewal History">
<IconButton
onClick={fetchRenewals}
disabled={loading}
sx={{ color: terminalTheme ? terminalColors.primary : 'inherit' }}
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
{loading && renewals.length === 0 ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 200 }}>
<CircularProgress sx={{ color: terminalTheme ? terminalColors.primary : 'inherit' }} />
</Box>
) : error ? (
<AlertWrapper severity="error">
{terminalTheme ? (
<TerminalTypography>{error}</TerminalTypography>
) : (
<Typography>{error}</Typography>
)}
</AlertWrapper>
) : renewals.length === 0 ? (
<AlertWrapper severity="info">
{terminalTheme ? (
<TerminalTypography>No renewal history found. Your subscription renewals will appear here.</TerminalTypography>
) : (
<Typography>No renewal history found. Your subscription renewals will appear here.</Typography>
)}
</AlertWrapper>
) : (
<>
<TableContainer component={Box} sx={{ maxHeight: 600, overflow: 'auto' }}>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TerminalTableCell sx={{ width: '8%' }}>#</TerminalTableCell>
<TerminalTableCell sx={{ width: '12%' }}>Type</TerminalTableCell>
<TerminalTableCell sx={{ width: '15%' }}>Plan</TerminalTableCell>
<TerminalTableCell sx={{ width: '12%' }}>Previous Plan</TerminalTableCell>
<TerminalTableCell sx={{ width: '10%' }}>Billing Cycle</TerminalTableCell>
<TerminalTableCell sx={{ width: '12%' }}>Period Start</TerminalTableCell>
<TerminalTableCell sx={{ width: '12%' }}>Period End</TerminalTableCell>
<TerminalTableCell sx={{ width: '10%' }}>Amount</TerminalTableCell>
<TerminalTableCell sx={{ width: '9%' }}>Status</TerminalTableCell>
</TableRow>
</TableHead>
<TableBody>
{renewals.map((renewal) => (
<TerminalTableRow key={renewal.id}>
<TerminalTableCell>
{terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
#{renewal.renewal_count}
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
#{renewal.renewal_count}
</Typography>
)}
</TerminalTableCell>
<TerminalTableCell>
{getRenewalTypeChip(renewal.renewal_type)}
</TerminalTableCell>
<TerminalTableCell>
{terminalTheme ? (
<>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
{renewal.plan_name}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
{renewal.plan_tier}
</TerminalTypography>
</>
) : (
<>
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
{renewal.plan_name}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
{renewal.plan_tier}
</Typography>
</>
)}
</TerminalTableCell>
<TerminalTableCell>
{renewal.previous_plan_name ? (
terminalTheme ? (
<>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{renewal.previous_plan_name}
</TerminalTypography>
<TerminalTypography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
{renewal.previous_plan_tier}
</TerminalTypography>
</>
) : (
<>
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{renewal.previous_plan_name}
</Typography>
<Typography variant="caption" sx={{ fontSize: '0.7rem', display: 'block', opacity: 0.7 }}>
{renewal.previous_plan_tier}
</Typography>
</>
)
) : (
terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontStyle: 'italic', opacity: 0.5 }}>
N/A
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontStyle: 'italic', opacity: 0.5 }}>
N/A
</Typography>
)
)}
</TerminalTableCell>
<TerminalTableCell>
{terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', textTransform: 'capitalize' }}>
{renewal.billing_cycle}
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem', textTransform: 'capitalize' }}>
{renewal.billing_cycle}
</Typography>
)}
</TerminalTableCell>
<TerminalTableCell>
{terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(renewal.new_period_start)}
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(renewal.new_period_start)}
</Typography>
)}
</TerminalTableCell>
<TerminalTableCell>
{terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(renewal.new_period_end)}
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(renewal.new_period_end)}
</Typography>
)}
</TerminalTableCell>
<TerminalTableCell>
{terminalTheme ? (
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
{formatCurrency(renewal.payment_amount)}
</TerminalTypography>
) : (
<Typography variant="body2" sx={{ fontSize: '0.75rem', fontWeight: 600 }}>
{formatCurrency(renewal.payment_amount)}
</Typography>
)}
</TerminalTableCell>
<TerminalTableCell>
{renewal.payment_status === 'paid' ? (
terminalTheme ? (
<TerminalChipSuccess label="Paid" size="small" />
) : (
<Chip
label="Paid"
size="small"
sx={{
backgroundColor: 'rgba(34, 197, 94, 0.2)',
color: '#22c55e',
fontWeight: 500
}}
/>
)
) : renewal.payment_status === 'pending' ? (
terminalTheme ? (
<TerminalChipWarning label="Pending" size="small" />
) : (
<Chip
label="Pending"
size="small"
sx={{
backgroundColor: 'rgba(245, 158, 11, 0.2)',
color: '#f59e0b',
fontWeight: 500
}}
/>
)
) : (
terminalTheme ? (
<TerminalChipError label="Failed" size="small" />
) : (
<Chip
label="Failed"
size="small"
sx={{
backgroundColor: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
fontWeight: 500
}}
/>
)
)}
</TerminalTableCell>
</TerminalTableRow>
))}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 20, 50, 100]}
sx={{
color: terminalTheme ? terminalColors.text : 'inherit',
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
color: terminalTheme ? terminalColors.text : 'inherit',
fontFamily: terminalTheme ? 'monospace' : 'inherit'
},
'& .MuiIconButton-root': {
color: terminalTheme ? terminalColors.primary : 'inherit'
}
}}
/>
</>
)}
</PaperComponent>
);
};
export default SubscriptionRenewalHistory;

View File

@@ -0,0 +1,426 @@
/**
* Usage Logs Table Component
* Displays API usage logs in a table with pagination and filtering.
* Terminal-themed UI matching scheduler dashboard style.
*/
import React, { useState, useEffect } from 'react';
import {
Box,
Table,
TableBody,
TableContainer,
TableHead,
TableRow,
TablePagination,
IconButton,
Tooltip,
Select,
MenuItem,
FormControl,
InputLabel,
CircularProgress
} from '@mui/material';
import {
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Refresh as RefreshIcon,
Receipt as ReceiptIcon
} from '@mui/icons-material';
import { billingService } from '../../services/billingService';
import { UsageLog, UsageLogsResponse } from '../../types/billing';
import {
TerminalPaper,
TerminalTypography,
TerminalChipSuccess,
TerminalChipError,
TerminalTableCell,
TerminalTableRow,
TerminalAlert,
terminalColors
} from '../SchedulerDashboard/terminalTheme';
import { formatCurrency } from '../../services/billingService';
interface UsageLogsTableProps {
initialLimit?: number;
}
const UsageLogsTable: React.FC<UsageLogsTableProps> = ({ initialLimit = 50 }) => {
const [logs, setLogs] = useState<UsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(initialLimit);
const [totalCount, setTotalCount] = useState(0);
const [statusFilter, setStatusFilter] = useState<'success' | 'failed' | 'all'>('all');
const [providerFilter, setProviderFilter] = useState<string>('all');
const fetchLogs = async () => {
try {
setLoading(true);
setError(null);
const statusCode = statusFilter === 'all' ? undefined : (statusFilter === 'success' ? 200 : 400);
const provider = providerFilter === 'all' ? undefined : providerFilter;
const response: UsageLogsResponse = await billingService.getUsageLogs(
rowsPerPage,
page * rowsPerPage,
provider,
statusCode
);
setLogs(response.logs || []);
setTotalCount(response.total_count || 0);
} catch (err: any) {
setError(err.message || 'Failed to fetch usage logs');
console.error('Error fetching usage logs:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, rowsPerPage, statusFilter, providerFilter]);
const handleChangePage = (_event: unknown, newPage: number) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10));
setPage(0);
};
const getStatusIcon = (status: string): React.ReactElement | undefined => {
switch (status) {
case 'success':
return <CheckCircleIcon fontSize="small" sx={{ color: terminalColors.success }} />;
case 'failed':
return <ErrorIcon fontSize="small" sx={{ color: terminalColors.error }} />;
default:
return undefined;
}
};
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
return date.toLocaleString();
} catch {
return dateString;
}
};
const formatResponseTime = (seconds: number) => {
if (!seconds) return 'N/A';
const ms = seconds * 1000;
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${seconds.toFixed(2)}s`;
};
const formatTokens = (tokens: number) => {
return new Intl.NumberFormat('en-US').format(tokens);
};
return (
<TerminalPaper sx={{ p: 3 }}>
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
<Box display="flex" alignItems="center" gap={1}>
<ReceiptIcon sx={{ color: terminalColors.primary }} />
<TerminalTypography variant="h6" component="h2" sx={{ fontSize: '1.25rem', fontWeight: 'bold' }}>
API Usage Logs
</TerminalTypography>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<FormControl
size="small"
sx={{
minWidth: 120,
'& .MuiOutlinedInput-root': {
color: terminalColors.primary,
'& fieldset': {
borderColor: terminalColors.primary,
},
'&:hover fieldset': {
borderColor: terminalColors.secondary,
},
},
'& .MuiInputLabel-root': {
color: terminalColors.textSecondary,
},
'& .MuiSelect-icon': {
color: terminalColors.primary,
}
}}
>
<InputLabel>Provider</InputLabel>
<Select
value={providerFilter}
label="Provider"
onChange={(e) => {
setProviderFilter(e.target.value);
setPage(0);
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: terminalColors.backgroundLight,
border: `1px solid ${terminalColors.primary}`,
'& .MuiMenuItem-root': {
color: terminalColors.primary,
fontFamily: 'monospace',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&.Mui-selected': {
backgroundColor: 'rgba(0, 255, 0, 0.15)',
}
}
}
}
}}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="gemini">Gemini</MenuItem>
<MenuItem value="huggingface">HuggingFace</MenuItem>
</Select>
</FormControl>
<FormControl
size="small"
sx={{
minWidth: 120,
'& .MuiOutlinedInput-root': {
color: terminalColors.primary,
'& fieldset': {
borderColor: terminalColors.primary,
},
'&:hover fieldset': {
borderColor: terminalColors.secondary,
},
},
'& .MuiInputLabel-root': {
color: terminalColors.textSecondary,
},
'& .MuiSelect-icon': {
color: terminalColors.primary,
}
}}
>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) => {
setStatusFilter(e.target.value as any);
setPage(0);
}}
MenuProps={{
PaperProps: {
sx: {
backgroundColor: terminalColors.backgroundLight,
border: `1px solid ${terminalColors.primary}`,
'& .MuiMenuItem-root': {
color: terminalColors.primary,
fontFamily: 'monospace',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&.Mui-selected': {
backgroundColor: 'rgba(0, 255, 0, 0.15)',
}
}
}
}
}}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="success">Success</MenuItem>
<MenuItem value="failed">Failed</MenuItem>
</Select>
</FormControl>
<Tooltip title="Refresh logs">
<IconButton
onClick={fetchLogs}
size="small"
sx={{
color: terminalColors.primary,
border: `1px solid ${terminalColors.primary}`,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
}}
>
<RefreshIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
{error && (
<TerminalAlert severity="error" sx={{ mb: 2 }}>
{error}
</TerminalAlert>
)}
{loading ? (
<Box display="flex" justifyContent="center" p={3}>
<CircularProgress sx={{ color: terminalColors.primary }} />
</Box>
) : (
<>
<TableContainer
sx={{
backgroundColor: terminalColors.background,
maxHeight: '600px',
overflow: 'auto'
}}
>
<Table size="small" sx={{ minWidth: 800 }}>
<TableHead>
<TerminalTableRow>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Timestamp</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Provider</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Model</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Tokens</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Cost</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Status</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Response Time</TerminalTableCell>
<TerminalTableCell sx={{ fontWeight: 'bold', color: terminalColors.primary }}>Endpoint</TerminalTableCell>
</TerminalTableRow>
</TableHead>
<TableBody>
{logs.length === 0 ? (
<TerminalTableRow>
<TerminalTableCell colSpan={8} align="center">
<Box sx={{ py: 4, textAlign: 'center' }}>
<ReceiptIcon sx={{ color: terminalColors.textSecondary, fontSize: 48, mb: 2, opacity: 0.5 }} />
<TerminalTypography variant="body2" sx={{ color: terminalColors.primary, mb: 1, fontWeight: 'bold' }}>
No Usage Logs Yet
</TerminalTypography>
<TerminalTypography variant="body2" sx={{ color: terminalColors.textSecondary }}>
API usage logs will appear here once you start making API calls.
</TerminalTypography>
</Box>
</TerminalTableCell>
</TerminalTableRow>
) : (
logs.map((log) => (
<TerminalTableRow
key={log.id}
sx={{
backgroundColor: terminalColors.background,
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
}
}}
>
<TerminalTableCell>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatDate(log.timestamp)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2" sx={{ textTransform: 'capitalize' }}>
{log.provider}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{log.model_used || 'N/A'}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2">
{formatTokens(log.tokens_total)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2">
{formatCurrency(log.cost_total)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
{log.status === 'success' ? (
<TerminalChipSuccess
icon={getStatusIcon(log.status) || undefined}
label={`${log.status_code}`}
size="small"
/>
) : (
<TerminalChipError
icon={getStatusIcon(log.status) || undefined}
label={`${log.status_code}`}
size="small"
/>
)}
</TerminalTableCell>
<TerminalTableCell>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem' }}>
{formatResponseTime(log.response_time)}
</TerminalTypography>
</TerminalTableCell>
<TerminalTableCell>
<Tooltip title={log.endpoint || ''}>
<TerminalTypography variant="body2" sx={{ fontSize: '0.75rem', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{log.is_aggregated ? (
<span style={{ color: terminalColors.warning, fontStyle: 'italic' }}>
[AGGREGATED] {log.error_message || 'Historical data'}
</span>
) : (
`${log.method} ${log.endpoint?.substring(0, 30)}...`
)}
</TerminalTypography>
</Tooltip>
</TerminalTableCell>
</TerminalTableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
sx={{
color: terminalColors.primary,
borderTop: `1px solid ${terminalColors.primary}`,
'& .MuiTablePagination-toolbar': {
color: terminalColors.primary,
},
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
color: terminalColors.primary,
fontFamily: 'monospace',
},
'& .MuiIconButton-root': {
color: terminalColors.primary,
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
},
'&.Mui-disabled': {
color: terminalColors.textSecondary,
}
},
'& .MuiSelect-root': {
color: terminalColors.primary,
borderColor: terminalColors.primary,
}
}}
/>
</>
)}
</TerminalPaper>
);
};
export default UsageLogsTable;

View File

@@ -2,6 +2,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode, useCa
import { apiClient, setGlobalSubscriptionErrorHandler } from '../api/client';
import SubscriptionExpiredModal from '../components/SubscriptionExpiredModal';
import { saveNavigationState, getCurrentPhaseForTool } from '../utils/navigationState';
import { showSubscriptionExpiredToast, showUsageLimitToast, showSubscriptionToast } from '../utils/toastNotifications';
export interface SubscriptionLimits {
gemini_calls: number;
@@ -108,6 +109,32 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
// Update ref immediately so callbacks can access latest value
subscriptionRef.current = subscriptionData;
// Check if subscription is expired/inactive and show modal
// Show modal if subscription is inactive on initial load (when subscription was null before)
// This ensures the modal shows when an end user navigates to the app
const wasSubscriptionNull = subscription === null;
const subscriptionJustBecameInactive = subscription?.active === true && !subscriptionData.active;
if (subscriptionData && !subscriptionData.active) {
// Show modal on initial load (when subscription was null) or if subscription just became inactive
// This ensures the modal shows when an end user navigates to the app with an inactive subscription
if (wasSubscriptionNull || subscriptionJustBecameInactive) {
console.log('SubscriptionContext: Subscription is inactive, showing modal', {
wasSubscriptionNull,
subscriptionJustBecameInactive,
subscriptionActive: subscriptionData.active
});
setIsUsageLimitModal(false);
setModalErrorData({
message: 'To continue using Alwrity and access all features, you need to renew your subscription.'
});
setShowModal(true);
setLastModalShowTime(Date.now());
// Also show toast notification with message similar to modal
showSubscriptionExpiredToast();
}
}
// Detect plan/tier change and start a grace window (5 minutes)
try {
const newSignature = `${subscriptionData?.plan || ''}:${subscriptionData?.tier || ''}`;
@@ -175,13 +202,29 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
// For inactive subscriptions, show modal immediately
console.log('SubscriptionContext: Showing deferred modal for inactive subscription');
const errorData = error.response?.data || {};
// If errorData is an array, extract the first element
let processedErrorData = errorData;
if (Array.isArray(errorData)) {
processedErrorData = errorData[0] || {};
}
// If errorData has a 'detail' field, extract it (FastAPI format)
if (processedErrorData.detail && typeof processedErrorData.detail === 'object') {
processedErrorData = processedErrorData.detail;
}
const modalMessage = processedErrorData.message || processedErrorData.error ||
'To continue using Alwrity and access all features, you need to renew your subscription.';
setModalErrorData({
provider: errorData.provider,
usage_info: errorData.usage_info,
message: errorData.message || errorData.error
provider: processedErrorData.provider,
usage_info: processedErrorData.usage_info,
message: modalMessage
});
setShowModal(true);
setLastModalShowTime(now);
// Also show toast notification
showSubscriptionExpiredToast();
}
}
} catch (err: any) {
@@ -348,6 +391,21 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
setShowModal(true);
setLastModalShowTime(now);
// Show toast notification with usage limit message
const toastMessage = modalData.message ||
'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.';
showUsageLimitToast(toastMessage);
// Emit custom event for billing page and other listeners
window.dispatchEvent(new CustomEvent('subscription-limit-exceeded', {
detail: {
provider: modalData.provider,
usage_info: modalData.usage_info,
message: toastMessage,
error: errorData
}
}));
console.log('SubscriptionContext: Showing usage limit modal', {
provider: modalData.provider,
message: modalData.message?.substring(0, 50)
@@ -374,13 +432,17 @@ export const SubscriptionProvider: React.FC<SubscriptionProviderProps> = ({ chil
if (!subscription.active) {
console.log('SubscriptionContext: Showing subscription expired modal');
setIsUsageLimitModal(false);
const modalMessage = errorData.message || errorData.error ||
'To continue using Alwrity and access all features, you need to renew your subscription.';
setModalErrorData({
provider: errorData.provider,
usage_info: errorData.usage_info,
message: errorData.message || errorData.error
message: modalMessage
});
setShowModal(true);
setLastModalShowTime(now);
// Also show toast notification
showSubscriptionExpiredToast();
return true;
}
}

View File

@@ -2,6 +2,11 @@ import { useState, useEffect, useCallback } from 'react';
import { BlogOutlineSection, BlogResearchResponse, BlogSEOMetadataResponse, BlogSEOAnalyzeResponse, SourceMappingStats, GroundingInsights, OptimizationResults, ResearchCoverage } from '../services/blogWriterApi';
import { researchCache } from '../services/researchCache';
const MINOR_TITLE_WORDS = new Set([
'a', 'an', 'and', 'or', 'but', 'the', 'for', 'nor', 'on', 'at', 'to', 'from', 'by',
'of', 'in', 'with', 'as', 'vs', 'vs.', 'into', 'over', 'under'
]);
export const useBlogWriterState = () => {
// Core state
const [research, setResearch] = useState<BlogResearchResponse | null>(null);
@@ -36,6 +41,57 @@ export const useBlogWriterState = () => {
// Section images state - persists images generated in outline phase to content phase
const [sectionImages, setSectionImages] = useState<Record<string, string>>({});
const formatContentAngleToTitle = useCallback((angle: string): string => {
if (!angle || typeof angle !== 'string') {
return '';
}
const cleaned = angle.replace(/\s+/g, ' ').trim();
if (!cleaned) {
return '';
}
const words = cleaned.split(' ');
const formattedWords = words.map((word, index) => {
const lower = word.toLowerCase();
if (index !== 0 && MINOR_TITLE_WORDS.has(lower)) {
return lower;
}
if (!lower) {
return '';
}
return lower.charAt(0).toUpperCase() + lower.slice(1);
}).filter(Boolean);
let formatted = formattedWords.join(' ');
if (formatted.length > 120) {
formatted = formatted.slice(0, 117).trimEnd() + '...';
}
return formatted;
}, []);
const dedupeTitles = useCallback((titles: string[]): string[] => {
const seen = new Set<string>();
const result: string[] = [];
titles.forEach((title) => {
if (!title) {
return;
}
const normalized = title.replace(/\s+/g, ' ').trim();
if (!normalized) {
return;
}
const key = normalized.toLowerCase();
if (seen.has(key)) {
return;
}
seen.add(key);
result.push(normalized);
});
return result;
}, []);
// Cache recovery - restore most recent research on page load
useEffect(() => {
const cachedEntries = researchCache.getAllCachedEntries();
@@ -71,14 +127,46 @@ export const useBlogWriterState = () => {
// Handle research completion
const handleResearchComplete = useCallback((researchData: BlogResearchResponse) => {
setResearch(researchData);
}, []);
const formattedAngles = dedupeTitles(
(researchData?.suggested_angles || []).map(formatContentAngleToTitle)
);
setResearchTitles(formattedAngles);
// Prefill title from research if no title is currently selected
if (!selectedTitle && formattedAngles.length > 0) {
const firstTitle = formattedAngles[0];
setSelectedTitle(firstTitle);
localStorage.setItem('blog_selected_title', firstTitle);
}
}, [dedupeTitles, formatContentAngleToTitle, selectedTitle]);
// Handle outline completion with enhanced metadata
const handleOutlineComplete = useCallback((result: any) => {
if (result?.outline) {
setOutline(result.outline);
setTitleOptions(result.title_options || []);
const aiTitleOptions: string[] = result.title_options || [];
const formattedAngles = dedupeTitles(
(research?.suggested_angles || []).map(formatContentAngleToTitle)
);
const combinedTitleOptions = dedupeTitles([
...formattedAngles,
...aiTitleOptions
]);
setTitleOptions(combinedTitleOptions);
setResearchTitles(formattedAngles);
const aiTitlesList = dedupeTitles(
aiTitleOptions.filter((title: string) => !formattedAngles.some(angle => angle.toLowerCase() === (title || '').toLowerCase().trim()))
);
setAiGeneratedTitles(aiTitlesList);
const nextSelectedTitle = aiTitlesList[0] || formattedAngles[0] || combinedTitleOptions[0] || '';
if (nextSelectedTitle) {
setSelectedTitle(nextSelectedTitle);
}
// Store enhanced metadata
if (result.source_mapping_stats) {
setSourceMappingStats(result.source_mapping_stats);
@@ -92,35 +180,13 @@ export const useBlogWriterState = () => {
if (result.research_coverage) {
setResearchCoverage(result.research_coverage);
}
// Separate research titles from AI-generated titles
if (result.title_options && research) {
const researchAngles = research.suggested_angles || [];
const researchTitlesList = result.title_options.filter((title: string) =>
researchAngles.some((angle: string) => title.toLowerCase().includes(angle.toLowerCase().substring(0, 20)))
);
const aiTitlesList = result.title_options.filter((title: string) =>
!researchTitlesList.includes(title)
);
setResearchTitles(researchTitlesList);
setAiGeneratedTitles(aiTitlesList);
// Auto-select first AI-generated title if available, otherwise first research title
if (aiTitlesList.length > 0) {
setSelectedTitle(aiTitlesList[0]);
} else if (researchTitlesList.length > 0) {
setSelectedTitle(researchTitlesList[0]);
} else if (result.title_options.length > 0) {
setSelectedTitle(result.title_options[0]);
}
}
// Save to localStorage for persistence (using shared cache utility)
try {
const { blogWriterCache } = require('../services/blogWriterCache');
blogWriterCache.cacheOutline(result.outline, result.title_options);
localStorage.setItem('blog_selected_title', result.title_options?.[0] || '');
blogWriterCache.cacheOutline(result.outline, combinedTitleOptions);
localStorage.setItem('blog_title_options', JSON.stringify(combinedTitleOptions));
localStorage.setItem('blog_selected_title', nextSelectedTitle || '');
console.log('Saved outline data to localStorage');
} catch (error) {
console.error('Error saving outline data:', error);
@@ -129,7 +195,7 @@ export const useBlogWriterState = () => {
setOutlineTaskId(null);
// Reset outline confirmation when new outline is generated
setOutlineConfirmed(false);
}, [research]);
}, [research, dedupeTitles, formatContentAngleToTitle]);
// Handle outline error
const handleOutlineError = useCallback((error: any) => {

View File

@@ -8,6 +8,7 @@
import { useEffect, useRef } from 'react';
import { billingService } from '../services/billingService';
import { UsageAlert } from '../types/billing';
import { showToastNotification } from '../utils/toastNotifications';
interface UseOAuthTokenAlertsOptions {
/**
@@ -135,58 +136,8 @@ export function useOAuthTokenAlerts(options: UseOAuthTokenAlertsOptions = {}) {
};
}
/**
* Show a toast notification using DOM-based approach
* Works globally across the app, regardless of which component is mounted
*/
function showToastNotification(message: string, type: 'error' | 'warning' | 'info' = 'info') {
const toast = document.createElement('div');
// Determine background color based on type
const bgColors = {
error: '#f44336',
warning: '#ff9800',
info: '#2196f3',
success: '#4caf50'
};
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
font-size: 14px;
z-index: 10000;
max-width: 400px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s ease;
background-color: ${bgColors[type] || bgColors.info};
word-wrap: break-word;
`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// Remove after 5 seconds (longer for important alerts)
const duration = type === 'error' ? 7000 : 5000;
setTimeout(() => {
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, duration);
}
// Note: showToastNotification is now imported from utils/toastNotifications.ts
// This ensures consistent toast notifications across the app
/**
* Extract platform name from alert title

View File

@@ -0,0 +1,156 @@
import { useEffect, useRef } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { apiClient } from '../api/client';
import { showToastNotification } from '../utils/toastNotifications';
/**
* Hook to poll for tasks needing intervention and show toast notifications
*/
export function useSchedulerTaskAlerts(options: {
enabled?: boolean;
interval?: number;
} = {}) {
const { enabled = true, interval = 60000 } = options;
const { userId, getToken } = useAuth();
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const lastAlertTimeRef = useRef<number>(0);
const isPollingRef = useRef(false);
const shownTaskIdsRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!enabled || !userId) {
return;
}
const pollAlerts = async () => {
if (isPollingRef.current) {
return;
}
try {
isPollingRef.current = true;
// Fetch tasks needing intervention
const response = await apiClient.get<{
success: boolean;
tasks: Array<{
task_id: number;
task_type: string;
user_id: string;
platform?: string;
website_url?: string;
failure_pattern: {
consecutive_failures: number;
recent_failures: number;
failure_reason: string;
last_failure_time: string | null;
error_patterns: string[];
};
failure_reason: string | null;
last_failure: string | null;
}>;
count: number;
}>(`/api/scheduler/tasks-needing-intervention/${userId}`);
if (!response.data.success) {
return;
}
const tasks = response.data.tasks || [];
// Show toast only for critical failures (API limits) - other failures are shown in dedicated section
for (const task of tasks) {
// Only show alert once per task
if (shownTaskIdsRef.current.has(task.task_id)) {
continue;
}
// Only show toast for critical failures (API limits) and if failure is very recent (within last 10 minutes)
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
if (task.last_failure && failureReason === 'api_limit') {
const failureTime = new Date(task.last_failure).getTime();
const tenMinutesAgo = Date.now() - 10 * 60 * 1000;
if (failureTime > tenMinutesAgo) {
showTaskAlert(task);
shownTaskIdsRef.current.add(task.task_id);
lastAlertTimeRef.current = Math.max(
lastAlertTimeRef.current,
failureTime
);
}
}
}
} catch (error) {
console.error('Error polling scheduler task alerts:', error);
// Don't show error to user - this is background polling
} finally {
isPollingRef.current = false;
}
};
// Poll immediately
pollAlerts();
// Set up periodic polling
intervalRef.current = setInterval(pollAlerts, interval);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [enabled, userId, interval]);
return {
isPolling: isPollingRef.current
};
}
function showTaskAlert(task: {
task_id: number;
task_type: string;
platform?: string;
website_url?: string;
failure_pattern: {
consecutive_failures: number;
failure_reason: string;
};
}) {
const failureReason = task.failure_pattern?.failure_reason || 'unknown';
const consecutiveFailures = task.failure_pattern?.consecutive_failures || 0;
let message = '';
let type: 'error' | 'warning' = 'error';
if (failureReason === 'api_limit') {
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times due to API limits. Manual intervention required.`;
type = 'error';
} else if (failureReason === 'auth_error') {
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times due to authentication issues. Please check your credentials.`;
type = 'warning';
} else {
message = `Task "${getTaskDisplayName(task)}" has failed ${consecutiveFailures} times and needs manual intervention.`;
type = 'error';
}
// Use existing toast function
showToastNotification(message, type);
}
function getTaskDisplayName(task: {
task_type: string;
platform?: string;
website_url?: string;
}): string {
if (task.task_type === 'oauth_token_monitoring') {
return `OAuth ${task.platform?.toUpperCase() || 'Unknown'}`;
} else if (task.task_type === 'website_analysis') {
const url = task.website_url || 'Unknown';
return `Website Analysis (${url.length > 30 ? url.substring(0, 30) + '...' : url})`;
} else if (task.task_type.includes('_insights')) {
return `${task.platform?.toUpperCase() || 'Unknown'} Insights`;
}
return task.task_type;
}

View File

@@ -0,0 +1,184 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
export interface StoryPhase {
id: 'setup' | 'outline' | 'writing' | 'export';
name: string;
icon: string;
description: string;
completed: boolean;
current: boolean;
disabled: boolean;
}
interface UseStoryWriterPhaseNavigationParams {
hasPremise: boolean;
hasOutline: boolean;
hasStoryContent: boolean;
isComplete: boolean;
}
export const useStoryWriterPhaseNavigation = ({
hasPremise,
hasOutline,
hasStoryContent,
isComplete,
}: UseStoryWriterPhaseNavigationParams) => {
// Initialize from localStorage if available
const getInitialPhase = (): string => {
try {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem('storywriter_current_phase');
if (stored) return stored;
}
} catch {}
return 'setup';
};
const [currentPhase, setCurrentPhase] = useState<string>(getInitialPhase());
const [userSelectedPhase, setUserSelectedPhase] = useState<boolean>(() => {
try {
if (typeof window !== 'undefined') {
const stored = window.localStorage.getItem('storywriter_user_selected_phase');
return stored === 'true';
}
} catch {}
return false;
});
const lastClickAtRef = useRef<number>(0);
// Determine phase states based on current data
const phases = useMemo((): StoryPhase[] => {
const setupCompleted = hasPremise; // Setup is complete when premise exists
const outlineCompleted = hasOutline;
const writingCompleted = hasStoryContent && isComplete;
const exportCompleted = isComplete;
return [
{
id: 'setup',
name: 'Setup',
icon: '⚙️',
description: 'Configure your story parameters and premise',
completed: setupCompleted,
current: currentPhase === 'setup',
disabled: false, // Always accessible
},
{
id: 'outline',
name: 'Outline',
icon: '📝',
description: 'Generate and refine story outline',
completed: outlineCompleted,
current: currentPhase === 'outline',
disabled: !hasPremise, // Need premise first
},
{
id: 'writing',
name: 'Writing',
icon: '✍️',
description: 'Generate and edit your story',
completed: writingCompleted,
current: currentPhase === 'writing',
disabled: !hasOutline, // Need outline first
},
{
id: 'export',
name: 'Export',
icon: '📤',
description: 'Export your completed story',
completed: exportCompleted,
current: currentPhase === 'export',
disabled: !hasStoryContent, // Need story content first
},
];
}, [hasPremise, hasOutline, hasStoryContent, isComplete, currentPhase]);
// Persist current phase and user selection
useEffect(() => {
try {
if (typeof window !== 'undefined') {
window.localStorage.setItem('storywriter_current_phase', currentPhase);
window.localStorage.setItem('storywriter_user_selected_phase', String(userSelectedPhase));
}
} catch {}
}, [currentPhase, userSelectedPhase]);
// Validate stored phase against current availability (quiet)
// Also migrate old 'premise' phase to 'outline' if needed
useEffect(() => {
// Migrate old 'premise' phase to 'outline' if stored
if (currentPhase === 'premise') {
if (hasPremise) {
setCurrentPhase('outline');
} else {
setCurrentPhase('setup');
}
return;
}
const current = phases.find((p) => p.id === currentPhase);
if (!current) {
setCurrentPhase('setup');
return;
}
if (current.disabled) {
// Find the first non-disabled phase in order of progression
const fallback = phases.find((p) => !p.disabled) || ({ id: 'setup' } as StoryPhase);
if (fallback.id !== currentPhase) {
setCurrentPhase(fallback.id);
}
}
}, [phases, currentPhase, hasPremise]);
// Auto-update current phase based on completion status (only if user hasn't manually selected)
useEffect(() => {
if (userSelectedPhase) {
return; // Don't auto-update if user has manually selected a phase
}
// Auto-progress to the next available phase when conditions are met
if (!hasPremise && currentPhase !== 'setup') {
setCurrentPhase('setup');
} else if (hasPremise && !hasOutline && currentPhase !== 'outline') {
setCurrentPhase('outline');
} else if (hasOutline && !hasStoryContent && currentPhase !== 'writing') {
setCurrentPhase('writing');
} else if (hasStoryContent && !isComplete && currentPhase !== 'export') {
setCurrentPhase('export');
}
}, [hasPremise, hasOutline, hasStoryContent, isComplete, currentPhase, userSelectedPhase]);
const navigateToPhase = useCallback(
(phaseId: string) => {
// Minimal debounce (200ms) to avoid race conditions on rapid clicks
const now = Date.now();
if (now - lastClickAtRef.current < 200) {
return;
}
lastClickAtRef.current = now;
const phase = phases.find((p) => p.id === phaseId);
if (phase && !phase.disabled) {
setCurrentPhase(phaseId);
setUserSelectedPhase(true); // Mark that user has manually selected a phase
}
},
[phases]
);
// Reset user selection when a new phase is completed (to allow auto-progression)
const resetUserSelection = useCallback(() => {
setUserSelectedPhase(false);
}, []);
return {
phases,
currentPhase,
navigateToPhase,
setCurrentPhase,
resetUserSelection,
};
};
export default useStoryWriterPhaseNavigation;

View File

@@ -0,0 +1,455 @@
import { useState, useCallback, useEffect } from 'react';
import {
StoryGenerationRequest,
StoryPremiseResponse,
StoryOutlineResponse,
StoryContentResponse,
StoryFullGenerationResponse,
} from '../services/storyWriterApi';
export interface StoryWriterState {
// Story parameters (Setup phase)
persona: string;
storySetting: string;
characters: string;
plotElements: string;
writingStyle: string;
storyTone: string;
narrativePOV: string;
audienceAgeGroup: string;
contentRating: string;
endingPreference: string;
storyLength: string;
enableExplainer: boolean;
enableIllustration: boolean;
enableVideoNarration: boolean;
// Image generation settings
imageProvider: string | null;
imageWidth: number;
imageHeight: number;
imageModel: string | null;
// Video generation settings
videoFps: number;
videoTransitionDuration: number;
// Audio generation settings
audioProvider: string;
audioLang: string;
audioSlow: boolean;
audioRate: number;
// Generated content
premise: string | null;
outline: string | null;
outlineScenes: any[] | null; // Structured scenes from outline
isOutlineStructured: boolean;
storyContent: string | null;
isComplete: boolean;
sceneImages: Map<number, string> | null; // Generated image URLs by scene number
sceneAudio: Map<number, string> | null; // Generated audio URLs by scene number
storyVideo: string | null; // Generated video URL
// Task management
currentTaskId: string | null;
generationProgress: number;
generationMessage: string | null;
// UI state
isLoading: boolean;
error: string | null;
}
const DEFAULT_STATE: Partial<StoryWriterState> = {
persona: '',
storySetting: '',
characters: '',
plotElements: '',
writingStyle: 'Formal',
storyTone: 'Suspenseful',
narrativePOV: 'Third Person Limited',
audienceAgeGroup: 'Adults (18+)',
contentRating: 'PG-13',
endingPreference: 'Happy',
storyLength: 'Medium',
enableExplainer: true,
enableIllustration: true,
enableVideoNarration: true,
// Image generation settings
imageProvider: null,
imageWidth: 1024,
imageHeight: 1024,
imageModel: null,
// Video generation settings
videoFps: 24,
videoTransitionDuration: 0.5,
// Audio generation settings
audioProvider: 'gtts',
audioLang: 'en',
audioSlow: false,
audioRate: 150,
premise: null,
outline: null,
outlineScenes: null,
isOutlineStructured: false,
storyContent: null,
isComplete: false,
sceneImages: null,
sceneAudio: null,
storyVideo: null,
currentTaskId: null,
generationProgress: 0,
generationMessage: null,
isLoading: false,
error: null,
};
// Mapping for old values to new values (for migration)
const AUDIENCE_AGE_GROUP_MIGRATION: Record<string, string> = {
'Adults': 'Adults (18+)',
'Children': 'Children (5-12)',
'Young Adults': 'Young Adults (13-17)',
};
// Valid audience age groups
const VALID_AUDIENCE_AGE_GROUPS = ['Children (5-12)', 'Young Adults (13-17)', 'Adults (18+)', 'All Ages'];
export const useStoryWriterState = () => {
const [state, setState] = useState<StoryWriterState>(() => {
// Initialize from localStorage if available
try {
const saved = localStorage.getItem('story_writer_state');
if (saved) {
const parsed = JSON.parse(saved);
// Migrate old audienceAgeGroup values to new format
if (parsed.audienceAgeGroup && AUDIENCE_AGE_GROUP_MIGRATION[parsed.audienceAgeGroup]) {
parsed.audienceAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[parsed.audienceAgeGroup];
}
// Validate audienceAgeGroup is in valid list, if not, use default
if (parsed.audienceAgeGroup && !VALID_AUDIENCE_AGE_GROUPS.includes(parsed.audienceAgeGroup)) {
console.warn(`Invalid audienceAgeGroup value: ${parsed.audienceAgeGroup}, using default`);
parsed.audienceAgeGroup = DEFAULT_STATE.audienceAgeGroup;
}
// Convert arrays back to Maps
const restoredState = {
...DEFAULT_STATE,
...parsed,
sceneImages: parsed.sceneImages ? new Map(parsed.sceneImages) : null,
sceneAudio: parsed.sceneAudio ? new Map(parsed.sceneAudio) : null,
};
return restoredState as StoryWriterState;
}
} catch (error) {
console.error('Error loading story writer state from localStorage:', error);
}
return DEFAULT_STATE as StoryWriterState;
});
// Fix invalid audienceAgeGroup values whenever state changes
useEffect(() => {
if (state.audienceAgeGroup && !VALID_AUDIENCE_AGE_GROUPS.includes(state.audienceAgeGroup)) {
// Migrate old values to new format
const migratedValue = AUDIENCE_AGE_GROUP_MIGRATION[state.audienceAgeGroup] || (DEFAULT_STATE.audienceAgeGroup as string);
if (migratedValue !== state.audienceAgeGroup) {
console.log(`Migrating audienceAgeGroup from '${state.audienceAgeGroup}' to '${migratedValue}'`);
setState((prev) => ({ ...prev, audienceAgeGroup: migratedValue }));
}
}
}, [state.audienceAgeGroup]);
// Persist state to localStorage
useEffect(() => {
try {
// Don't persist loading/error states
const { isLoading, error, ...persistableState } = state;
// Ensure audienceAgeGroup is valid before persisting
let validAudienceAgeGroup = persistableState.audienceAgeGroup;
if (!VALID_AUDIENCE_AGE_GROUPS.includes(validAudienceAgeGroup)) {
validAudienceAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[validAudienceAgeGroup] || (DEFAULT_STATE.audienceAgeGroup as string);
// Update state if corrected
if (validAudienceAgeGroup !== persistableState.audienceAgeGroup) {
setState((prev) => ({ ...prev, audienceAgeGroup: validAudienceAgeGroup }));
}
}
// Convert Maps to arrays for JSON serialization
const serializableState = {
...persistableState,
audienceAgeGroup: validAudienceAgeGroup,
sceneImages: persistableState.sceneImages ? Array.from(persistableState.sceneImages.entries()) : null,
sceneAudio: persistableState.sceneAudio ? Array.from(persistableState.sceneAudio.entries()) : null,
};
localStorage.setItem('story_writer_state', JSON.stringify(serializableState));
} catch (error) {
console.error('Error saving story writer state to localStorage:', error);
}
}, [state]);
// Setters
const setPersona = useCallback((persona: string) => {
setState((prev) => ({ ...prev, persona }));
}, []);
const setStorySetting = useCallback((setting: string) => {
setState((prev) => ({ ...prev, storySetting: setting }));
}, []);
const setCharacters = useCallback((characters: string) => {
setState((prev) => ({ ...prev, characters }));
}, []);
const setPlotElements = useCallback((plotElements: string) => {
setState((prev) => ({ ...prev, plotElements }));
}, []);
const setWritingStyle = useCallback((style: string) => {
setState((prev) => ({ ...prev, writingStyle: style }));
}, []);
const setStoryTone = useCallback((tone: string) => {
setState((prev) => ({ ...prev, storyTone: tone }));
}, []);
const setNarrativePOV = useCallback((pov: string) => {
setState((prev) => ({ ...prev, narrativePOV: pov }));
}, []);
const setAudienceAgeGroup = useCallback((ageGroup: string) => {
// Migrate old values to new format
const migratedAgeGroup = AUDIENCE_AGE_GROUP_MIGRATION[ageGroup] || ageGroup;
// Validate the value is in the valid list
if (VALID_AUDIENCE_AGE_GROUPS.includes(migratedAgeGroup)) {
setState((prev) => ({ ...prev, audienceAgeGroup: migratedAgeGroup }));
} else {
console.warn(`Invalid audienceAgeGroup value: ${ageGroup}, using default`);
setState((prev) => ({ ...prev, audienceAgeGroup: DEFAULT_STATE.audienceAgeGroup as string }));
}
}, []);
const setContentRating = useCallback((rating: string) => {
setState((prev) => ({ ...prev, contentRating: rating }));
}, []);
const setEndingPreference = useCallback((ending: string) => {
setState((prev) => ({ ...prev, endingPreference: ending }));
}, []);
const setStoryLength = useCallback((length: string) => {
setState((prev) => ({ ...prev, storyLength: length }));
}, []);
const setEnableExplainer = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, enableExplainer: enabled }));
}, []);
const setEnableIllustration = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, enableIllustration: enabled }));
}, []);
const setEnableVideoNarration = useCallback((enabled: boolean) => {
setState((prev) => ({ ...prev, enableVideoNarration: enabled }));
}, []);
// Image generation setters
const setImageProvider = useCallback((provider: string | null) => {
setState((prev) => ({ ...prev, imageProvider: provider }));
}, []);
const setImageWidth = useCallback((width: number) => {
setState((prev) => ({ ...prev, imageWidth: width }));
}, []);
const setImageHeight = useCallback((height: number) => {
setState((prev) => ({ ...prev, imageHeight: height }));
}, []);
const setImageModel = useCallback((model: string | null) => {
setState((prev) => ({ ...prev, imageModel: model }));
}, []);
// Video generation setters
const setVideoFps = useCallback((fps: number) => {
setState((prev) => ({ ...prev, videoFps: fps }));
}, []);
const setVideoTransitionDuration = useCallback((duration: number) => {
setState((prev) => ({ ...prev, videoTransitionDuration: duration }));
}, []);
// Audio generation setters
const setAudioProvider = useCallback((provider: string) => {
setState((prev) => ({ ...prev, audioProvider: provider }));
}, []);
const setAudioLang = useCallback((lang: string) => {
setState((prev) => ({ ...prev, audioLang: lang }));
}, []);
const setAudioSlow = useCallback((slow: boolean) => {
setState((prev) => ({ ...prev, audioSlow: slow }));
}, []);
const setAudioRate = useCallback((rate: number) => {
setState((prev) => ({ ...prev, audioRate: rate }));
}, []);
const setPremise = useCallback((premise: string | null) => {
setState((prev) => ({ ...prev, premise }));
}, []);
const setOutline = useCallback((outline: string | null) => {
setState((prev) => ({ ...prev, outline }));
}, []);
const setOutlineScenes = useCallback((scenes: any[] | null) => {
setState((prev) => ({ ...prev, outlineScenes: scenes, isOutlineStructured: scenes !== null && scenes.length > 0 }));
}, []);
const setIsOutlineStructured = useCallback((isStructured: boolean) => {
setState((prev) => ({ ...prev, isOutlineStructured: isStructured }));
}, []);
const setStoryContent = useCallback((content: string | null) => {
setState((prev) => ({ ...prev, storyContent: content }));
}, []);
const setSceneImages = useCallback((images: Map<number, string> | null) => {
setState((prev) => ({ ...prev, sceneImages: images }));
}, []);
const setSceneAudio = useCallback((audio: Map<number, string> | null) => {
setState((prev) => ({ ...prev, sceneAudio: audio }));
}, []);
const setStoryVideo = useCallback((video: string | null) => {
setState((prev) => ({ ...prev, storyVideo: video }));
}, []);
const setIsComplete = useCallback((complete: boolean) => {
setState((prev) => ({ ...prev, isComplete: complete }));
}, []);
const setCurrentTaskId = useCallback((taskId: string | null) => {
setState((prev) => ({ ...prev, currentTaskId: taskId }));
}, []);
const setGenerationProgress = useCallback((progress: number) => {
setState((prev) => ({ ...prev, generationProgress: progress }));
}, []);
const setGenerationMessage = useCallback((message: string | null) => {
setState((prev) => ({ ...prev, generationMessage: message }));
}, []);
const setIsLoading = useCallback((loading: boolean) => {
setState((prev) => ({ ...prev, isLoading: loading }));
}, []);
const setError = useCallback((error: string | null) => {
setState((prev) => ({ ...prev, error }));
}, []);
// Helper to get request object
const getRequest = useCallback((): StoryGenerationRequest => {
return {
persona: state.persona,
story_setting: state.storySetting,
character_input: state.characters,
plot_elements: state.plotElements,
writing_style: state.writingStyle,
story_tone: state.storyTone,
narrative_pov: state.narrativePOV,
audience_age_group: state.audienceAgeGroup,
content_rating: state.contentRating,
ending_preference: state.endingPreference,
story_length: state.storyLength,
enable_explainer: state.enableExplainer,
enable_illustration: state.enableIllustration,
enable_video_narration: state.enableVideoNarration,
// Image generation settings
image_provider: state.imageProvider || undefined,
image_width: state.imageWidth,
image_height: state.imageHeight,
image_model: state.imageModel || undefined,
// Video generation settings
video_fps: state.videoFps,
video_transition_duration: state.videoTransitionDuration,
// Audio generation settings
audio_provider: state.audioProvider,
audio_lang: state.audioLang,
audio_slow: state.audioSlow,
audio_rate: state.audioRate,
};
}, [state]);
// Reset state
const resetState = useCallback(() => {
setState(DEFAULT_STATE as StoryWriterState);
// Clear story writer state from localStorage
localStorage.removeItem('story_writer_state');
// Clear phase navigation from localStorage
try {
if (typeof window !== 'undefined') {
localStorage.removeItem('storywriter_current_phase');
localStorage.removeItem('storywriter_user_selected_phase');
}
} catch (error) {
console.error('Error clearing phase navigation from localStorage:', error);
}
}, []);
return {
// State
...state,
// Setters
setPersona,
setStorySetting,
setCharacters,
setPlotElements,
setWritingStyle,
setStoryTone,
setNarrativePOV,
setAudienceAgeGroup,
setContentRating,
setEndingPreference,
setStoryLength,
setEnableExplainer,
setEnableIllustration,
setEnableVideoNarration,
setImageProvider,
setImageWidth,
setImageHeight,
setImageModel,
setVideoFps,
setVideoTransitionDuration,
setAudioProvider,
setAudioLang,
setAudioSlow,
setAudioRate,
setPremise,
setOutline,
setOutlineScenes,
setIsOutlineStructured,
setStoryContent,
setIsComplete,
setSceneImages,
setSceneAudio,
setStoryVideo,
setCurrentTaskId,
setGenerationProgress,
setGenerationMessage,
setIsLoading,
setError,
// Helpers
getRequest,
resetState,
};
};

View File

@@ -0,0 +1,222 @@
/**
* Billing Page
* Dedicated page for billing and usage information with terminal-themed UI.
*/
import React, { useEffect } from 'react';
import {
Box,
Container,
Typography,
IconButton,
Tooltip,
} from '@mui/material';
import {
Refresh as RefreshIcon,
} from '@mui/icons-material';
import { DollarSign } from 'lucide-react';
import { styled } from '@mui/material/styles';
import EnhancedBillingDashboard from '../components/billing/EnhancedBillingDashboard';
import UsageLogsTable from '../components/billing/UsageLogsTable';
import SubscriptionRenewalHistory from '../components/billing/SubscriptionRenewalHistory';
import { showToastNotification } from '../utils/toastNotifications';
import { useSubscription } from '../contexts/SubscriptionContext';
// Terminal-themed styled components
const TerminalContainer = styled(Container)(({ theme }) => ({
backgroundColor: '#0a0a0a',
minHeight: '100vh',
color: '#00ff00',
fontFamily: '"Courier New", "Monaco", "Consolas", "Fira Code", monospace',
padding: theme.spacing(3),
'& *': {
fontFamily: 'inherit',
}
}));
const TerminalHeader = styled(Box)({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 24,
paddingBottom: 16,
borderBottom: '2px solid #00ff00',
});
const TerminalTitle = styled(Typography)<{ component?: React.ElementType }>(({ theme }) => ({
color: '#00ff00',
fontFamily: 'inherit',
fontSize: '1.75rem',
fontWeight: 'bold',
textShadow: '0 0 10px rgba(0, 255, 0, 0.5)',
letterSpacing: '2px',
}));
const TerminalSubtitle = styled(Typography)({
color: '#00ff88',
fontFamily: 'inherit',
fontSize: '0.875rem',
marginTop: 4,
opacity: 0.8,
});
const TerminalIconButton = styled(IconButton)({
color: '#00ff00',
border: '1px solid #00ff00',
'&:hover': {
backgroundColor: 'rgba(0, 255, 0, 0.1)',
boxShadow: '0 0 10px rgba(0, 255, 0, 0.3)',
},
});
const BillingPage: React.FC = () => {
const { subscription, checkSubscription } = useSubscription();
// Monitor subscription status and show toast notifications
useEffect(() => {
if (subscription) {
// Only show toast for subscription renewal (not for every status check)
const wasJustRenewed = sessionStorage.getItem('subscription_renewed');
if (wasJustRenewed && subscription.active) {
showToastNotification(
`🎉 Subscription renewed successfully! Your ${subscription.plan} plan is now active.`,
'success',
{ duration: 6000 }
);
sessionStorage.removeItem('subscription_renewed');
}
// Note: Subscription expiration toast is handled by SubscriptionContext
// We don't show toast here for inactive subscriptions to avoid duplicate toasts
}
}, [subscription]);
// Check subscription on mount
useEffect(() => {
checkSubscription();
}, [checkSubscription]);
// Listen for subscription renewal events
useEffect(() => {
const handleSubscriptionRenewed = () => {
// Mark that subscription was renewed (will show toast in subscription status effect)
sessionStorage.setItem('subscription_renewed', 'true');
// Refresh subscription status to get latest data
checkSubscription();
};
window.addEventListener('subscription-updated', handleSubscriptionRenewed);
return () => {
window.removeEventListener('subscription-updated', handleSubscriptionRenewed);
};
}, [checkSubscription]);
// Listen for subscription limit exceeded events (429 errors from anywhere in the app)
useEffect(() => {
const handleSubscriptionLimitExceeded = (event: Event) => {
const customEvent = event as CustomEvent;
const { provider, usage_info, message, error } = customEvent.detail || {};
console.log('BillingPage: Subscription limit exceeded event received', {
provider,
usage_info,
message
});
// Show toast notification with detailed message
if (message) {
// Format message for better readability
const formattedMessage = message.includes('limit would be exceeded')
? `⚠️ ${message}` // Add warning emoji if not present
: message;
showToastNotification(formattedMessage, 'warning', { duration: 10000 }); // Longer duration for detailed messages
} else {
// Fallback message if no specific message provided
const fallbackMessage = usage_info
? `⚠️ Usage limit exceeded for ${provider || 'API'}. Current: ${(usage_info.current_tokens || usage_info.current_calls || 0).toLocaleString()}, Limit: ${(usage_info.limit || 0).toLocaleString()}`
: '⚠️ You\'ve reached your monthly usage limit. Upgrade your plan to get higher limits.';
showToastNotification(fallbackMessage, 'warning', { duration: 8000 });
}
// Refresh billing data to show updated usage
// Note: EnhancedBillingDashboard will refresh automatically via API events,
// but we trigger a manual refresh here to ensure immediate update
setTimeout(() => {
checkSubscription();
// Trigger a custom event to refresh billing dashboard
window.dispatchEvent(new CustomEvent('billing-refresh-requested'));
}, 500);
};
window.addEventListener('subscription-limit-exceeded', handleSubscriptionLimitExceeded);
return () => {
window.removeEventListener('subscription-limit-exceeded', handleSubscriptionLimitExceeded);
};
}, [checkSubscription]);
// Note: EnhancedBillingDashboard has its own refresh functionality
// This page-level refresh button triggers a full page reload
const handleRefresh = () => {
showToastNotification('Refreshing billing data...', 'info', { duration: 2000 });
checkSubscription();
// Trigger a full page reload after a short delay to ensure fresh data
setTimeout(() => {
window.location.reload();
}, 500);
};
return (
<TerminalContainer maxWidth="xl">
{/* Header */}
<TerminalHeader>
<Box display="flex" flexDirection="column" gap={2} flex={1}>
{/* Title Row */}
<Box display="flex" alignItems="center" justifyContent="space-between">
<Box display="flex" alignItems="center" gap={2}>
<DollarSign size={32} color="#00ff00" />
<Box>
<TerminalTitle component="h1">
BILLING & USAGE DASHBOARD
</TerminalTitle>
<TerminalSubtitle>
Monitor API usage, costs, and system performance
</TerminalSubtitle>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Tooltip
title="Refresh billing data"
arrow
>
<TerminalIconButton
onClick={handleRefresh}
>
<RefreshIcon />
</TerminalIconButton>
</Tooltip>
</Box>
</Box>
</Box>
</TerminalHeader>
{/* Billing Dashboard Content */}
<Box>
<EnhancedBillingDashboard terminalTheme={true} />
{/* Subscription Renewal History Section */}
<SubscriptionRenewalHistory
userId={undefined}
terminalTheme={true}
initialLimit={20}
/>
{/* Usage Logs Section */}
<Box sx={{ mt: 4 }}>
<UsageLogsTable initialLimit={50} />
</Box>
</Box>
</TerminalContainer>
);
};
export default BillingPage;

View File

@@ -36,6 +36,8 @@ import SchedulerEventHistory from '../components/SchedulerDashboard/SchedulerEve
import SchedulerCharts from '../components/SchedulerDashboard/SchedulerCharts';
import TaskMonitoringTabs from '../components/SchedulerDashboard/TaskMonitoringTabs';
import { TerminalTypography, terminalColors } from '../components/SchedulerDashboard/terminalTheme';
import { useSchedulerTaskAlerts } from '../hooks/useSchedulerTaskAlerts';
import TasksNeedingIntervention from '../components/SchedulerDashboard/TasksNeedingIntervention';
// Terminal-themed styled components
const TerminalContainer = styled(Container)(({ theme }) => ({
@@ -188,7 +190,7 @@ const TerminalLoading = styled(Box)({
});
const SchedulerDashboard: React.FC = () => {
const { isSignedIn, isLoaded } = useAuth();
const { isSignedIn, isLoaded, userId } = useAuth();
const [dashboardData, setDashboardData] = useState<SchedulerDashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -197,6 +199,12 @@ const SchedulerDashboard: React.FC = () => {
const [autoRefreshInterval, setAutoRefreshInterval] = useState<NodeJS.Timeout | null>(null);
const [lastUpdateTimestamp, setLastUpdateTimestamp] = useState<string | null>(null);
// Poll for tasks needing intervention and show toast notifications
useSchedulerTaskAlerts({
enabled: isSignedIn && isLoaded,
interval: 60000 // Poll every minute
});
// Use refs to track loading state without causing re-renders
const loadingRef = useRef(false);
const refreshingRef = useRef(false);
@@ -658,6 +666,13 @@ const SchedulerDashboard: React.FC = () => {
</Box>
</Box>
{/* Tasks Needing Intervention - Show prominently but only when needed */}
{userId && (
<Box mb={4}>
<TasksNeedingIntervention userId={userId} />
</Box>
)}
{/* Task Monitoring Tabs */}
<Box mb={4}>
<TaskMonitoringTabs />

View File

@@ -1,5 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { emitApiEvent } from '../utils/apiEvents';
import { getApiUrl } from '../api/client';
import {
DashboardData,
UsageStats,
@@ -7,6 +8,8 @@ import {
SubscriptionPlan,
APIPricing,
UsageAlert,
UsageLog,
UsageLogsResponse,
DashboardAPIResponse,
UsageAPIResponse,
PlansAPIResponse,
@@ -14,47 +17,56 @@ import {
AlertsAPIResponse,
DashboardDataSchema,
UsageStatsSchema,
ProviderBreakdown,
UsagePercentages,
ProviderUsage,
ProviderBreakdownSchema,
SubscriptionRenewal,
RenewalHistoryResponse,
RenewalHistoryAPIResponse,
} from '../types/billing';
// API base configuration - consistent with client.ts pattern
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
const API_BASE_URL = getApiUrl();
const BILLING_BASE_URL = API_BASE_URL
? `${API_BASE_URL.replace(/\/+$/, '')}/api/subscription`
: '/api/subscription';
// Create axios instance with default config
const billingAPI = axios.create({
baseURL: `${API_BASE_URL}/api/subscription`,
baseURL: BILLING_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor for authentication
// Optional token getter - will be set by App.tsx when Clerk is available
let authTokenGetter: (() => Promise<string | null>) | null = null;
// Export function to set auth token getter (called from App.tsx)
export const setBillingAuthTokenGetter = (getter: (() => Promise<string | null>)) => {
authTokenGetter = getter;
};
// Request interceptor for authentication - uses Clerk token getter
billingAPI.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add user ID to ALL requests for billing tracking
const userId = localStorage.getItem('user_id') || 'demo-user';
// Replace {user_id} in URL if present
if (config.url?.includes('{user_id}')) {
config.url = config.url.replace('{user_id}', userId);
}
// Add user_id as query parameter for billing tracking
if (config.params) {
config.params.user_id = userId;
async (config) => {
// Use Clerk token getter if available (same pattern as apiClient)
if (authTokenGetter) {
try {
const token = await authTokenGetter();
if (token) {
config.headers = config.headers || {};
config.headers.Authorization = `Bearer ${token}`;
}
} catch (tokenError) {
console.error('Error getting auth token for billing API:', tokenError);
}
} else {
config.params = { user_id: userId };
console.warn('Billing API: authTokenGetter not set - request may fail authentication');
}
// Also add as header for additional tracking
config.headers['X-User-ID'] = userId;
return config;
},
(error) => {
@@ -62,24 +74,44 @@ billingAPI.interceptors.request.use(
}
);
// Response interceptor for error handling
// Response interceptor for error handling - similar to apiClient pattern
billingAPI.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error) => {
console.error('Billing API Error:', error);
async (error) => {
const originalRequest = error.config;
// Handle specific error cases
if (error.response?.status === 401) {
// Unauthorized - redirect to login
localStorage.removeItem('auth_token');
window.location.href = '/login';
} else if (error.response?.status === 429) {
// Rate limited
console.warn('Rate limited by billing API');
// Handle network errors
if (!error.response) {
console.error('Billing API Network Error:', error.message);
return Promise.reject(error);
}
// Handle 401 errors - try to refresh token if possible
if (error?.response?.status === 401 && !originalRequest._retry && authTokenGetter) {
originalRequest._retry = true;
try {
const newToken = await authTokenGetter();
if (newToken) {
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return billingAPI(originalRequest);
}
} catch (retryError) {
console.error('Billing API: Token refresh failed:', retryError);
}
// If retry failed, don't redirect here - let ProtectedRoute handle it
// The 401 will propagate and ProtectedRoute will check authentication
}
// Handle rate limiting
if (error.response?.status === 429) {
console.warn('Billing API: Rate limited');
}
console.error('Billing API Error:', error.response?.status, error.response?.data);
return Promise.reject(error);
}
);
@@ -92,14 +124,7 @@ const defaultProviderUsage = { calls: 0, tokens: 0, cost: 0 };
const defaultProviderBreakdown = {
gemini: { ...defaultProviderUsage },
openai: { ...defaultProviderUsage },
anthropic: { ...defaultProviderUsage },
mistral: { ...defaultProviderUsage },
tavily: { ...defaultProviderUsage },
serper: { ...defaultProviderUsage },
metaphor: { ...defaultProviderUsage },
firecrawl: { ...defaultProviderUsage },
stability: { ...defaultProviderUsage },
huggingface: { ...defaultProviderUsage },
};
const defaultLimits = {
@@ -169,39 +194,85 @@ function coerceUsageStats(raw: any): UsageStats {
features: raw?.limits?.features ?? [],
};
// Extract provider breakdown - only include gemini and huggingface
// Backend sends mistral data for HuggingFace, so we map it to huggingface
// Explicitly extract and type the provider usage data
const geminiData = providerBreakdown.gemini;
const mistralData = providerBreakdown.mistral; // Backend sends 'mistral' for HuggingFace
const huggingfaceData = providerBreakdown.huggingface;
// Create properly typed ProviderUsage objects
const geminiUsage: ProviderUsage = geminiData && typeof geminiData === 'object' && 'calls' in geminiData
? { calls: Number(geminiData.calls) || 0, tokens: Number(geminiData.tokens) || 0, cost: Number(geminiData.cost) || 0 }
: { calls: 0, tokens: 0, cost: 0 };
// Map mistral data to huggingface (HuggingFace is stored as MISTRAL in DB)
const huggingfaceUsage: ProviderUsage = (huggingfaceData && typeof huggingfaceData === 'object' && 'calls' in huggingfaceData)
? { calls: Number(huggingfaceData.calls) || 0, tokens: Number(huggingfaceData.tokens) || 0, cost: Number(huggingfaceData.cost) || 0 }
: (mistralData && typeof mistralData === 'object' && 'calls' in mistralData)
? { calls: Number(mistralData.calls) || 0, tokens: Number(mistralData.tokens) || 0, cost: Number(mistralData.cost) || 0 }
: { calls: 0, tokens: 0, cost: 0 };
// Create ProviderBreakdown with only gemini and huggingface
const providerBreakdownCoerced: ProviderBreakdown = {
gemini: geminiUsage,
huggingface: huggingfaceUsage,
};
// Extract usage percentages - only include gemini and huggingface
// Backend sends mistral_calls for HuggingFace, map it to huggingface_calls
const usagePercentagesCoerced: UsagePercentages = {
gemini_calls: typeof raw?.usage_percentages?.gemini_calls === 'number' ? raw.usage_percentages.gemini_calls : 0,
huggingface_calls: typeof raw?.usage_percentages?.mistral_calls === 'number'
? raw.usage_percentages.mistral_calls
: (typeof raw?.usage_percentages?.huggingface_calls === 'number' ? raw.usage_percentages.huggingface_calls : 0),
cost: typeof raw?.usage_percentages?.cost === 'number' ? raw.usage_percentages.cost : 0,
};
// Calculate total_cost from provider breakdown
// Always calculate from provider breakdown to ensure accuracy, but prefer backend total if it's more accurate
const backendTotalCost = typeof raw?.total_cost === 'number' ? raw.total_cost : 0;
const calculatedTotalCost = geminiUsage.cost + huggingfaceUsage.cost;
// Use the maximum of backend cost and calculated cost to ensure we show the actual cost
// If backend cost is 0 but we have provider costs, use calculated cost
// If both are 0, the cost is genuinely 0 (no API calls with costs yet)
const totalCost = Math.max(backendTotalCost, calculatedTotalCost);
// Debug logging for cost calculation
if (calculatedTotalCost > 0 || backendTotalCost > 0) {
console.log('💰 [BILLING DEBUG] Cost calculation in coerceUsageStats:', {
backendTotalCost,
calculatedTotalCost,
finalTotalCost: totalCost,
geminiCost: geminiUsage.cost,
huggingfaceCost: huggingfaceUsage.cost,
geminiCalls: geminiUsage.calls,
huggingfaceCalls: huggingfaceUsage.calls,
});
}
// Calculate total_calls and total_tokens from provider breakdown if needed
const backendTotalCalls = typeof raw?.total_calls === 'number' ? raw.total_calls : 0;
const calculatedTotalCalls = geminiUsage.calls + huggingfaceUsage.calls;
const totalCalls = backendTotalCalls > 0 ? backendTotalCalls : calculatedTotalCalls;
const backendTotalTokens = typeof raw?.total_tokens === 'number' ? raw.total_tokens : 0;
const calculatedTotalTokens = geminiUsage.tokens + huggingfaceUsage.tokens;
const totalTokens = backendTotalTokens > 0 ? backendTotalTokens : calculatedTotalTokens;
const coerced: UsageStats = {
billing_period: raw?.billing_period ?? new Date().toISOString().slice(0,7),
usage_status: raw?.usage_status ?? 'active',
total_calls: raw?.total_calls ?? 0,
total_tokens: raw?.total_tokens ?? 0,
total_cost: raw?.total_cost ?? 0,
total_calls: totalCalls,
total_tokens: totalTokens,
total_cost: totalCost,
avg_response_time: raw?.avg_response_time ?? 0,
error_rate: raw?.error_rate ?? 0,
limits: defaultLimits,
provider_breakdown: {
gemini: providerBreakdown.gemini ?? { calls: 0, tokens: 0, cost: 0 },
openai: providerBreakdown.openai ?? { calls: 0, tokens: 0, cost: 0 },
anthropic: providerBreakdown.anthropic ?? { calls: 0, tokens: 0, cost: 0 },
mistral: providerBreakdown.mistral ?? { calls: 0, tokens: 0, cost: 0 },
tavily: providerBreakdown.tavily ?? { calls: 0, tokens: 0, cost: 0 },
serper: providerBreakdown.serper ?? { calls: 0, tokens: 0, cost: 0 },
metaphor: providerBreakdown.metaphor ?? { calls: 0, tokens: 0, cost: 0 },
firecrawl: providerBreakdown.firecrawl ?? { calls: 0, tokens: 0, cost: 0 },
stability: providerBreakdown.stability ?? { calls: 0, tokens: 0, cost: 0 },
},
provider_breakdown: providerBreakdownCoerced,
alerts: coerceAlerts(raw?.alerts),
usage_percentages: {
gemini_calls: raw?.usage_percentages?.gemini_calls ?? 0,
openai_calls: raw?.usage_percentages?.openai_calls ?? 0,
anthropic_calls: raw?.usage_percentages?.anthropic_calls ?? 0,
mistral_calls: raw?.usage_percentages?.mistral_calls ?? 0,
tavily_calls: raw?.usage_percentages?.tavily_calls ?? 0,
serper_calls: raw?.usage_percentages?.serper_calls ?? 0,
metaphor_calls: raw?.usage_percentages?.metaphor_calls ?? 0,
firecrawl_calls: raw?.usage_percentages?.firecrawl_calls ?? 0,
stability_calls: raw?.usage_percentages?.stability_calls ?? 0,
cost: raw?.usage_percentages?.cost ?? 0,
},
usage_percentages: usagePercentagesCoerced,
last_updated: raw?.last_updated ?? new Date().toISOString(),
};
@@ -229,8 +300,11 @@ export const billingService = {
// Coerce missing fields to satisfy the contract before validation
const raw = response.data.data as any;
// Coerce usage stats first to ensure proper typing
const currentUsage = coerceUsageStats(raw?.current_usage ?? raw);
const coerced: DashboardData = {
current_usage: coerceUsageStats(raw?.current_usage ?? raw),
current_usage: currentUsage,
trends: raw?.trends ?? {
periods: [],
total_calls: [],
@@ -253,14 +327,43 @@ export const billingService = {
},
};
// Debug logs removed to reduce console noise
// Debug: Log cost calculation details
console.log('💰 [BILLING DEBUG] Cost calculation:', {
backendTotalCost: coerced.current_usage.total_cost,
geminiCost: coerced.current_usage.provider_breakdown.gemini.cost,
huggingfaceCost: coerced.current_usage.provider_breakdown.huggingface.cost,
calculatedTotal: coerced.current_usage.provider_breakdown.gemini.cost + coerced.current_usage.provider_breakdown.huggingface.cost,
providerBreakdown: coerced.current_usage.provider_breakdown,
});
// Validate response data after coercion
const validatedData = DashboardDataSchema.parse(coerced);
// Debug logs removed to reduce console noise
// Notify app that fresh billing data is available
emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' });
return validatedData;
// Note: If validation fails due to cached schema, we'll handle it gracefully
try {
const validatedData = DashboardDataSchema.parse(coerced);
// Notify app that fresh billing data is available
emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' });
return validatedData;
} catch (validationError: any) {
// Check if error is due to old schema expecting other providers
const isOldSchemaError = validationError.errors?.some((err: any) =>
err.path?.includes('provider_breakdown') &&
err.path[err.path.length - 1] !== 'gemini' &&
err.path[err.path.length - 1] !== 'huggingface'
);
if (isOldSchemaError) {
console.error('❌ [BILLING DEBUG] Validation failed due to cached old schema. Browser cache needs to be cleared.');
console.error('❌ [BILLING DEBUG] Error details:', validationError.errors);
// Still return the coerced data - it's correct, just schema validation is cached
// The data structure is correct with only gemini and huggingface
emitApiEvent({ url: `/dashboard/${actualUserId}`, method: 'GET', source: 'billing' });
return coerced;
}
// For other validation errors, throw them
console.error('❌ [BILLING DEBUG] Validation error:', validationError);
throw validationError;
}
} catch (error) {
console.error('❌ [BILLING DEBUG] Error fetching dashboard data:', error);
throw error;
@@ -409,6 +512,67 @@ export const billingService = {
throw error;
}
},
/**
* Get API usage logs for the current user
*/
getUsageLogs: async (
limit: number = 50,
offset: number = 0,
provider?: string,
statusCode?: number,
billingPeriod?: string
): Promise<UsageLogsResponse> => {
try {
const params: any = { limit, offset };
if (provider) params.provider = provider;
if (statusCode !== undefined) params.status_code = statusCode;
if (billingPeriod) params.billing_period = billingPeriod;
const response = await billingAPI.get<UsageLogsResponse>('/usage-logs', { params });
return response.data;
} catch (error: any) {
console.error('Error fetching usage logs:', error);
throw new Error(
error.response?.data?.detail ||
error.message ||
'Failed to fetch usage logs'
);
}
},
/**
* Get subscription renewal history for the current user
*/
getRenewalHistory: async (
userId?: string,
limit: number = 50,
offset: number = 0
): Promise<RenewalHistoryResponse> => {
try {
const actualUserId = userId || localStorage.getItem('user_id') || 'demo-user';
const params: any = { limit, offset };
const response = await billingAPI.get<RenewalHistoryAPIResponse>(
`/renewal-history/${actualUserId}`,
{ params }
);
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to fetch renewal history');
}
emitApiEvent({ url: `/renewal-history/${actualUserId}`, method: 'GET', source: 'billing' });
return response.data.data;
} catch (error: any) {
console.error('Error fetching renewal history:', error);
throw new Error(
error.response?.data?.detail ||
error.message ||
'Failed to fetch renewal history'
);
}
},
};
// Utility functions
@@ -463,14 +627,7 @@ export const calculateUsagePercentage = (current: number, limit: number): number
export const getProviderIcon = (provider: string): string => {
const icons: { [key: string]: string } = {
gemini: '🤖',
openai: '🧠',
anthropic: '🎭',
mistral: '🌪️',
tavily: '🔍',
serper: '🔎',
metaphor: '🔮',
firecrawl: '🕷️',
stability: '🎨',
huggingface: '🤗', // HuggingFace icon
};
return icons[provider.toLowerCase()] || '🔧';
};
@@ -478,14 +635,7 @@ export const getProviderIcon = (provider: string): string => {
export const getProviderColor = (provider: string): string => {
const colors: { [key: string]: string } = {
gemini: '#4285f4',
openai: '#10a37f',
anthropic: '#d97706',
mistral: '#7c3aed',
tavily: '#059669',
serper: '#dc2626',
metaphor: '#7c2d12',
firecrawl: '#ea580c',
stability: '#0891b2',
huggingface: '#ffd21e', // HuggingFace yellow color
};
return colors[provider.toLowerCase()] || '#6b7280';
};

View File

@@ -461,6 +461,31 @@ export const blogWriterApi = {
return data;
},
async generateSEOTitles(payload: {
research: BlogResearchResponse;
outline: BlogOutlineSection[];
primary_keywords: string[];
secondary_keywords: string[];
content_angles: string[];
search_intent?: string;
word_count?: number;
}): Promise<{ success: boolean; titles: string[] }> {
const { data } = await aiApiClient.post('/api/blog/titles/generate-seo', payload);
return data;
},
async generateIntroductions(payload: {
blog_title: string;
research: BlogResearchResponse;
outline: BlogOutlineSection[];
sections_content: Record<string, string>;
primary_keywords: string[];
search_intent?: string;
}): Promise<{ success: boolean; introductions: string[] }> {
const { data } = await aiApiClient.post('/api/blog/introductions/generate', payload);
return data;
},
// Enhanced Outline Methods
async enhanceSection(section: BlogOutlineSection, focus: string = 'general improvement'): Promise<BlogOutlineSection> {
const { data } = await apiClient.post("/api/blog/outline/enhance-section", section, {

View File

@@ -1,5 +1,6 @@
import axios, { AxiosResponse } from 'axios';
import { emitApiEvent } from '../utils/apiEvents';
import { getApiUrl } from '../api/client';
import {
SystemHealth,
APIStats,
@@ -16,11 +17,14 @@ import {
} from '../types/monitoring';
// API base configuration - consistent with client.ts pattern
const API_BASE_URL = process.env.REACT_APP_API_URL || process.env.REACT_APP_BACKEND_URL || '';
const API_BASE_URL = getApiUrl();
const MONITORING_BASE_URL = API_BASE_URL
? `${API_BASE_URL.replace(/\/+$/, '')}/api/content-planning/monitoring`
: '/api/content-planning/monitoring';
// Create axios instance for monitoring APIs
const monitoringAPI = axios.create({
baseURL: `${API_BASE_URL}/api/content-planning/monitoring`,
baseURL: MONITORING_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',

View File

@@ -0,0 +1,446 @@
import { aiApiClient, pollingApiClient } from "../api/client";
/**
* Story Writer API Service
*
* Provides TypeScript-typed API calls for story generation endpoints.
*/
export interface StoryGenerationRequest {
persona: string;
story_setting: string;
character_input: string;
plot_elements: string;
writing_style: string;
story_tone: string;
narrative_pov: string;
audience_age_group: string;
content_rating: string;
ending_preference: string;
story_length?: string;
enable_explainer?: boolean;
enable_illustration?: boolean;
enable_video_narration?: boolean;
// Image generation settings
image_provider?: string;
image_width?: number;
image_height?: number;
image_model?: string;
// Video generation settings
video_fps?: number;
video_transition_duration?: number;
// Audio generation settings
audio_provider?: string;
audio_lang?: string;
audio_slow?: boolean;
audio_rate?: number;
}
export interface StorySetupGenerationRequest {
story_idea: string;
}
export interface StorySetupOption {
persona: string;
story_setting: string;
character_input: string;
plot_elements: string;
writing_style: string;
story_tone: string;
narrative_pov: string;
audience_age_group: string;
content_rating: string;
ending_preference: string;
story_length?: string;
premise: string;
reasoning: string;
// Image generation settings
image_provider?: string;
image_width?: number;
image_height?: number;
image_model?: string;
// Video generation settings
video_fps?: number;
video_transition_duration?: number;
// Audio generation settings
audio_provider?: string;
audio_lang?: string;
audio_slow?: boolean;
audio_rate?: number;
}
export interface StorySetupGenerationResponse {
options: StorySetupOption[];
success: boolean;
}
export interface StoryPremiseResponse {
premise: string;
success: boolean;
task_id?: string;
}
export interface StoryScene {
scene_number: number;
title: string;
description: string;
image_prompt: string;
audio_narration: string;
character_descriptions?: string[];
key_events?: string[];
}
export interface StoryOutlineResponse {
outline: string | StoryScene[];
success: boolean;
task_id?: string;
is_structured?: boolean;
}
export interface StoryContentResponse {
story: string;
premise?: string;
outline?: string;
is_complete: boolean;
iterations: number;
success: boolean;
task_id?: string;
}
export interface StoryFullGenerationResponse {
premise: string;
outline: string;
story: string;
is_complete: boolean;
iterations: number;
success: boolean;
task_id?: string;
}
export interface StoryStartRequest extends StoryGenerationRequest {
premise: string;
outline: string | StoryScene[];
}
export interface StoryContinueRequest extends StoryGenerationRequest {
premise: string;
outline: string | StoryScene[];
story_text: string;
}
export interface StoryContinueResponse {
continuation: string;
is_complete: boolean;
success: boolean;
}
export interface TaskStatus {
task_id: string;
status: "pending" | "processing" | "completed" | "failed";
progress?: number;
message?: string;
result?: any;
error?: string;
created_at?: string;
updated_at?: string;
}
export interface CacheStats {
total_entries: number;
cache_keys: string[];
}
export interface StoryImageGenerationRequest {
scenes: StoryScene[];
provider?: string;
width?: number;
height?: number;
model?: string;
}
export interface StoryImageResult {
scene_number: number;
scene_title: string;
image_filename: string;
image_url: string;
width: number;
height: number;
provider: string;
model?: string;
seed?: number;
error?: string;
}
export interface StoryImageGenerationResponse {
images: StoryImageResult[];
success: boolean;
task_id?: string;
}
export interface StoryAudioGenerationRequest {
scenes: StoryScene[];
provider?: string;
lang?: string;
slow?: boolean;
rate?: number;
}
export interface StoryAudioResult {
scene_number: number;
scene_title: string;
audio_filename: string;
audio_url: string;
provider: string;
file_size: number;
error?: string;
}
export interface StoryAudioGenerationResponse {
audio_files: StoryAudioResult[];
success: boolean;
task_id?: string;
}
export interface StoryVideoGenerationRequest {
scenes: StoryScene[];
image_urls: string[];
audio_urls: string[];
story_title?: string;
fps?: number;
transition_duration?: number;
}
export interface StoryVideoResult {
video_filename: string;
video_url: string;
duration: number;
fps: number;
file_size: number;
num_scenes: number;
error?: string;
}
export interface StoryVideoGenerationResponse {
video: StoryVideoResult;
success: boolean;
task_id?: string;
}
class StoryWriterApi {
/**
* Generate 3 story setup options from a user's story idea
*/
async generateStorySetup(
request: StorySetupGenerationRequest
): Promise<StorySetupGenerationResponse> {
const response = await aiApiClient.post<StorySetupGenerationResponse>(
"/api/story/generate-setup",
request
);
return response.data;
}
/**
* Generate a story premise
*/
async generatePremise(request: StoryGenerationRequest): Promise<StoryPremiseResponse> {
const response = await aiApiClient.post<StoryPremiseResponse>(
"/api/story/generate-premise",
request
);
return response.data;
}
/**
* Generate a story outline from a premise
*/
async generateOutline(
premise: string,
request: StoryGenerationRequest
): Promise<StoryOutlineResponse> {
// Create StoryStartRequest with premise included
const outlineRequest: StoryStartRequest = {
...request,
premise: premise,
outline: [], // Empty outline for outline generation
};
const response = await aiApiClient.post<StoryOutlineResponse>(
`/api/story/generate-outline`,
outlineRequest
);
return response.data;
}
/**
* Generate the starting section of a story
*/
async generateStoryStart(
premise: string,
outline: string | StoryScene[],
request: StoryGenerationRequest
): Promise<StoryContentResponse> {
// Create request body with premise and outline
const requestBody = {
...request,
premise,
outline,
};
const response = await aiApiClient.post<StoryContentResponse>(
`/api/story/generate-start`,
requestBody
);
return response.data;
}
/**
* Continue writing a story
*/
async continueStory(request: StoryContinueRequest): Promise<StoryContinueResponse> {
const response = await aiApiClient.post<StoryContinueResponse>(
"/api/story/continue",
request
);
return response.data;
}
/**
* Generate a complete story asynchronously
* Returns a task_id for polling
*/
async generateFullStory(
request: StoryGenerationRequest,
maxIterations: number = 10
): Promise<{ task_id: string; status: string; message: string }> {
const response = await aiApiClient.post<{ task_id: string; status: string; message: string }>(
"/api/story/generate-full",
request,
{
params: { max_iterations: maxIterations },
}
);
return response.data;
}
/**
* Get the status of a story generation task
*/
async getTaskStatus(taskId: string): Promise<TaskStatus> {
const response = await pollingApiClient.get<TaskStatus>(
`/api/story/task/${taskId}/status`
);
return response.data;
}
/**
* Get the result of a completed story generation task
*/
async getTaskResult(taskId: string): Promise<StoryFullGenerationResponse> {
const response = await aiApiClient.get<StoryFullGenerationResponse>(
`/api/story/task/${taskId}/result`
);
return response.data;
}
/**
* Get cache statistics
*/
async getCacheStats(): Promise<{ success: boolean; stats: CacheStats }> {
const response = await pollingApiClient.get<{ success: boolean; stats: CacheStats }>(
"/api/story/cache/stats"
);
return response.data;
}
/**
* Clear the story generation cache
*/
async clearCache(): Promise<{ success: boolean; status: string; message: string }> {
const response = await pollingApiClient.post<{ success: boolean; status: string; message: string }>(
"/api/story/cache/clear"
);
return response.data;
}
/**
* Generate images for story scenes
*/
async generateSceneImages(request: StoryImageGenerationRequest): Promise<StoryImageGenerationResponse> {
const response = await aiApiClient.post<StoryImageGenerationResponse>(
"/api/story/generate-images",
request
);
return response.data;
}
/**
* Get image URL for a scene image
*/
getImageUrl(imageUrl: string): string {
// If imageUrl is already a full URL, return it as-is
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
// Otherwise, prepend the base URL
const baseURL = aiApiClient.defaults.baseURL || '';
// Remove trailing slash from baseURL if present, and leading slash from imageUrl if present
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
const cleanImageUrl = imageUrl.startsWith('/') ? imageUrl : `/${imageUrl}`;
return `${cleanBaseURL}${cleanImageUrl}`;
}
/**
* Generate audio narration for story scenes
*/
async generateSceneAudio(request: StoryAudioGenerationRequest): Promise<StoryAudioGenerationResponse> {
const response = await aiApiClient.post<StoryAudioGenerationResponse>(
"/api/story/generate-audio",
request
);
return response.data;
}
/**
* Get audio URL for a scene audio file
*/
getAudioUrl(audioUrl: string): string {
// If audioUrl is already a full URL, return it as-is
if (audioUrl.startsWith('http://') || audioUrl.startsWith('https://')) {
return audioUrl;
}
// Otherwise, prepend the base URL
const baseURL = aiApiClient.defaults.baseURL || '';
// Remove trailing slash from baseURL if present, and leading slash from audioUrl if present
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
const cleanAudioUrl = audioUrl.startsWith('/') ? audioUrl : `/${audioUrl}`;
return `${cleanBaseURL}${cleanAudioUrl}`;
}
/**
* Generate video from story scenes, images, and audio
*/
async generateStoryVideo(request: StoryVideoGenerationRequest): Promise<StoryVideoGenerationResponse> {
const response = await aiApiClient.post<StoryVideoGenerationResponse>(
"/api/story/generate-video",
request
);
return response.data;
}
/**
* Get video URL for a story video file
*/
getVideoUrl(videoUrl: string): string {
// If videoUrl is already a full URL, return it as-is
if (videoUrl.startsWith('http://') || videoUrl.startsWith('https://')) {
return videoUrl;
}
// Otherwise, prepend the base URL
const baseURL = aiApiClient.defaults.baseURL || '';
// Remove trailing slash from baseURL if present, and leading slash from videoUrl if present
const cleanBaseURL = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL;
const cleanVideoUrl = videoUrl.startsWith('/') ? videoUrl : `/${videoUrl}`;
return `${cleanBaseURL}${cleanVideoUrl}`;
}
}
export const storyWriterApi = new StoryWriterApi();

View File

@@ -27,14 +27,7 @@ export interface UsageStats {
export interface ProviderBreakdown {
gemini: ProviderUsage;
openai: ProviderUsage;
anthropic: ProviderUsage;
mistral: ProviderUsage;
tavily: ProviderUsage;
serper: ProviderUsage;
metaphor: ProviderUsage;
firecrawl: ProviderUsage;
stability: ProviderUsage;
huggingface: ProviderUsage;
}
export interface ProviderUsage {
@@ -108,6 +101,36 @@ export interface UsageSummary {
unread_alerts: number;
}
export interface UsageLog {
id: number;
timestamp: string;
provider: string;
model_used: string | null;
endpoint: string;
method: string;
tokens_input: number;
tokens_output: number;
tokens_total: number;
cost_input: number;
cost_output: number;
cost_total: number;
response_time: number;
status_code: number;
status: 'success' | 'failed';
error_message: string | null;
billing_period: string;
retry_count: number;
is_aggregated?: boolean; // Flag indicating this is an aggregated log entry
}
export interface UsageLogsResponse {
logs: UsageLog[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface SubscriptionPlan {
id: number;
name: string;
@@ -149,28 +172,14 @@ export interface APIPricing {
export interface UsagePercentages {
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;
huggingface_calls: number; // HuggingFace uses mistral_calls in backend
cost: number;
}
// Zod validation schemas
export const UsagePercentagesSchema = z.object({
gemini_calls: z.number(),
openai_calls: z.number(),
anthropic_calls: z.number(),
mistral_calls: z.number(),
tavily_calls: z.number(),
serper_calls: z.number(),
metaphor_calls: z.number(),
firecrawl_calls: z.number(),
stability_calls: z.number(),
huggingface_calls: z.number(), // Backend sends mistral_calls, we map to huggingface_calls
cost: z.number(),
});
@@ -182,14 +191,7 @@ export const ProviderUsageSchema = z.object({
export const ProviderBreakdownSchema = z.object({
gemini: ProviderUsageSchema,
openai: ProviderUsageSchema,
anthropic: ProviderUsageSchema,
mistral: ProviderUsageSchema,
tavily: ProviderUsageSchema,
serper: ProviderUsageSchema,
metaphor: ProviderUsageSchema,
firecrawl: ProviderUsageSchema,
stability: ProviderUsageSchema,
huggingface: ProviderUsageSchema,
});
export const SubscriptionLimitsSchema = z.object({
@@ -285,3 +287,41 @@ export interface DashboardAPIResponse extends BillingAPIResponse<DashboardData>
export interface PlansAPIResponse extends BillingAPIResponse<{ plans: SubscriptionPlan[]; total: number }> {}
export interface PricingAPIResponse extends BillingAPIResponse<{ pricing: APIPricing[]; total: number }> {}
export interface AlertsAPIResponse extends BillingAPIResponse<{ alerts: UsageAlert[]; total: number; unread_count: number }> {}
// Subscription Renewal History
export interface SubscriptionRenewal {
id: number;
plan_name: string;
plan_tier: string;
previous_period_start: string | null;
previous_period_end: string | null;
new_period_start: string;
new_period_end: string;
billing_cycle: 'monthly' | 'yearly';
renewal_type: 'new' | 'renewal' | 'upgrade' | 'downgrade';
renewal_count: number;
previous_plan_name: string | null;
previous_plan_tier: string | null;
usage_before_renewal: {
total_calls?: number;
total_tokens?: number;
total_cost?: number;
gemini_calls?: number;
mistral_calls?: number;
usage_status?: string;
} | null;
payment_amount: number;
payment_status: string;
payment_date: string | null;
created_at: string;
}
export interface RenewalHistoryResponse {
renewals: SubscriptionRenewal[];
total_count: number;
limit: number;
offset: number;
has_more: boolean;
}
export interface RenewalHistoryAPIResponse extends BillingAPIResponse<RenewalHistoryResponse> {}

View File

@@ -0,0 +1,147 @@
/**
* Shared toast notification utility
* Provides a consistent way to show toast notifications across the app
*/
export type ToastType = 'error' | 'warning' | 'info' | 'success';
interface ToastOptions {
/**
* Duration in milliseconds before toast auto-dismisses
* @default 5000 for info/warning, 7000 for error
*/
duration?: number;
/**
* Position of the toast
* @default 'top-right'
*/
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
/**
* Maximum width of the toast
* @default 400
*/
maxWidth?: number;
}
/**
* Show a toast notification using DOM-based approach
* Works globally across the app, regardless of which component is mounted
*
* @param message - The message to display
* @param type - The type of toast (error, warning, info, success)
* @param options - Optional configuration
*/
export function showToastNotification(
message: string,
type: ToastType = 'info',
options: ToastOptions = {}
): void {
const {
duration,
position = 'top-right',
maxWidth = 400
} = options;
// Determine duration based on type if not specified
const toastDuration = duration !== undefined
? duration
: type === 'error' ? 7000 : 5000;
// Determine background color based on type
const bgColors: Record<ToastType, string> = {
error: '#f44336',
warning: '#ff9800',
info: '#2196f3',
success: '#4caf50'
};
// Determine position styles
const positionStyles: Record<string, { top?: string; bottom?: string; left?: string; right?: string }> = {
'top-left': { top: '20px', left: '20px' },
'top-right': { top: '20px', right: '20px' },
'bottom-left': { bottom: '20px', left: '20px' },
'bottom-right': { bottom: '20px', right: '20px' }
};
const pos = positionStyles[position] || positionStyles['top-right'];
// Create toast element
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
${pos.top ? `top: ${pos.top};` : ''}
${pos.bottom ? `bottom: ${pos.bottom};` : ''}
${pos.left ? `left: ${pos.left};` : ''}
${pos.right ? `right: ${pos.right};` : ''}
padding: 16px 24px;
border-radius: 8px;
color: white;
font-weight: 500;
font-size: 14px;
z-index: 10000;
max-width: ${maxWidth}px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transform: translateX(${position.includes('right') ? '100%' : position.includes('left') ? '-100%' : '0'});
transition: transform 0.3s ease;
background-color: ${bgColors[type] || bgColors.info};
word-wrap: break-word;
line-height: 1.5;
`;
toast.textContent = message;
document.body.appendChild(toast);
// Animate in
setTimeout(() => {
toast.style.transform = 'translateX(0)';
}, 100);
// Remove after duration
setTimeout(() => {
const translateX = position.includes('right') ? '100%' : position.includes('left') ? '-100%' : '0';
toast.style.transform = `translateX(${translateX})`;
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, toastDuration);
}
/**
* Show subscription-related toast notifications
* Uses messages similar to SubscriptionExpiredModal
*/
export function showSubscriptionToast(
message: string,
type: 'error' | 'warning' = 'warning',
options: ToastOptions = {}
): void {
showToastNotification(message, type, {
duration: type === 'error' ? 8000 : 6000, // Longer duration for subscription messages
...options
});
}
/**
* Show subscription expired toast
*/
export function showSubscriptionExpiredToast(): void {
showSubscriptionToast(
'Your subscription has expired. To continue using Alwrity and access all features, you need to renew your subscription.',
'warning'
);
}
/**
* Show usage limit reached toast
*/
export function showUsageLimitToast(message?: string): void {
showSubscriptionToast(
message || 'You\'ve reached your monthly usage limit for this plan. Upgrade your plan to get higher limits.',
'warning'
);
}