/** * SEO Metadata Modal Component * * Comprehensive SEO metadata generation and editing interface with: * - Tabbed interface for different metadata types * - Live preview of social media cards * - Character counters and validation * - Copy-to-clipboard functionality * - Integration with backend metadata generation */ import React, { useState, useEffect, useCallback } from 'react'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Box, Typography, Tabs, Tab, CircularProgress, Alert, IconButton, Chip, Tooltip } from '@mui/material'; import { Close as CloseIcon, Check as CheckIcon, Preview as PreviewIcon, Search as SearchIcon, Share as ShareIcon, Code as CodeIcon, Tag as TagIcon, Refresh as RefreshIcon } from '@mui/icons-material'; import { apiClient, triggerSubscriptionError } from '../../api/client'; // Import metadata display components import { CoreMetadataTab } from './SEO/MetadataDisplay/CoreMetadataTab'; import { SocialMediaTab } from './SEO/MetadataDisplay/SocialMediaTab'; import { StructuredDataTab } from './SEO/MetadataDisplay/StructuredDataTab'; import { PreviewCard } from './SEO/MetadataDisplay/PreviewCard'; import { subscribeImage } from '../../utils/imageBus'; interface SEOMetadataModalProps { isOpen: boolean; onClose: () => void; blogContent: string; blogTitle: string; researchData: any; outline?: any[]; // Add outline structure seoAnalysis?: any; // Add SEO analysis results onMetadataGenerated: (metadata: any) => void; } interface SEOMetadataResult { success: boolean; seo_title?: string; meta_description?: string; url_slug?: string; blog_tags?: string[]; blog_categories?: string[]; social_hashtags?: string[]; open_graph?: any; twitter_card?: any; json_ld_schema?: any; canonical_url?: string; reading_time?: number; focus_keyword?: string; generated_at?: string; optimization_score?: number; error?: string; } // Cache helper functions (similar to SEOAnalysisModal) async function hashContent(text: string): Promise { try { const enc = new TextEncoder().encode(text); const digest = await crypto.subtle.digest('SHA-256', enc); const bytes = Array.from(new Uint8Array(digest)); return bytes.map(b => b.toString(16).padStart(2, '0')).join(''); } catch { // Fallback hash let h = 0; for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) | 0; return String(h); } } function getMetadataCacheKey(contentHash: string, title?: string): string { return `seo_metadata_cache:${contentHash}:${title || ''}`; } export const SEOMetadataModal: React.FC = ({ isOpen, onClose, blogContent, blogTitle, researchData, outline, seoAnalysis, onMetadataGenerated }) => { const [isGenerating, setIsGenerating] = useState(false); const [metadataResult, setMetadataResult] = useState(null); const [error, setError] = useState(null); const [tabValue, setTabValue] = useState('preview'); // Start with preview tab first const [previewTabValue, setPreviewTabValue] = useState('google'); // Sub-tab for preview platforms const [copiedItems, setCopiedItems] = useState>(new Set()); const [editableMetadata, setEditableMetadata] = useState(null); const [contentHash, setContentHash] = useState(''); // Subscribe to image generation bus to auto-fill OG/Twitter image fields useEffect(() => { const unsub = subscribeImage(({ base64 }: { base64: string }) => { setEditableMetadata(prev => { const next = { ...(prev || metadataResult || {}) } as any; next.open_graph = { ...(next.open_graph || {}), image: `data:image/png;base64,${base64}` }; next.twitter_card = { ...(next.twitter_card || {}), image: `data:image/png;base64,${base64}` }; return next; }); }); return unsub; }, [metadataResult]); // Debug logging only in development and when modal state changes meaningfully useEffect(() => { if (process.env.NODE_ENV === 'development' && isOpen) { console.log('🔍 SEOMetadataModal render:', { isOpen, blogContent: blogContent?.length, blogTitle, researchData: !!researchData }); } }, [isOpen, blogContent?.length, blogTitle, researchData]); // Reset state when modal closes useEffect(() => { if (!isOpen) { // Reset state when modal closes (but keep result for next time) setError(null); setIsGenerating(false); } }, [isOpen]); const generateMetadata = useCallback(async (forceRefresh = false) => { try { setIsGenerating(true); setError(null); if (forceRefresh) { setMetadataResult(null); } console.log('🚀 Starting SEO metadata generation...', { forceRefresh }); // 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') { const cached = window.localStorage.getItem(cacheKey); if (cached) { try { const parsed = JSON.parse(cached) as SEOMetadataResult; // 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 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 const response = await apiClient.post('/api/blog/seo/metadata', { content: blogContent, title: blogTitle, research_data: researchData, outline: outline || null, seo_analysis: seoAnalysis || null }); const result = response.data; console.log('✅ SEO metadata generation response:', result); // 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'); } // Cache the result if (typeof window !== 'undefined') { try { window.localStorage.setItem(cacheKey, JSON.stringify(result)); console.log('💾 SEO metadata cached'); } catch (e) { console.warn('Failed to cache metadata:', e); } } const sanitizeMetadata = (data: any) => { const safe = { ...data }; safe.seo_title = safe.seo_title ?? ''; safe.meta_description = safe.meta_description ?? ''; safe.url_slug = safe.url_slug ?? ''; safe.focus_keyword = safe.focus_keyword ?? ''; safe.reading_time = typeof safe.reading_time === 'number' ? safe.reading_time : 0; safe.blog_tags = Array.isArray(safe.blog_tags) ? safe.blog_tags : []; safe.blog_categories = Array.isArray(safe.blog_categories) ? safe.blog_categories : []; safe.social_hashtags = Array.isArray(safe.social_hashtags) ? safe.social_hashtags : []; safe.open_graph = { ...(safe.open_graph || {}), title: safe.open_graph?.title ?? '', description: safe.open_graph?.description ?? '', image: safe.open_graph?.image ?? '', url: safe.open_graph?.url ?? '' }; safe.twitter_card = { ...(safe.twitter_card || {}), title: safe.twitter_card?.title ?? '', description: safe.twitter_card?.description ?? '', image: safe.twitter_card?.image ?? '', site: safe.twitter_card?.site ?? '' }; safe.json_ld_schema = { ...(safe.json_ld_schema || {}) }; return safe; }; const sanitized = sanitizeMetadata(result); setMetadataResult(sanitized); setEditableMetadata(sanitized); console.log('📊 Metadata result set:', result); } catch (err: any) { console.error('❌ SEO metadata generation failed:', err); // 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 (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'); 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 { console.warn('SEOMetadataModal: Global subscription error handler did not handle the error'); } } // For non-subscription errors, show local error message setError(err instanceof Error ? err.message : 'Failed to generate SEO metadata'); } finally { setIsGenerating(false); } }, [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); }; const handleCopyToClipboard = async (text: string, itemId: string) => { try { await navigator.clipboard.writeText(text); setCopiedItems(prev => new Set([...prev, itemId])); setTimeout(() => { setCopiedItems(prev => { const newSet = new Set(prev); newSet.delete(itemId); return newSet; }); }, 2000); } catch (err) { console.error('Failed to copy to clipboard:', err); } }; const handleMetadataEdit = (field: string, value: any) => { if (editableMetadata) { setEditableMetadata(prev => ({ ...prev!, [field]: value })); } }; /** * Handle Apply Metadata button click * * This saves the generated/edited metadata to the parent component's state. * The metadata is then used when publishing to platforms: * - WordPress: Requires SEO metadata for proper post creation with SEO fields * - Wix: Currently doesn't require metadata, but could be added in future * * The metadata includes: * - SEO title, meta description, URL slug * - Blog tags, categories, focus keyword * - Open Graph tags (Facebook/LinkedIn) * - Twitter Card tags * - JSON-LD structured data (Schema.org Article) * * All of these will be passed to the platform's API when publishing. */ const handleApplyMetadata = () => { if (editableMetadata) { onMetadataGenerated(editableMetadata); onClose(); } }; const getTabIcon = (tabValue: string) => { switch (tabValue) { case 'core': return ; case 'social': return ; case 'structured': return ; case 'preview': return ; default: return ; } }; const getTabLabel = (tabValue: string) => { switch (tabValue) { case 'core': return 'Core SEO'; case 'social': return 'Social Media'; case 'structured': return 'Structured Data'; case 'preview': return 'Preview'; default: return 'Metadata'; } }; return ( SEO Metadata Generator {metadataResult && ( = 80 ? 'success' : metadataResult.optimization_score && metadataResult.optimization_score >= 60 ? 'warning' : 'error'} size="small" /> )} {metadataResult && ( generateMetadata(true)} size="small" disabled={isGenerating} color="primary" > )} {isGenerating && ( Generating SEO Metadata... Creating optimized titles, descriptions, and social media tags )} {error && ( {error} )} {metadataResult && ( {/* Tabs */} {['preview', 'core', 'social', 'structured'].map((tab) => ( ))} {/* Tab Content */} {tabValue === 'core' && ( )} {tabValue === 'social' && ( )} {tabValue === 'structured' && ( )} {tabValue === 'preview' && ( )} )} {metadataResult && ( )} ); };