import { useState, useEffect, useRef, useCallback } from 'react'; import { useNavigate, useLocation, useParams } from 'react-router'; import { motion } from 'motion/react'; import { Check, AlertCircle, RefreshCw, ShieldX } from 'lucide-react'; import { discoverChannels, collectChannelData, generateReportV2, generateContentPlan, fetchPipelineStatus, } from '../lib/supabase'; type Phase = 'resuming' | 'discovering' | 'collecting' | 'generating' | 'planning' | 'complete'; const PHASE_STEPS = [ { key: 'discovering' as Phase, label: 'Scanning website & discovering channels...', labelDone: 'Channels discovered' }, { key: 'collecting' as Phase, label: 'Collecting channel data & market analysis...', labelDone: 'Data collected' }, { key: 'generating' as Phase, label: 'Generating AI marketing report...', labelDone: 'Report generated' }, { key: 'planning' as Phase, label: 'Generating AI content strategy...', labelDone: 'Content plan generated' }, { key: 'complete' as Phase, label: 'Finalizing report...', labelDone: 'Complete' }, ]; // Session keys for pipeline resume const SESSION_KEYS = { reportId: 'infinith_reportId', clinicId: 'infinith_clinicId', runId: 'infinith_runId', url: 'infinith_url', }; function saveSession(data: { reportId: string; clinicId?: string; runId?: string; url?: string }) { sessionStorage.setItem(SESSION_KEYS.reportId, data.reportId); if (data.clinicId) sessionStorage.setItem(SESSION_KEYS.clinicId, data.clinicId); if (data.runId) sessionStorage.setItem(SESSION_KEYS.runId, data.runId); if (data.url) sessionStorage.setItem(SESSION_KEYS.url, data.url); } function loadSession() { return { reportId: sessionStorage.getItem(SESSION_KEYS.reportId), clinicId: sessionStorage.getItem(SESSION_KEYS.clinicId), runId: sessionStorage.getItem(SESSION_KEYS.runId), url: sessionStorage.getItem(SESSION_KEYS.url), }; } function clearSession() { Object.values(SESSION_KEYS).forEach(k => sessionStorage.removeItem(k)); } export default function AnalysisLoadingPage() { const [phase, setPhase] = useState('discovering'); const [error, setError] = useState(null); const [errorCode, setErrorCode] = useState(null); const [errorDomain, setErrorDomain] = useState(null); const [errorDetails, setErrorDetails] = useState | null>(null); const navigate = useNavigate(); const location = useLocation(); const { reportId: urlReportId } = useParams<{ reportId?: string }>(); const locState = (location.state as { url?: string; manualChannels?: import('../lib/supabase').ManualChannels; }) ?? {}; const url = locState.url; const manualChannels = locState.manualChannels; const hasStarted = useRef(false); const phaseIndex = PHASE_STEPS.findIndex(s => s.key === phase); const runPipeline = useCallback(async ( startUrl?: string, resumeFrom?: { reportId: string; clinicId?: string; runId?: string; phase: Phase }, ) => { try { let reportId = resumeFrom?.reportId || ''; let clinicId = resumeFrom?.clinicId; let runId = resumeFrom?.runId; let startPhase = resumeFrom?.phase || 'discovering'; // Phase 1: Discover Channels (skip if resuming from later phase) if (startPhase === 'discovering') { if (!startUrl) throw new Error('No URL provided'); setPhase('discovering'); // manualChannels가 있으면 Edge Function이 Firecrawl discovery를 스킵하고 // 사용자 제공 URL을 직접 verified_channels에 주입합니다. const discovery = await discoverChannels(startUrl, undefined, manualChannels); if (!discovery.success) throw new Error(discovery.error || 'Channel discovery failed'); reportId = discovery.reportId; clinicId = discovery.clinicId; runId = discovery.runId; // Save to session + update URL for resume saveSession({ reportId, clinicId, runId, url: startUrl }); window.history.replaceState(null, '', `/report/loading/${reportId}`); startPhase = 'collecting'; } // Phase 2: Collect Channel Data if (startPhase === 'collecting') { setPhase('collecting'); const collection = await collectChannelData(reportId, clinicId, runId); // Allow partial success — only fail on total failure if (collection.success === false && !collection.partialFailure) { throw new Error(collection.error || 'Data collection failed'); } if (collection.channelErrors && Object.keys(collection.channelErrors).length > 0) { console.warn('[pipeline] Partial failures:', collection.channelErrors); } startPhase = 'generating'; } // Phase 3: Generate Report let reportResult: Record | null = null; if (startPhase === 'generating') { setPhase('generating'); const result = await generateReportV2(reportId, clinicId, runId); if (!result.success) throw new Error(result.error || 'Report generation failed'); reportResult = result; startPhase = 'planning'; } // Phase 4: Generate Content Plan (non-blocking — failure doesn't stop pipeline) if (startPhase === 'planning') { setPhase('planning'); try { await generateContentPlan(reportId, clinicId, runId); } catch (planErr) { console.warn('[pipeline] Content plan generation failed (non-blocking):', planErr); } // Complete — navigate to report setPhase('complete'); clearSession(); // Use stored report result or refetch const result = reportResult || await generateReportV2(reportId, clinicId, runId).catch(() => null); setTimeout(() => { navigate(`/report/${reportId}`, { replace: true, state: result?.report && result?.metadata ? { report: result.report, metadata: result.metadata, reportId, clinicId } : undefined, }); }, 800); } } catch (err) { const msg = err instanceof Error ? err.message : 'An error occurred'; setError(msg); if (err && typeof err === 'object' && 'code' in err) { setErrorCode((err as { code?: string }).code || null); } if (err && typeof err === 'object' && 'domain' in err) { setErrorDomain((err as { domain?: string }).domain || null); } } }, [navigate, manualChannels]); // Retry from the current failed phase const handleRetry = useCallback(() => { setError(null); setErrorDetails(null); const session = loadSession(); if (session.reportId) { // Resume from the phase that failed runPipeline(undefined, { reportId: session.reportId, clinicId: session.clinicId || undefined, runId: session.runId || undefined, phase, }); } else if (url || session.url) { // Restart from scratch hasStarted.current = false; runPipeline(url || session.url || undefined); } }, [phase, url, runPipeline]); useEffect(() => { if (hasStarted.current) return; hasStarted.current = true; // 1. Try URL param resume (e.g., /report/loading/abc-123) if (urlReportId) { setPhase('resuming'); fetchPipelineStatus(urlReportId) .then((status) => { // Also check sessionStorage for clinicId/runId const session = loadSession(); const clinicId = session.clinicId || status.clinicId; const runId = session.runId || status.runId; if (status.hasReport || status.status === 'complete') { // Already done — go to report navigate(`/report/${urlReportId}`, { replace: true }); return; } let resumePhase: Phase = 'discovering'; if (status.status === 'discovered' || status.status === 'discovering') { resumePhase = 'collecting'; } else if (['collecting', 'collected', 'partial'].includes(status.status)) { resumePhase = 'generating'; } else if (status.status === 'collection_failed') { setError('Data collection failed. Please retry.'); setPhase('collecting'); return; } saveSession({ reportId: urlReportId, clinicId, runId, url: session.url || undefined }); runPipeline(undefined, { reportId: urlReportId, clinicId, runId, phase: resumePhase }); }) .catch(() => { setError('Could not resume analysis. Please try again.'); }); return; } // 2. Try sessionStorage resume const session = loadSession(); if (session.reportId && !url) { setPhase('resuming'); fetchPipelineStatus(session.reportId) .then((status) => { if (status.hasReport || status.status === 'complete') { clearSession(); navigate(`/report/${session.reportId}`, { replace: true }); return; } let resumePhase: Phase = 'discovering'; if (['discovered', 'discovering'].includes(status.status)) { resumePhase = 'collecting'; } else if (['collecting', 'collected', 'partial'].includes(status.status)) { resumePhase = 'generating'; } runPipeline(undefined, { reportId: session.reportId!, clinicId: session.clinicId || undefined, runId: session.runId || undefined, phase: resumePhase, }); }) .catch(() => { clearSession(); navigate('/', { replace: true }); }); return; } // 3. Fresh start with URL if (!url) { navigate('/', { replace: true }); return; } runPipeline(url); }, [url, urlReportId, navigate, runPipeline]); // Adjust phaseIndex for 'resuming' state const displayPhaseIndex = phase === 'resuming' ? -1 : phaseIndex; return (
INFINITH {(url || loadSession().url) && ( {url || loadSession().url} )} {error ? ( errorCode === 'CLINIC_NOT_REGISTERED' ? (

미등록 병원

{errorDomain}

현재 분석 대상 병원 목록에 포함되지 않은 도메인입니다.

) : (

{error}

{errorDetails && (

Failed channels:

{Object.entries(errorDetails).map(([ch, err]) => (

• {ch}: {err}

))}
)}
) ) : ( <> {phase === 'resuming' ? (

Resuming analysis...

) : ( <>
{PHASE_STEPS.map((step, index) => { const isCompleted = displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete'); const isActive = displayPhaseIndex === index && phase !== 'complete'; return (
{isCompleted ? ( ) : isActive ? (
) : (
)}
{isCompleted ? step.labelDone : step.label} ); })}

AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.

)} )}
); }