diff --git a/src/components/discovery/BuildSiteCta.tsx b/src/components/discovery/BuildSiteCta.tsx
new file mode 100644
index 0000000..3cf3a97
--- /dev/null
+++ b/src/components/discovery/BuildSiteCta.tsx
@@ -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
(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 (
+
+
+
+
+
+ Build the Site
+
+
진단에서 나온 것을 사이트로 옮깁니다
+
+ AI가 병원을 설명할 때 근거로 삼을 페이지를 만듭니다.{' '}
+ 홈페이지 공개 자료만 쓰고, 없는 값은 지어내지 않고 확인 대기로 둡니다.
+ {actionCounts && (actionCounts.p0 + actionCounts.p1 > 0) && (
+ <> 위 실행 항목 가운데 콘텐츠와 구조에 해당하는 것(P0 {actionCounts.p0}건 · P1 {actionCounts.p1}건)이 이 단계에서 채워집니다.>
+ )}
+
+
+
+
+ {OUTPUTS.map((o, i) => {
+ const Icon = o.icon;
+ return (
+
+
+ {o.title}
+ {o.detail}
+
+ );
+ })}
+
+
+
+ {checking ? (
+
빌드 상태를 확인하는 중입니다.
+ ) : hasBuild ? (
+ <>
+
+
{STATE_LABEL[existing!.status]}
+ {!inFlight && (
+
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ {error &&
시작하지 못했습니다. {error}
}
+
+ 만들어진 사이트는 검색에 노출되지 않는 상태로 올라갑니다.{' '}
+ 공개 여부는 병원이 확인한 뒤에 정합니다.
+
+
+
+ );
+}
diff --git a/src/lib/buildPhases.ts b/src/lib/buildPhases.ts
new file mode 100644
index 0000000..872b9b1
--- /dev/null
+++ b/src/lib/buildPhases.ts
@@ -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();
+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 = {
+ queued: '차례를 기다리는 중입니다',
+ running: '만드는 중입니다',
+ gate_failed: '발행 기준에서 멈췄습니다',
+ failed: '만들다가 멈췄습니다',
+ preview: '준비됐습니다',
+ approved: '승인 완료',
+ published: '검색 노출까지 켜졌습니다',
+ published_pending_tasks: '발행됐고 남은 확인이 있습니다',
+};
diff --git a/src/main.tsx b/src/main.tsx
index 78b0831..4593dc4 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -19,6 +19,7 @@ import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
import SupportersBuildPage from './pages/SupportersBuildPage.tsx';
import ImageReviewPage from './pages/ImageReviewPage.tsx';
+import SiteBuildPage from './pages/SiteBuildPage.tsx';
import './index.css';
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
@@ -50,6 +51,7 @@ createRoot(document.getElementById('root')!).render(
} />
} />
} />
+ } />
diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx
index fb68943..946b05a 100644
--- a/src/pages/DiscoveryReportPage.tsx
+++ b/src/pages/DiscoveryReportPage.tsx
@@ -7,6 +7,7 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
+import { BuildSiteCta } from '../components/discovery/BuildSiteCta';
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type {
@@ -87,13 +88,10 @@ export default function DiscoveryReportPage() {
[result],
);
+ // 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
+ // 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
if (!result || !overall) {
- return (
-
-
진단 결과를 찾을 수 없습니다.
-
홈으로
-
- );
+ return ;
}
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
@@ -101,7 +99,7 @@ export default function DiscoveryReportPage() {
return (
{/* ── 1. 헤더 (dark) ── */}
-
+
@@ -132,19 +130,19 @@ export default function DiscoveryReportPage() {
-
+
{overallV2 && [overallV2.aeo, overallV2.geo].map((ax) => (
{ax.grade}
- {ax.axis.code} {ax.axis.name}
+ {ax.axis.code} {ax.axis.name}
))}
-
+
v1.0 종합 {overall.score}/{overall.grade} · 실측 {overall.earned} / {overall.possible}점
@@ -175,13 +173,13 @@ export default function DiscoveryReportPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
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"
>
{f.title}
-
{f.detail}
+
{f.detail}
))}
@@ -201,7 +199,7 @@ export default function DiscoveryReportPage() {
return (
@@ -217,7 +215,7 @@ export default function DiscoveryReportPage() {
-
배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}
+
배점 {cs.weight} · 검증 {cs.verifiedCount}/{cs.totalCount}
{cs.earned} / {cs.possible}점
@@ -234,14 +232,14 @@ export default function DiscoveryReportPage() {
return (
-
{c.id}
+
{c.id}
{c.name}
-
{c.weight}점
+
{c.weight}점
-
{r?.evidence}
+
{r?.evidence}
{r?.source && (
-
{r.source}
+
{r.source}
)}
{EVIDENCE_LABEL[c.evidenceGrade]}
@@ -252,7 +250,7 @@ export default function DiscoveryReportPage() {
{level !== 'unverified' && (
-
+
{Math.round((c.weight * level) / 3 * 10) / 10} / {c.weight}
)}
@@ -292,13 +290,13 @@ export default function DiscoveryReportPage() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
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"
>
{a.title}
- {a.effort}
+ {a.effort}
- {a.detail}
+ {a.detail}
{a.criterionIds.map((cid) => (
{cid}
@@ -312,6 +310,17 @@ export default function DiscoveryReportPage() {
+ {/* ── 4b. 사이트로 빌드 (dark) ── */}
+ a.priority === 'P0').length,
+ p1: result.actions.filter((a) => a.priority === 'P1').length,
+ }}
+ />
+
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
-
+
하지 않을 것
{result.notDoing.map((n) => (
@@ -328,19 +337,19 @@ export default function DiscoveryReportPage() {
{n.item}
-
{n.reason}
+
{n.reason}
))}
-
+
측정 조건
{result.conditions.map((c) => (
-
-
{c}
+ {c}
))}
@@ -364,9 +373,9 @@ export default function DiscoveryReportPage() {
title="Scoring Rubric"
subtitle={`실측 기준표 v${AEO_GEO_RUBRIC.version} · ${AEO_GEO_RUBRIC.criteria.length}개 항목. v2.0 AEO/GEO 점수는 이 항목들을 파생 신호로 쓴다`}
>
-
+
-
+
| ID |
항목 |
@@ -395,6 +404,50 @@ export default function DiscoveryReportPage() {
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
+/** 진단 데이터가 없는 병원. 빌드·사진 확인으로 잇고, 진단이 무엇인지 한 줄로 알린다. */
+function NoReport({ clinicId }: { clinicId: string }) {
+ return (
+
+
+
+
+
+
+
INFINITH AI Discovery
+
+
+ No Audit Yet
+
+
{clinicId}
+
+ 이 병원은 아직 AEO/GEO 진단을 받지 않았습니다.{' '}
+ 진단은 36개 항목을 실제로 물어보고 답변을 확인해 매기는 별도 작업이라, 사이트 빌드만으로는 만들어지지 않습니다.
+
+
+
+
+
+
+
진단 없이도 아래는 바로 보실 수 있습니다.
+
+
+
사이트 빌드 결과
+
만들어진 글과 안내 페이지를 봅니다.
+
+
+
사진 확인
+
사이트에 실을 사진을 고릅니다.
+
+
+
+ 진단 리포트 예시는 뷰성형외과 리포트에서 보실 수 있습니다.
+
+
+
+
+ );
+}
+
function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: number }) {
const Icon = CATEGORY_ICON[cs.category.id];
return (
@@ -403,14 +456,14 @@ function CategoryBar({ cs, index }: { key?: string; cs: CategoryScore; index: nu
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
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]"
>
{cs.category.code}. {cs.category.name}
-
{cs.weight}점
+
{cs.weight}점
{c.id}
|
{c.name}
- {c.rationale}
- {c.howToCheck}
+ {c.rationale}
+ {c.howToCheck}
|
{c.weight} |
{EVIDENCE_LABEL[c.evidenceGrade]} |
diff --git a/src/pages/SiteBuildPage.tsx b/src/pages/SiteBuildPage.tsx
new file mode 100644
index 0000000..fa38588
--- /dev/null
+++ b/src/pages/SiteBuildPage.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState(null);
+ const timer = useRef(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 (
+
+
+
+
+
+
+
INFINITH AI Discovery · Site Build
+
+
+
+
+ {finished ? 'Your Site Is Ready' : 'Building Your Site'}
+
+
+
{name}
+
{STATE_LABEL[state]}
+
+ {loading &&
상태를 불러오는 중입니다.
}
+
+ {!loading && !build && (
+
+
아직 빌드가 시작되지 않았습니다
+
+ {loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}
+
+
+ 진단 리포트로 가기
+
+
+ )}
+
+ {!loading && build && (
+ <>
+ {/* 단계 */}
+
+ {BUILD_STEPS.map((step, i) => {
+ const st = states[i];
+ return (
+
+
+ {st === 'done' ? (
+
+
+
+ ) : st === 'active' ? (
+
+ ) : st === 'failed' ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {st === 'done' ? step.labelDone : step.label}
+
+
{step.detail}
+
+
+ );
+ })}
+
+
+ {/* 진행바 */}
+
+
+
+
+ {finished
+ ? `${build.posts?.length ?? 0}편의 글과 안내 페이지를 만들었습니다.`
+ : '창을 닫으셔도 계속 진행됩니다. 보통 10분 안팎 걸립니다.'}
+
+
+ {/* 멈춤 */}
+ {stopped && (
+
+
+ {state === 'gate_failed' ? '발행 기준에서 멈췄습니다' : '만들다가 멈췄습니다'}
+
+
+ {state === 'gate_failed'
+ ? '사이트에 실으면 안 되는 내용이 걸려 배포하지 않고 멈췄습니다. 담당자가 확인한 뒤 다시 진행합니다.'
+ : '자동으로 처리하지 못한 지점이 있습니다. 담당자가 확인한 뒤 다시 진행합니다.'}
+
+ {build.error &&
{build.error.slice(0, 300)}
}
+
+ )}
+
+ {/* 완료 */}
+ {finished && (
+
+
+
다음은 병원에서 확인해 주셔야 합니다
+
+ 지금 사이트는 검색에 노출되지 않는 상태입니다.{' '}
+ 아래 두 가지를 확인해 주시면 공개 준비가 끝납니다.
+
+
+
+
사진 확인
+
사이트에 실을 사진을 한 장씩 골라 주세요.
+
+
+
확인 항목 입력
+
공개 자료에 없어 병원만 답할 수 있는 값입니다.
+
+
+
+
+ {build.preview_url && (
+
+ 사이트 열어 보기
+
+ )}
+
+ {warnings.length > 0 && (
+
+
+
+
확인 대기로 발행된 부분
+
+
+ {warnings.map((w) => (
+ -
+ {w.summary?.split('\n')[0] ?? w.phase}
+
+ ))}
+
+
자동으로 채우지 못한 값입니다. 지어내지 않고 빈 자리를 그대로 두었습니다.
+
+ )}
+
+ )}
+
+
+ 갱신 {build.updated_at.slice(0, 16).replace('T', ' ')}
+ {' · '}
+ 진단 리포트
+
+ >
+ )}
+
+
+ );
+}