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:
@@ -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 />} />
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -122,6 +122,7 @@ export const PhaseContent: React.FC<PhaseContentProps> = ({
|
||||
aiGeneratedTitles={aiGeneratedTitles}
|
||||
onTitleSelect={onTitleSelect}
|
||||
onCustomTitle={onCustomTitle}
|
||||
research={research}
|
||||
/>
|
||||
<EnhancedOutlineEditor
|
||||
outline={outline}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 high‑quality 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;
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -340,7 +340,7 @@ const MainDashboard: React.FC = () => {
|
||||
<AnalyticsInsights />
|
||||
|
||||
{/* Billing & Usage Dashboard */}
|
||||
<EnhancedBillingDashboard />
|
||||
<EnhancedBillingDashboard terminalTheme={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
122
frontend/src/components/StoryWriter/PhaseNavigation.tsx
Normal file
122
frontend/src/components/StoryWriter/PhaseNavigation.tsx
Normal 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;
|
||||
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal file
360
frontend/src/components/StoryWriter/Phases/StoryExport.tsx
Normal 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;
|
||||
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal file
970
frontend/src/components/StoryWriter/Phases/StoryOutline.tsx
Normal 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;
|
||||
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal file
111
frontend/src/components/StoryWriter/Phases/StoryPremise.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
|
||||
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal file
257
frontend/src/components/StoryWriter/Phases/StorySetup/index.tsx
Normal 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;
|
||||
|
||||
@@ -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)',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal file
292
frontend/src/components/StoryWriter/Phases/StoryWriting.tsx
Normal 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;
|
||||
120
frontend/src/components/StoryWriter/StoryWriter.tsx
Normal file
120
frontend/src/components/StoryWriter/StoryWriter.tsx
Normal 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;
|
||||
2
frontend/src/components/StoryWriter/index.ts
Normal file
2
frontend/src/components/StoryWriter/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as StoryWriter } from './StoryWriter';
|
||||
export { default as PhaseNavigation } from './PhaseNavigation';
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
467
frontend/src/components/billing/SubscriptionRenewalHistory.tsx
Normal file
467
frontend/src/components/billing/SubscriptionRenewalHistory.tsx
Normal 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;
|
||||
|
||||
426
frontend/src/components/billing/UsageLogsTable.tsx
Normal file
426
frontend/src/components/billing/UsageLogsTable.tsx
Normal 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
156
frontend/src/hooks/useSchedulerTaskAlerts.ts
Normal file
156
frontend/src/hooks/useSchedulerTaskAlerts.ts
Normal 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;
|
||||
}
|
||||
|
||||
184
frontend/src/hooks/useStoryWriterPhaseNavigation.ts
Normal file
184
frontend/src/hooks/useStoryWriterPhaseNavigation.ts
Normal 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;
|
||||
455
frontend/src/hooks/useStoryWriterState.ts
Normal file
455
frontend/src/hooks/useStoryWriterState.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
222
frontend/src/pages/BillingPage.tsx
Normal file
222
frontend/src/pages/BillingPage.tsx
Normal 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;
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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',
|
||||
|
||||
446
frontend/src/services/storyWriterApi.ts
Normal file
446
frontend/src/services/storyWriterApi.ts
Normal 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();
|
||||
@@ -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> {}
|
||||
|
||||
147
frontend/src/utils/toastNotifications.ts
Normal file
147
frontend/src/utils/toastNotifications.ts
Normal 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'
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user