- 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>
427 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|