feat(front): 진단 리포트에서 사이트 빌드로 이어지는 화면
예전 전략마케팅 툴은 분석 진행 화면에서 단계를 보여 주고 리포트로 넘어갔다. 지금 버전은 진단과 사이트 빌드가 서로 모르는 상태로 떨어져 있어, 진단을 읽은 병원이 다음에 무엇을 하는지 알 수 없었다. 진단 → 빌드 → 사진 확인 → 확인 항목을 한 줄로 잇는다. - /build/:id 를 추가한다. 예전 /report/loading 의 단계 UX(스피너·체크·진행바)를 잇되, 진행 상황을 프런트가 만들지 않고 supporter_builds 를 폴링해 워커가 쓴 값을 읽는다. 프런트가 파이프라인을 돌리지 않으므로 창을 닫아도 빌드는 계속되고, 재개 로직이 필요 없다. - 워커 17단계를 고객이 읽을 수 있는 5묶음으로 접는다(src/lib/buildPhases.ts). 진행률은 묶음이 아니라 끝난 워커 단계 수로 세어 한 묶음 안에서도 바가 움직인다. - 경고로 끝난 단계는 완료 화면에 "확인 대기로 발행된 부분"으로 남긴다. 자동으로 못 채운 값을 감추지 않는다. 멈춤은 발행 게이트와 실행 실패를 구분해 문구를 나눈다. - 진단 리포트의 Action Plan 뒤에 빌드 구간을 둔다. 무엇을 만들어 주는지와 버튼만 둔다. 버튼은 supporter_builds 에 queued 를 넣고 폴러가 집어 간다. - 상단 메뉴를 경로로 가른다. 병원 작업 경로에서는 마케팅 메뉴 대신 4단계 절차를 띄운다. Pricing·Use Cases 는 사진을 고르는 중인 병원에게 방해다. - 진단 데이터가 없는 병원(빌드만 한 곳)은 막다른 길 대신 빌드·사진 확인으로 잇는다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
74c70d656e
commit
be1ea72fcc
86
src/components/ClinicNav.tsx
Normal file
86
src/components/ClinicNav.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 병원 작업 공간 상단 메뉴.
|
||||||
|
*
|
||||||
|
* 마케팅 메뉴(Product·Pricing·Use Cases)는 진단을 받은 병원에게 쓸모가 없다.
|
||||||
|
* 이 화면들은 진단 → 빌드 → 사진 확인 → 확인 항목이라는 하나의 절차이므로,
|
||||||
|
* 상단을 그 절차로 바꾼다. 지금 어디에 있고 다음이 무엇인지가 한 줄에 보여야 한다.
|
||||||
|
*
|
||||||
|
* 단계의 완료 여부는 각 화면이 판단한다. 여기서는 위치만 표시한다.
|
||||||
|
*/
|
||||||
|
import { Link, useLocation } from 'react-router';
|
||||||
|
import { PrismFilled } from './icons/FilledIcons';
|
||||||
|
|
||||||
|
type Step = { key: string; label: string; to: (id: string) => string; match: RegExp };
|
||||||
|
|
||||||
|
const STEPS: Step[] = [
|
||||||
|
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
|
||||||
|
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
|
||||||
|
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
|
||||||
|
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 작업 공간 경로면 병원 id 를 돌려준다. 아니면 null. */
|
||||||
|
export function clinicIdFromPath(pathname: string): string | null {
|
||||||
|
for (const s of STEPS) {
|
||||||
|
if (s.match.test(pathname)) return pathname.split('/')[2] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClinicNav({ clinicId }: { clinicId: string }) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md" data-no-print>
|
||||||
|
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center gap-6">
|
||||||
|
{/* 로고 + 병원 */}
|
||||||
|
<Link to="/" className="flex items-center gap-3 shrink-0" aria-label="INFINITH 홈">
|
||||||
|
<PrismFilled size={22} className="text-[#6C5CE7]" />
|
||||||
|
<span className="hidden sm:block font-serif text-xl font-black tracking-[0.05em] bg-gradient-to-r from-[#4F1DA1] to-[#021341] bg-clip-text text-transparent">
|
||||||
|
INFINITH
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span className="hidden md:block w-px h-7 bg-slate-200 shrink-0" />
|
||||||
|
|
||||||
|
<span className="hidden md:block text-sm font-semibold text-slate-500 shrink-0 max-w-[160px] truncate" title={clinicId}>
|
||||||
|
{clinicId}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 단계 */}
|
||||||
|
<ol className="flex items-center gap-1 overflow-x-auto ml-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
|
{STEPS.map((s, i) => {
|
||||||
|
const isCurrent = i === currentIndex;
|
||||||
|
const isPast = currentIndex > -1 && i < currentIndex;
|
||||||
|
return (
|
||||||
|
<li key={s.key} className="flex items-center shrink-0">
|
||||||
|
{i > 0 && <span className="w-4 h-px bg-slate-200 mx-0.5" aria-hidden="true" />}
|
||||||
|
<Link
|
||||||
|
to={s.to(clinicId)}
|
||||||
|
aria-current={isCurrent ? 'page' : undefined}
|
||||||
|
className={`group inline-flex items-center gap-2 rounded-full pl-2 pr-3.5 py-2 text-sm font-semibold transition-colors
|
||||||
|
${isCurrent
|
||||||
|
? 'bg-[#F3F0FF] text-[#4A3A7C]'
|
||||||
|
: 'text-slate-500 hover:bg-slate-100 hover:text-[#1D0024]'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 transition-colors
|
||||||
|
${isCurrent
|
||||||
|
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
|
||||||
|
: isPast
|
||||||
|
? 'bg-[#D5CDF5] text-[#4A3A7C]'
|
||||||
|
: 'bg-slate-100 text-slate-400 group-hover:bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-nowrap">{s.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,11 +18,17 @@
|
|||||||
* 예컨대 `/pricing`에서 `#solution`을 누르면 /pricing 페이지 내의 #solution을 찾다가 실패합니다.
|
* 예컨대 `/pricing`에서 `#solution`을 누르면 /pricing 페이지 내의 #solution을 찾다가 실패합니다.
|
||||||
* `/#solution`은 React Router가 홈으로 이동시킨 뒤 브라우저가 해시 스크롤을 처리합니다.
|
* `/#solution`은 React Router가 홈으로 이동시킨 뒤 브라우저가 해시 스크롤을 처리합니다.
|
||||||
*/
|
*/
|
||||||
import { Link } from 'react-router';
|
import { Link, useLocation } from 'react-router';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { buildContactMailto } from '../lib/contact';
|
import { buildContactMailto } from '../lib/contact';
|
||||||
|
import ClinicNav, { clinicIdFromPath } from './ClinicNav';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
// 병원 작업 공간(진단·빌드·사진 확인·확인 항목)에서는 절차 메뉴로 바꾼다.
|
||||||
|
const clinicId = clinicIdFromPath(pathname);
|
||||||
|
if (clinicId) return <ClinicNav clinicId={clinicId} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md">
|
<nav className="fixed top-0 left-0 right-0 z-50 bg-white/95 border-b border-slate-100 backdrop-blur-md">
|
||||||
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
|
||||||
|
|||||||
156
src/components/discovery/BuildSiteCta.tsx
Normal file
156
src/components/discovery/BuildSiteCta.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* 진단 리포트 → 사이트 빌드로 넘어가는 구간.
|
||||||
|
*
|
||||||
|
* Action Plan 바로 다음에 둔다. 진단에서 무엇이 나왔는지 이미 읽은 상태이므로,
|
||||||
|
* 여기서는 "그래서 무엇을 만들어 주는가"와 버튼 하나만 둔다.
|
||||||
|
* 버튼은 supporter_builds 에 queued 를 넣고 /build/:clinicId 로 보낸다. 폴러가 집어 간다.
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { supabase } from '../../lib/supabase';
|
||||||
|
import { STATE_LABEL } from '../../lib/buildPhases';
|
||||||
|
import type { BuildState } from '../../lib/buildPhases';
|
||||||
|
import { FileTextFilled, ShieldFilled, MapPinFilled, CalendarFilled, VideoFilled } from '../icons/FilledIcons';
|
||||||
|
// CTA 버튼의 ArrowRight 는 라인 아이콘 금지 규칙의 유일한 예외다(디자인 시스템).
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
clinicId: string;
|
||||||
|
clinicName: string;
|
||||||
|
clinicUrl?: string;
|
||||||
|
/** 진단에서 나온 실행 항목 수. 맥락으로만 쓴다. */
|
||||||
|
actionCounts?: { p0: number; p1: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const OUTPUTS = [
|
||||||
|
{ icon: FileTextFilled, title: '질문에 답하는 글', detail: '병원을 알아보는 사람이 실제로 묻는 질문을 골라 공개 자료를 근거로 답합니다.' },
|
||||||
|
{ icon: ShieldFilled, title: '의료진과 안전 근거', detail: '홈페이지에 흩어져 있는 의료진 이력과 안전 시스템을 한자리에 모읍니다.' },
|
||||||
|
{ icon: VideoFilled, title: '원장 설명 영상과 뉴스룸', detail: '유튜브 설명 영상과 언론 보도를 글의 근거로 연결합니다.' },
|
||||||
|
{ icon: CalendarFilled, title: '회복 일정 안내', detail: '시술별 회복 기간을 병원 원문 기준으로 정리합니다.' },
|
||||||
|
{ icon: MapPinFilled, title: '오시는 길과 주변 안내', detail: '병원 위치를 기준으로 머무는 동안 필요한 정보를 붙입니다.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BuildSiteCta({ clinicId, clinicName, clinicUrl, actionCounts }: Props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [existing, setExisting] = useState<{ status: BuildState; preview_url: string | null } | null>(null);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
void (async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('supporter_builds')
|
||||||
|
.select('status,preview_url')
|
||||||
|
.eq('clinic_id', clinicId)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
if (!alive) return;
|
||||||
|
setExisting((data?.[0] as { status: BuildState; preview_url: string | null }) ?? null);
|
||||||
|
setChecking(false);
|
||||||
|
})();
|
||||||
|
return () => { alive = false; };
|
||||||
|
}, [clinicId]);
|
||||||
|
|
||||||
|
async function startBuild() {
|
||||||
|
setStarting(true);
|
||||||
|
setError(null);
|
||||||
|
const { error: e } = await supabase.from('supporter_builds').insert({
|
||||||
|
clinic_id: clinicId,
|
||||||
|
clinic_name: clinicName,
|
||||||
|
url: clinicUrl ?? '',
|
||||||
|
status: 'queued',
|
||||||
|
});
|
||||||
|
setStarting(false);
|
||||||
|
if (e) { setError(e.message); return; }
|
||||||
|
navigate(`/build/${clinicId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBuild = Boolean(existing);
|
||||||
|
const inFlight = existing?.status === 'queued' || existing?.status === 'running';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden py-16 md:py-20 px-6" data-no-print>
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,rgba(108,92,231,0.16),transparent_60%)]" />
|
||||||
|
<div className="relative max-w-7xl mx-auto">
|
||||||
|
<div className="max-w-[62ch] mb-10">
|
||||||
|
<h2 className="font-serif text-3xl md:text-5xl font-bold tracking-[-0.02em] mb-4">
|
||||||
|
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">Build the Site</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-lg text-purple-200 mb-3">진단에서 나온 것을 사이트로 옮깁니다</p>
|
||||||
|
<p className="ui-body">
|
||||||
|
AI가 병원을 설명할 때 근거로 삼을 페이지를 만듭니다.{' '}
|
||||||
|
홈페이지 공개 자료만 쓰고, 없는 값은 지어내지 않고 확인 대기로 둡니다.
|
||||||
|
{actionCounts && (actionCounts.p0 + actionCounts.p1 > 0) && (
|
||||||
|
<> 위 실행 항목 가운데 콘텐츠와 구조에 해당하는 것(P0 {actionCounts.p0}건 · P1 {actionCounts.p1}건)이 이 단계에서 채워집니다.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4 mb-10">
|
||||||
|
{OUTPUTS.map((o, i) => {
|
||||||
|
const Icon = o.icon;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={o.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.07 }}
|
||||||
|
className="on-light bg-white rounded-2xl p-5 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]"
|
||||||
|
>
|
||||||
|
<Icon size={20} className="text-[#6C5CE7] mb-3" />
|
||||||
|
<h3 className="font-bold text-[#1D0024] mb-1.5">{o.title}</h3>
|
||||||
|
<p className="ui-body">{o.detail}</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
{checking ? (
|
||||||
|
<span className="text-white/50 text-base">빌드 상태를 확인하는 중입니다.</span>
|
||||||
|
) : hasBuild ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`/build/${clinicId}`)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base"
|
||||||
|
>
|
||||||
|
{inFlight ? '진행 상황 보기' : '빌드 결과 보기'} <ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-purple-200 text-base">{STATE_LABEL[existing!.status]}</span>
|
||||||
|
{!inFlight && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void startBuild()}
|
||||||
|
disabled={starting}
|
||||||
|
className="rounded-full bg-white/10 border border-white/15 text-white font-semibold px-6 py-4 text-base disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{starting ? '시작하는 중' : '다시 빌드'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void startBuild()}
|
||||||
|
disabled={starting}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{starting ? '시작하는 중' : '사이트로 빌드'} <ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-[#F5D5DC] text-base mt-4">시작하지 못했습니다. {error}</p>}
|
||||||
|
<p className="ui-note mt-5">
|
||||||
|
만들어진 사이트는 검색에 노출되지 않는 상태로 올라갑니다.{' '}
|
||||||
|
공개 여부는 병원이 확인한 뒤에 정합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/lib/buildPhases.ts
Normal file
120
src/lib/buildPhases.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* 서포터즈 빌드 17단계를 고객이 읽을 수 있는 5묶음으로 옮긴다.
|
||||||
|
*
|
||||||
|
* 워커(workers/supporters-build/run.mjs)의 PHASES 와 순서가 같아야 한다.
|
||||||
|
* 워커가 단계를 늘리면 여기 STEPS 의 phases 에도 넣는다. 매핑에 없는 단계는
|
||||||
|
* 진행률 계산에서 빠지므로, 모르는 값이 와도 화면이 멈추지는 않는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 워커가 supporter_builds.phase 에 쓰는 값 */
|
||||||
|
export type WorkerPhase =
|
||||||
|
| 'evidence' | 'ocr' | 'data' | 'discover' | 'youtube' | 'news' | 'images'
|
||||||
|
| 'tourism' | 'recovery' | 'planner' | 'inputs' | 'briefs' | 'generate'
|
||||||
|
| 'build' | 'deploy' | 'publish' | 'done';
|
||||||
|
|
||||||
|
export type PhaseRow = { phase: string; status: 'running' | 'ok' | 'warning' | 'failed'; summary?: string };
|
||||||
|
|
||||||
|
export type BuildStep = {
|
||||||
|
key: string;
|
||||||
|
/** 진행 중일 때 문구 */
|
||||||
|
label: string;
|
||||||
|
/** 끝났을 때 문구 */
|
||||||
|
labelDone: string;
|
||||||
|
/** 무엇을 하는지 한 줄 */
|
||||||
|
detail: string;
|
||||||
|
phases: WorkerPhase[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUILD_STEPS: BuildStep[] = [
|
||||||
|
{
|
||||||
|
key: 'collect',
|
||||||
|
label: '홈페이지를 읽는 중입니다',
|
||||||
|
labelDone: '홈페이지 자료 확보',
|
||||||
|
detail: '공개된 페이지와 이미지 속 글자까지 읽어 병원 정보와 의료진을 정리합니다.',
|
||||||
|
phases: ['evidence', 'ocr', 'data'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sources',
|
||||||
|
label: '채널과 근거를 모으는 중입니다',
|
||||||
|
labelDone: '채널과 근거 확보',
|
||||||
|
detail: '유튜브·인스타그램·블로그 채널을 찾고, 기사와 사진을 근거로 모읍니다.',
|
||||||
|
phases: ['discover', 'youtube', 'news', 'images'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'guides',
|
||||||
|
label: '방문·회복 안내를 만드는 중입니다',
|
||||||
|
labelDone: '방문·회복 안내 완성',
|
||||||
|
detail: '오시는 길, 회복 일정, 머무는 동안의 주변 안내를 병원 위치 기준으로 만듭니다.',
|
||||||
|
phases: ['tourism', 'recovery', 'planner'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'write',
|
||||||
|
label: '질문에 답하는 글을 쓰는 중입니다',
|
||||||
|
labelDone: '글 작성 완료',
|
||||||
|
detail: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 공개 자료를 근거로 답합니다.',
|
||||||
|
phases: ['inputs', 'briefs', 'generate'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'publish',
|
||||||
|
label: '사이트를 만들고 올리는 중입니다',
|
||||||
|
labelDone: '사이트 준비 완료',
|
||||||
|
detail: '발행 기준을 검사한 뒤 사이트를 만들어 올립니다. 검색 노출은 확인을 마친 뒤에 켭니다.',
|
||||||
|
phases: ['build', 'deploy', 'publish', 'done'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PHASE_TO_STEP = new Map<string, number>();
|
||||||
|
BUILD_STEPS.forEach((s, i) => s.phases.forEach((p) => PHASE_TO_STEP.set(p, i)));
|
||||||
|
|
||||||
|
/** 전체 워커 단계 수. 진행률을 단계 묶음이 아니라 실제 단계 기준으로 센다. */
|
||||||
|
const TOTAL_PHASES = BUILD_STEPS.reduce((n, s) => n + s.phases.length, 0);
|
||||||
|
|
||||||
|
export type BuildState = 'queued' | 'running' | 'gate_failed' | 'failed' | 'preview' | 'published' | 'published_pending_tasks' | 'approved';
|
||||||
|
|
||||||
|
/** 지금 어느 묶음에 있는지. 모르는 단계면 -1. */
|
||||||
|
export function stepIndexOf(phase: string | null | undefined): number {
|
||||||
|
if (!phase) return -1;
|
||||||
|
return PHASE_TO_STEP.get(phase) ?? -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 끝난 워커 단계 수 기준 진행률 0~1 */
|
||||||
|
export function progressOf(phases: PhaseRow[] | null | undefined, state: BuildState): number {
|
||||||
|
if (state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved') return 1;
|
||||||
|
if (!phases?.length) return 0;
|
||||||
|
const done = phases.filter((p) => (p.status === 'ok' || p.status === 'warning') && PHASE_TO_STEP.has(p.phase)).length;
|
||||||
|
return Math.min(done / TOTAL_PHASES, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 묶음별 상태. 화면에서 체크·스피너·대기를 고르는 데 쓴다. */
|
||||||
|
export type StepState = 'done' | 'active' | 'waiting' | 'failed';
|
||||||
|
|
||||||
|
export function stepStates(phases: PhaseRow[] | null | undefined, phase: string | null, state: BuildState): StepState[] {
|
||||||
|
const finished = state === 'preview' || state === 'published' || state === 'published_pending_tasks' || state === 'approved';
|
||||||
|
const cur = stepIndexOf(phase);
|
||||||
|
const rows = new Map((phases ?? []).map((p) => [p.phase, p.status]));
|
||||||
|
return BUILD_STEPS.map((s, i) => {
|
||||||
|
if (finished) return 'done';
|
||||||
|
if ((state === 'failed' || state === 'gate_failed') && i === cur) return 'failed';
|
||||||
|
const allDone = s.phases.every((p) => { const st = rows.get(p); return st === 'ok' || st === 'warning'; });
|
||||||
|
if (allDone) return 'done';
|
||||||
|
if (i === cur) return 'active';
|
||||||
|
if (cur > i) return 'done';
|
||||||
|
return 'waiting';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 경고가 남은 단계. 완료 화면에서 "확인 대기"로 보여 준다. */
|
||||||
|
export function warningsOf(phases: PhaseRow[] | null | undefined): PhaseRow[] {
|
||||||
|
return (phases ?? []).filter((p) => p.status === 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATE_LABEL: Record<BuildState, string> = {
|
||||||
|
queued: '차례를 기다리는 중입니다',
|
||||||
|
running: '만드는 중입니다',
|
||||||
|
gate_failed: '발행 기준에서 멈췄습니다',
|
||||||
|
failed: '만들다가 멈췄습니다',
|
||||||
|
preview: '준비됐습니다',
|
||||||
|
approved: '승인 완료',
|
||||||
|
published: '검색 노출까지 켜졌습니다',
|
||||||
|
published_pending_tasks: '발행됐고 남은 확인이 있습니다',
|
||||||
|
};
|
||||||
@ -19,6 +19,7 @@ import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
|
|||||||
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
|
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
|
||||||
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
|
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
|
||||||
import ImageReviewPage from './pages/ImageReviewPage.tsx';
|
import ImageReviewPage from './pages/ImageReviewPage.tsx';
|
||||||
|
import SiteBuildPage from './pages/SiteBuildPage.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
|
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
|
||||||
@ -50,6 +51,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
||||||
<Route path="supporters/:id" element={<SupportersBuildPage />} />
|
<Route path="supporters/:id" element={<SupportersBuildPage />} />
|
||||||
<Route path="images/:id" element={<ImageReviewPage />} />
|
<Route path="images/:id" element={<ImageReviewPage />} />
|
||||||
|
<Route path="build/:id" element={<SiteBuildPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
|||||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||||
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
||||||
|
import { BuildSiteCta } from '../components/discovery/BuildSiteCta';
|
||||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||||
import type {
|
import type {
|
||||||
@ -87,13 +88,10 @@ export default function DiscoveryReportPage() {
|
|||||||
[result],
|
[result],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
|
||||||
|
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
|
||||||
if (!result || !overall) {
|
if (!result || !overall) {
|
||||||
return (
|
return <NoReport clinicId={id ?? ''} />;
|
||||||
<div className="pt-36 pb-24 px-6 text-center">
|
|
||||||
<p className="text-slate-600">진단 결과를 찾을 수 없습니다.</p>
|
|
||||||
<Link to="/" className="text-[#6C5CE7] text-base mt-4 inline-block">홈으로</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
|
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
|
||||||
@ -101,7 +99,7 @@ export default function DiscoveryReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div data-report-content className="[word-break:keep-all]">
|
<div data-report-content className="[word-break:keep-all]">
|
||||||
{/* ── 1. 헤더 (dark) ── */}
|
{/* ── 1. 헤더 (dark) ── */}
|
||||||
<section className="bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
|
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
||||||
<div className="relative max-w-7xl mx-auto grid lg:grid-cols-[1fr_auto] gap-12 items-center">
|
<div className="relative max-w-7xl mx-auto grid lg:grid-cols-[1fr_auto] gap-12 items-center">
|
||||||
<div>
|
<div>
|
||||||
@ -132,19 +130,19 @@ export default function DiscoveryReportPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
|
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-8 flex flex-col items-center gap-4 min-w-[260px]">
|
||||||
<div className="flex items-start gap-8">
|
<div className="flex items-start gap-8">
|
||||||
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
|
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
|
||||||
<div key={ax.axis.id} className="flex flex-col items-center gap-2">
|
<div key={ax.axis.id} className="flex flex-col items-center gap-2">
|
||||||
<ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
|
<ScoreRing score={ax.score} size={130} label={ax.axis.nameEn} />
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
|
<span className="font-serif text-3xl font-black text-[#0A1128]">{ax.grade}</span>
|
||||||
<span className="text-sm text-slate-500">{ax.axis.code} {ax.axis.name}</span>
|
<span className="ui-note">{ax.axis.code} {ax.axis.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="ui-note">
|
||||||
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
|
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -175,13 +173,13 @@ export default function DiscoveryReportPage() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ delay: i * 0.08 }}
|
transition={{ delay: i * 0.08 }}
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
className="on-light bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-4 mb-3">
|
<div className="flex items-start justify-between gap-4 mb-3">
|
||||||
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
|
<h3 className="text-xl font-bold text-[#0A1128]">{f.title}</h3>
|
||||||
<FindingIcon severity={f.severity} />
|
<FindingIcon severity={f.severity} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-slate-600 leading-relaxed">{f.detail}</p>
|
<p className="ui-body">{f.detail}</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -201,7 +199,7 @@ export default function DiscoveryReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={cs.category.id}
|
key={cs.category.id}
|
||||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
|
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]"
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
<div className="flex flex-wrap items-center justify-between gap-4 mb-5">
|
||||||
<div>
|
<div>
|
||||||
@ -217,7 +215,7 @@ export default function DiscoveryReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm text-slate-400">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
|
<p className="ui-note">배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}</p>
|
||||||
<p className="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
|
<p className="text-base font-semibold">{cs.earned} / {cs.possible}점</p>
|
||||||
</div>
|
</div>
|
||||||
<ScoreRing score={cs.pct} size={64} />
|
<ScoreRing score={cs.pct} size={64} />
|
||||||
@ -234,14 +232,14 @@ export default function DiscoveryReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
|
<div key={c.id} className="py-4 grid md:grid-cols-[170px_1fr_auto] gap-3 items-start">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-semibold text-slate-400">{c.id}</span>
|
<span className="ui-note font-semibold">{c.id}</span>
|
||||||
<p className="text-base font-bold">{c.name}</p>
|
<p className="text-base font-bold">{c.name}</p>
|
||||||
<p className="text-sm text-slate-400 mt-0.5">{c.weight}점</p>
|
<p className="ui-note mt-0.5">{c.weight}점</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-base text-slate-700">{r?.evidence}</p>
|
<p className="ui-body">{r?.evidence}</p>
|
||||||
{r?.source && (
|
{r?.source && (
|
||||||
<p className="text-sm text-slate-400 mt-1 font-mono break-all">{r.source}</p>
|
<p className="ui-note mt-1 font-mono break-all">{r.source}</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
|
<Tag>{EVIDENCE_LABEL[c.evidenceGrade]}</Tag>
|
||||||
@ -252,7 +250,7 @@ export default function DiscoveryReportPage() {
|
|||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
|
<SeverityBadge severity={levelToSeverity(level)} label={levelLabel} className="text-base font-bold px-4 py-1.5" />
|
||||||
{level !== 'unverified' && (
|
{level !== 'unverified' && (
|
||||||
<span className="text-sm text-slate-400">
|
<span className="ui-note">
|
||||||
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
|
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -292,13 +290,13 @@ export default function DiscoveryReportPage() {
|
|||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
transition={{ delay: i * 0.08 }}
|
transition={{ delay: i * 0.08 }}
|
||||||
className="bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
|
className="on-light bg-white rounded-2xl border border-slate-100 shadow-sm p-5"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
|
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
|
||||||
<span className="text-sm text-slate-400 shrink-0">{a.effort}</span>
|
<span className="ui-note shrink-0">{a.effort}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base text-slate-600 leading-relaxed mb-3">{a.detail}</p>
|
<p className="ui-body mb-3">{a.detail}</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{a.criterionIds.map((cid) => (
|
{a.criterionIds.map((cid) => (
|
||||||
<Tag key={cid}>{cid}</Tag>
|
<Tag key={cid}>{cid}</Tag>
|
||||||
@ -312,6 +310,17 @@ export default function DiscoveryReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
|
|
||||||
|
{/* ── 4b. 사이트로 빌드 (dark) ── */}
|
||||||
|
<BuildSiteCta
|
||||||
|
clinicId={result.id}
|
||||||
|
clinicName={result.clinicName}
|
||||||
|
clinicUrl={result.url}
|
||||||
|
actionCounts={{
|
||||||
|
p0: result.actions.filter((a) => a.priority === 'P0').length,
|
||||||
|
p1: result.actions.filter((a) => a.priority === 'P1').length,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
|
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
|
||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
id="integrity"
|
id="integrity"
|
||||||
@ -320,7 +329,7 @@ export default function DiscoveryReportPage() {
|
|||||||
dark
|
dark
|
||||||
>
|
>
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
||||||
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">하지 않을 것</h3>
|
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">하지 않을 것</h3>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{result.notDoing.map((n) => (
|
{result.notDoing.map((n) => (
|
||||||
@ -328,19 +337,19 @@ export default function DiscoveryReportPage() {
|
|||||||
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
|
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-base font-semibold">{n.item}</p>
|
<p className="text-base font-semibold">{n.item}</p>
|
||||||
<p className="text-sm text-slate-500 mt-0.5">{n.reason}</p>
|
<p className="ui-note mt-0.5">{n.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
<div className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6 text-[#0A1128]">
|
||||||
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">측정 조건</h3>
|
<h3 className="text-xl font-bold mb-4 font-sans text-[#0A1128]!">측정 조건</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{result.conditions.map((c) => (
|
{result.conditions.map((c) => (
|
||||||
<li key={c} className="flex gap-3">
|
<li key={c} className="flex gap-3">
|
||||||
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
|
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
|
||||||
<p className="text-base text-slate-600">{c}</p>
|
<p className="ui-body">{c}</p>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -364,9 +373,9 @@ export default function DiscoveryReportPage() {
|
|||||||
title="Scoring Rubric"
|
title="Scoring Rubric"
|
||||||
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
|
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
|
||||||
>
|
>
|
||||||
<div className="overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
|
<div className="on-light overflow-x-auto rounded-2xl border border-slate-100 shadow-sm bg-white">
|
||||||
<table className="w-full text-base min-w-[860px]">
|
<table className="w-full text-base min-w-[860px]">
|
||||||
<thead className="bg-[#0A1128] text-white">
|
<thead className="on-dark bg-[#0A1128] text-white">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
|
<th className="text-left px-4 py-3 font-semibold w-16">ID</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold">항목</th>
|
<th className="text-left px-4 py-3 font-semibold">항목</th>
|
||||||
@ -395,6 +404,50 @@ export default function DiscoveryReportPage() {
|
|||||||
|
|
||||||
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
|
||||||
|
|
||||||
|
/** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */
|
||||||
|
function NoReport({ clinicId }: { clinicId: string }) {
|
||||||
|
return (
|
||||||
|
<div className="[word-break:keep-all]">
|
||||||
|
<section className="on-dark bg-[#0A1128] text-white relative overflow-hidden pt-28 pb-16 md:pt-36 md:pb-20 px-6">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
||||||
|
<div className="relative max-w-3xl mx-auto">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-6">
|
||||||
|
<PrismFilled size={16} className="text-purple-300" />
|
||||||
|
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-4">
|
||||||
|
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">No Audit Yet</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-2xl font-bold mb-3">{clinicId}</p>
|
||||||
|
<p className="ui-body">
|
||||||
|
이 병원은 아직 AEO/GEO 진단을 받지 않았습니다.{' '}
|
||||||
|
진단은 36개 항목을 실제로 물어보고 답변을 확인해 매기는 별도 작업이라, 사이트 빌드만으로는 만들어지지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white py-16 md:py-20 px-6">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
<p className="ui-ask ui-ask-block mb-8">진단 없이도 아래는 바로 보실 수 있습니다.</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
|
<Link to={`/build/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||||
|
<p className="font-bold text-[#1D0024] mb-1">사이트 빌드 결과</p>
|
||||||
|
<p className="ui-note">만들어진 글과 안내 페이지를 봅니다.</p>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/images/${clinicId}`} className="rounded-2xl border border-slate-200 p-5 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||||
|
<p className="font-bold text-[#1D0024] mb-1">사진 확인</p>
|
||||||
|
<p className="ui-note">사이트에 실을 사진을 고릅니다.</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="ui-note mt-8">
|
||||||
|
진단 리포트 예시는 <Link to="/discovery/viewclinic" className="underline underline-offset-4 text-[#4A3A7C]">뷰성형외과 리포트</Link>에서 보실 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
|
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
|
||||||
const Icon = CATEGORY_ICON[cs.category.id];
|
const Icon = CATEGORY_ICON[cs.category.id];
|
||||||
return (
|
return (
|
||||||
@ -403,14 +456,14 @@ function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: nu
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.2 + index * 0.06 }}
|
transition={{ delay: 0.2 + index * 0.06 }}
|
||||||
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
|
className="on-light bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow p-5 text-[#0A1128]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
|
<span className="text-[#6C5CE7]"><Icon size={18} /></span>
|
||||||
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
|
<span className="text-base font-bold">{cs.category.code}. {cs.category.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-slate-400">{cs.weight}점</span>
|
<span className="ui-note">{cs.weight}점</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
<div className="h-2 rounded-full bg-slate-100 overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -458,8 +511,8 @@ function RubricRows({ categoryName, criteria }: { key?: string; categoryName: st
|
|||||||
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
|
<td className="px-4 py-3 font-semibold text-slate-500">{c.id}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="font-semibold text-[#0A1128]">{c.name}</p>
|
<p className="font-semibold text-[#0A1128]">{c.name}</p>
|
||||||
<p className="text-sm text-slate-500 mt-1 leading-relaxed">{c.rationale}</p>
|
<p className="ui-note mt-1">{c.rationale}</p>
|
||||||
<p className="text-sm text-slate-400 mt-1 font-mono">{c.howToCheck}</p>
|
<p className="ui-note mt-1 font-mono">{c.howToCheck}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-semibold">{c.weight}</td>
|
<td className="px-4 py-3 font-semibold">{c.weight}</td>
|
||||||
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>
|
<td className="px-4 py-3 text-slate-600">{EVIDENCE_LABEL[c.evidenceGrade]}</td>
|
||||||
|
|||||||
241
src/pages/SiteBuildPage.tsx
Normal file
241
src/pages/SiteBuildPage.tsx
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
/**
|
||||||
|
* /build/:id — 진단 리포트에서 "사이트로 빌드"를 누른 뒤 보는 진행 화면.
|
||||||
|
*
|
||||||
|
* 예전 전략마케팅 툴의 분석 진행 화면(/report/loading)의 단계 UX를 그대로 이어받되,
|
||||||
|
* 진행 상황을 프런트가 직접 만들지 않고 supporter_builds 를 폴링해 워커가 쓴 값을 읽는다.
|
||||||
|
* 프런트가 파이프라인을 돌리지 않으므로 창을 닫아도 빌드는 계속된다.
|
||||||
|
*/
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router';
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { BUILD_STEPS, STATE_LABEL, progressOf, stepStates, warningsOf } from '../lib/buildPhases';
|
||||||
|
import type { BuildState, PhaseRow } from '../lib/buildPhases';
|
||||||
|
import { CheckFilled, WarningFilled, EyeFilled, PrismFilled } from '../components/icons/FilledIcons';
|
||||||
|
|
||||||
|
type Build = {
|
||||||
|
id: string;
|
||||||
|
clinic_id: string;
|
||||||
|
clinic_name: string | null;
|
||||||
|
status: BuildState;
|
||||||
|
phase: string | null;
|
||||||
|
phases: PhaseRow[] | null;
|
||||||
|
posts: Array<{ id: string; title: string }> | null;
|
||||||
|
preview_url: string | null;
|
||||||
|
error: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const POLL_MS = 4000;
|
||||||
|
|
||||||
|
export default function SiteBuildPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const [build, setBuild] = useState<Build | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const timer = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
let alive = true;
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('supporter_builds')
|
||||||
|
.select('id,clinic_id,clinic_name,status,phase,phases,posts,preview_url,error,updated_at')
|
||||||
|
.eq('clinic_id', id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
if (!alive) return;
|
||||||
|
if (error) setLoadError(error.message);
|
||||||
|
const row = (data?.[0] as Build) ?? null;
|
||||||
|
setBuild(row);
|
||||||
|
setLoading(false);
|
||||||
|
// 끝났으면 더 부르지 않는다.
|
||||||
|
const done = row && ['preview', 'published', 'published_pending_tasks', 'approved', 'failed', 'gate_failed'].includes(row.status);
|
||||||
|
if (!done) timer.current = window.setTimeout(tick, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
return () => { alive = false; if (timer.current) window.clearTimeout(timer.current); };
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const state: BuildState = build?.status ?? 'queued';
|
||||||
|
const finished = ['preview', 'published', 'published_pending_tasks', 'approved'].includes(state);
|
||||||
|
const stopped = state === 'failed' || state === 'gate_failed';
|
||||||
|
const states = stepStates(build?.phases, build?.phase ?? null, state);
|
||||||
|
const pct = Math.round(progressOf(build?.phases, state) * 100);
|
||||||
|
const warnings = warningsOf(build?.phases);
|
||||||
|
const name = build?.clinic_name || id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="on-dark min-h-screen bg-[#0A1128] text-white relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,rgba(108,92,231,0.18),transparent_60%)]" />
|
||||||
|
|
||||||
|
<div className="relative max-w-3xl mx-auto px-6 pt-28 pb-24 md:pt-36">
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-full bg-white/10 border border-white/10 px-4 py-1.5 mb-8">
|
||||||
|
<PrismFilled size={16} className="text-purple-300" />
|
||||||
|
<span className="text-sm font-semibold tracking-wide text-purple-200">INFINITH AI Discovery · Site Build</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="font-serif text-4xl md:text-5xl font-bold tracking-[-0.02em] mb-3">
|
||||||
|
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
|
||||||
|
{finished ? 'Your Site Is Ready' : 'Building Your Site'}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-2xl font-bold mb-1">{name}</p>
|
||||||
|
<p className="text-purple-200 text-base mb-12">{STATE_LABEL[state]}</p>
|
||||||
|
|
||||||
|
{loading && <p className="text-white/50">상태를 불러오는 중입니다.</p>}
|
||||||
|
|
||||||
|
{!loading && !build && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-8">
|
||||||
|
<h2 className="text-lg font-bold mb-2">아직 빌드가 시작되지 않았습니다</h2>
|
||||||
|
<p className="text-white/60 text-base leading-[1.75]">
|
||||||
|
{loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}
|
||||||
|
</p>
|
||||||
|
<Link to={`/discovery/${id}`} className="inline-block mt-6 rounded-full bg-white/10 px-6 py-3 text-base font-semibold">
|
||||||
|
진단 리포트로 가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && build && (
|
||||||
|
<>
|
||||||
|
{/* 단계 */}
|
||||||
|
<div className="space-y-6 mb-12">
|
||||||
|
{BUILD_STEPS.map((step, i) => {
|
||||||
|
const st = states[i];
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={step.key}
|
||||||
|
initial={{ opacity: 0, x: -16 }}
|
||||||
|
animate={{ opacity: st === 'waiting' ? 0.35 : 1, x: 0 }}
|
||||||
|
transition={{ duration: 0.4, delay: i * 0.08 }}
|
||||||
|
className="flex items-start gap-4"
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 shrink-0 flex items-center justify-center mt-0.5">
|
||||||
|
{st === 'done' ? (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<CheckFilled size={14} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
) : st === 'active' ? (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-purple-400 border-t-transparent animate-spin" />
|
||||||
|
) : st === 'failed' ? (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-[#FFF0F0] flex items-center justify-center">
|
||||||
|
<WarningFilled size={14} className="text-[#7C3A4B]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full border-2 border-white/10" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className={`text-base font-semibold ${st === 'done' ? 'text-white' : st === 'active' ? 'text-purple-200' : st === 'failed' ? 'text-[#F5D5DC]' : 'text-white/40'}`}>
|
||||||
|
{st === 'done' ? step.labelDone : step.label}
|
||||||
|
</p>
|
||||||
|
<p className="ui-note mt-0.5">{step.detail}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 진행바 */}
|
||||||
|
<div className="w-full h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
initial={{ width: '0%' }}
|
||||||
|
animate={{ width: `${pct}%` }}
|
||||||
|
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-sm mt-4">
|
||||||
|
{finished
|
||||||
|
? `${build.posts?.length ?? 0}편의 글과 안내 페이지를 만들었습니다.`
|
||||||
|
: '창을 닫으셔도 계속 진행됩니다. 보통 10분 안팎 걸립니다.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 멈춤 */}
|
||||||
|
{stopped && (
|
||||||
|
<div className="mt-10 bg-white/5 border border-[#F5D5DC]/30 rounded-2xl p-6">
|
||||||
|
<h2 className="text-lg font-bold mb-2 text-[#F5D5DC]">
|
||||||
|
{state === 'gate_failed' ? '발행 기준에서 멈췄습니다' : '만들다가 멈췄습니다'}
|
||||||
|
</h2>
|
||||||
|
<p className="ui-body">
|
||||||
|
{state === 'gate_failed'
|
||||||
|
? '사이트에 실으면 안 되는 내용이 걸려 배포하지 않고 멈췄습니다. 담당자가 확인한 뒤 다시 진행합니다.'
|
||||||
|
: '자동으로 처리하지 못한 지점이 있습니다. 담당자가 확인한 뒤 다시 진행합니다.'}
|
||||||
|
</p>
|
||||||
|
{build.error && <p className="text-white/40 text-sm mt-3 font-mono break-all">{build.error.slice(0, 300)}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 완료 */}
|
||||||
|
{finished && (
|
||||||
|
<div className="mt-12 grid gap-4">
|
||||||
|
<div className="on-light bg-white rounded-2xl p-6 md:p-7 shadow-[3px_4px_12px_rgba(0,0,0,0.2)]">
|
||||||
|
<h2 className="ui-ask text-lg mb-2">다음은 병원에서 확인해 주셔야 합니다</h2>
|
||||||
|
<p className="ui-body mb-5">
|
||||||
|
지금 사이트는 검색에 노출되지 않는 상태입니다.{' '}
|
||||||
|
아래 두 가지를 확인해 주시면 공개 준비가 끝납니다.
|
||||||
|
</p>
|
||||||
|
<div className="grid sm:grid-cols-2 gap-3">
|
||||||
|
<Link to={`/images/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||||
|
<p className="font-bold text-[#1D0024] mb-1">사진 확인</p>
|
||||||
|
<p className="ui-body">사이트에 실을 사진을 한 장씩 골라 주세요.</p>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/supporters/${build.clinic_id}`} className="rounded-xl border border-slate-200 p-4 hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow">
|
||||||
|
<p className="font-bold text-[#1D0024] mb-1">확인 항목 입력</p>
|
||||||
|
<p className="ui-body">공개 자료에 없어 병원만 답할 수 있는 값입니다.</p>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{build.preview_url && (
|
||||||
|
<a
|
||||||
|
href={build.preview_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white font-semibold px-7 py-4 text-base"
|
||||||
|
>
|
||||||
|
<EyeFilled size={16} /> 사이트 열어 보기
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
||||||
|
<div className="flex items-center gap-2 mb-2 text-[#F5E0C5]">
|
||||||
|
<WarningFilled size={15} />
|
||||||
|
<h3 className="text-base font-bold">확인 대기로 발행된 부분</h3>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<li key={w.phase} className="ui-note">
|
||||||
|
{w.summary?.split('\n')[0] ?? w.phase}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-sm text-white/40 mt-3">자동으로 채우지 못한 값입니다. 지어내지 않고 빈 자리를 그대로 두었습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-white/30 text-xs mt-10">
|
||||||
|
갱신 {build.updated_at.slice(0, 16).replace('T', ' ')}
|
||||||
|
{' · '}
|
||||||
|
<Link to={`/discovery/${build.clinic_id}`} className="underline underline-offset-4">진단 리포트</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user