o2o-infinith-demo/src/pages/AnalysisLoadingPage.tsx
Haewon Kam 938ebacbf9 feat: PART III 랜딩 피봇 — 다채널 URL 입력 + Strategic Planner 포지셔닝
- MultiChannelInput: URL 뭉치 붙여넣기 → classifyUrls로 7개 채널 자동 분류
  (homepage·YouTube·Instagram·Facebook·네이버플레이스·블로그·강남언니)
  · "Analyze" pill 버튼 복원 + variant별 색 분기 (hero=dark brand,
    cta=#fff3eb→#e4cfff→#f5f9ff warm 3-stop)
  · placeholder 중앙 정렬 + "한 줄씩" 규칙 제거 (유연 파싱 노출)

- Navbar: Free Report CTA 제거 → Login + 문의하기 (contact@o2o.kr) duo
- LoginPage: 계약 고객용 스캐폴딩 페이지 신규 추가
- PricingPage: 계약 기반 영업 반영, FAQ에서 해지·환불 항목 제거
  (세부 정책 미확정 → 후속 추가)

- Landing 카피 Strategic Planner 포지셔닝 피봇:
  · Hero sub: "10분 진단 → 12개월 전략 설계"
  · Solution AGDP: Audit / Generation / Direction / Planning 재해석
  · Modules: Intelligence + Planning Available, 나머지 Coming Soon 정직화
  · TargetAudience: 전략 파트너 / 전략 자문 + Partner Program 신청 waitlist
  · Problems: 콘텐츠 소진 / 경쟁사 분석 부재 / 데이터 부족 3축
  · UseCases: 진단·전략·KPI(Medical) · 수주·자문·포트폴리오(Agency)

- discover-channels Edge Function: manualChannels 수용 — 사용자 붙여넣은
  URL이 Firecrawl 스크래핑보다 우선, naverPlace/gangnamUnni 직접 주입

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 09:01:41 +09:00

427 lines
17 KiB
TypeScript

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<Phase>('discovering');
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<string | null>(null);
const [errorDomain, setErrorDomain] = useState<string | null>(null);
const [errorDetails, setErrorDetails] = useState<Record<string, string> | 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<string, unknown> | 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 (
<div className="relative min-h-screen bg-primary-900 flex flex-col items-center justify-center px-6 overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_rgba(79,29,161,0.25)_0%,_transparent_70%)]" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-purple-600/20 rounded-full blur-[120px] pointer-events-none" />
<div className="relative z-10 flex flex-col items-center w-full max-w-lg">
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-4xl md:text-5xl font-serif font-bold mb-4"
style={{
background: 'linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
INFINITH
</motion.h1>
{(url || loadSession().url) && (
<motion.p
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
className="text-purple-300/80 text-sm font-mono mb-12 truncate max-w-full"
>
{url || loadSession().url}
</motion.p>
)}
{error ? (
errorCode === 'CLINIC_NOT_REGISTERED' ? (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full p-6 rounded-2xl bg-purple-500/10 border border-purple-500/20 text-center"
>
<ShieldX className="w-10 h-10 text-purple-400 mx-auto mb-3" />
<p className="text-white text-lg font-medium mb-2">미등록 병원</p>
<p className="text-purple-300/80 text-sm mb-1">
<span className="font-mono text-purple-200">{errorDomain}</span>
</p>
<p className="text-purple-300/60 text-sm mb-6">
현재 분석 대상 병원 목록에 포함되지 않은 도메인입니다.
</p>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors"
>
돌아가기
</button>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full p-6 rounded-2xl bg-red-500/10 border border-red-500/20 text-center"
>
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 text-sm mb-4">{error}</p>
{errorDetails && (
<div className="mb-4 text-left bg-red-500/5 rounded-lg p-3">
<p className="text-red-400/60 text-xs font-mono mb-1">Failed channels:</p>
{Object.entries(errorDetails).map(([ch, err]) => (
<p key={ch} className="text-red-400/80 text-xs font-mono">
• {ch}: {err}
</p>
))}
</div>
)}
<div className="flex gap-3 justify-center">
<button
onClick={handleRetry}
className="px-6 py-2 text-sm font-medium text-white bg-purple-600/30 rounded-lg hover:bg-purple-600/50 transition-colors flex items-center gap-2"
>
<RefreshCw className="w-4 h-4" />
Retry
</button>
<button
onClick={() => { clearSession(); navigate('/', { replace: true }); }}
className="px-6 py-2 text-sm font-medium text-white bg-white/10 rounded-lg hover:bg-white/20 transition-colors"
>
Start Over
</button>
</div>
</motion.div>
)
) : (
<>
{phase === 'resuming' ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-4 mb-14"
>
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
<p className="text-purple-200 text-sm">Resuming analysis...</p>
</motion.div>
) : (
<>
<div className="w-full space-y-5 mb-14">
{PHASE_STEPS.map((step, index) => {
const isCompleted = displayPhaseIndex > index || (step.key === 'complete' && phase === 'complete');
const isActive = displayPhaseIndex === index && phase !== 'complete';
return (
<motion.div
key={step.key}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: isActive || isCompleted ? 1 : 0.3, x: 0 }}
transition={{ duration: 0.4, delay: index * 0.15 }}
className="flex items-center gap-4"
>
<div className="w-7 h-7 flex-shrink-0 flex items-center justify-center">
{isCompleted ? (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="w-7 h-7 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] flex items-center justify-center"
>
<Check className="w-4 h-4 text-white" strokeWidth={3} />
</motion.div>
) : isActive ? (
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
) : (
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
)}
</div>
<span
className={`text-base font-sans transition-colors duration-300 ${
isCompleted ? 'text-white' : isActive ? 'text-purple-200' : 'text-white/30'
}`}
>
{isCompleted ? step.labelDone : step.label}
</span>
</motion.div>
);
})}
</div>
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<motion.div
initial={{ width: '0%' }}
animate={{ width: `${((displayPhaseIndex + (phase === 'complete' ? 1 : 0.5)) / PHASE_STEPS.length) * 100}%` }}
transition={{ duration: 0.8, ease: 'easeInOut' }}
className="h-full bg-gradient-to-r from-[#4F1DA1] to-[#6C5CE7] rounded-full"
/>
</div>
<p className="text-white/40 text-xs mt-4">
AI가 마케팅 데이터를 분석하고 있습니다. 약 1~2분 소요됩니다.
</p>
</>
)}
</>
)}
</div>
</div>
);
}