From f38c0f3bda9ae6dd25846a389c962660b0a6bf37 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 18 Sep 2026 17:25:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(front):=20=EC=A7=84=EB=8B=A8=20=EC=9A=94?= =?UTF-8?q?=EC=95=BD=EA=B3=BC=20=EB=B3=B4=EC=99=84=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EC=97=AC=20=EC=A4=80=20=EB=92=A4=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=ED=95=98=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 이 빌드가 무엇을 보완하는지 말한 뒤 누르게 한다. 처음 만든 판은 실행 항목을 카테고리로 갈라 "이 빌드로 채워지는 항목 5건"이라고 했는데 그 5건이 깨진 JSON-LD 수정·GBP 전화번호 등록·H1 재설계처럼 전부 병원 홈페이지 몫이었다. 카테고리는 무엇에 관한 문제인지를 말할 뿐 누가 고치는지를 말하지 않는다. 지키지 못할 약속이라 프레임을 바꿨다. 빌드는 홈페이지를 한 줄도 고치지 않는다. AI가 인용할 새 표면을 하나 더 만들어 약한 카테고리(콘텐츠·엔티티·표면)를 우회 보완할 뿐이다. 화면도 그렇게 말한다. "이 빌드가 대신할 수 없는 것"으로 실행 항목이 그대로 병원 몫임을 밝힌다. - 다크 섹션 위 주 버튼을 .btn-on-dark 로 뺀다. 기본 CTA 그라디언트(#4F1DA1→#021341)는 끝 색이 배경(#0A1128)과 거의 같아 버튼으로 읽히지 않았다. 밝은 보라와 글로우로 떼어 놓는다. - 문구에서 "결과"를 뺀다. 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니고, 누른 적 없는 사람에게 "결과 보기"부터 보이는 것도 맞지 않는다. - 이전 단계로 가는 플로팅 버튼을 왼쪽 아래에 고정한다. 오른쪽 아래는 주 행동 자리이고, 좁은 화면에서는 상단 절차 메뉴가 가로 스크롤 안으로 밀린다. 첫 단계에서는 띄우지 않는다. - 떠 있던 승인 바와 BuildSiteCta 는 이 섹션과 역할이 겹쳐 지운다. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ClinicNav.tsx | 33 ++++ src/components/discovery/BuildPlanSection.tsx | 179 ++++++++++++++++++ src/components/discovery/BuildSiteCta.tsx | 156 --------------- src/index.css | 24 +++ src/lib/buildCoverage.ts | 47 +++++ src/pages/DiscoveryReportPage.tsx | 22 ++- src/pages/SiteBuildPage.tsx | 4 +- 7 files changed, 298 insertions(+), 167 deletions(-) create mode 100644 src/components/discovery/BuildPlanSection.tsx delete mode 100644 src/components/discovery/BuildSiteCta.tsx create mode 100644 src/lib/buildCoverage.ts diff --git a/src/components/ClinicNav.tsx b/src/components/ClinicNav.tsx index af11c69..8cfe02a 100644 --- a/src/components/ClinicNav.tsx +++ b/src/components/ClinicNav.tsx @@ -8,6 +8,7 @@ * 단계의 완료 여부는 각 화면이 판단한다. 여기서는 위치만 표시한다. */ import { Link, useLocation } from 'react-router'; +import { motion } from 'motion/react'; import { PrismFilled } from './icons/FilledIcons'; type Step = { key: string; label: string; to: (id: string) => string; match: RegExp }; @@ -30,8 +31,10 @@ export function clinicIdFromPath(pathname: string): string | null { export default function ClinicNav({ clinicId }: { clinicId: string }) { const { pathname } = useLocation(); const currentIndex = STEPS.findIndex((s) => s.match.test(pathname)); + const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null; return ( + <> + + {/* 이전 단계로. 상단 절차 메뉴는 좁은 화면에서 가로 스크롤 안으로 밀려 눈에 안 들어온다. + 되돌아가는 길은 늘 같은 자리에 있어야 한다. 첫 단계에서는 돌아갈 곳이 없어 띄우지 않는다. */} + {prev && ( + + + + + + + 이전 단계 + {prev.label} + + + + )} + ); } diff --git a/src/components/discovery/BuildPlanSection.tsx b/src/components/discovery/BuildPlanSection.tsx new file mode 100644 index 0000000..1789206 --- /dev/null +++ b/src/components/discovery/BuildPlanSection.tsx @@ -0,0 +1,179 @@ +/** + * 진단 요약 → 이렇게 보완하겠습니다 → 빌드. + * + * 버튼만 두면 무엇에 동의하는지 모른 채 누르게 된다. 진단을 요약하고, 무엇을 보완하는지 말한 뒤 누르게 한다. + * + * 이 화면에서 틀리면 안 되는 것: 빌드는 병원 홈페이지를 한 줄도 고치지 않는다. + * 그래서 진단의 실행 항목은 단 하나도 이 빌드로 해결되지 않는다. 전부 병원 몫이다. + * 빌드가 하는 일은 AI가 인용할 새 표면을 하나 더 만들어 약한 카테고리를 우회 보완하는 것이다. + * "실행 항목 N건 해결"로 쓰면 지키지 못할 약속이 된다. + */ +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 { weakSpots } from '../../lib/buildCoverage'; +import type { DiscoveryResult, OverallScore } from '../../types/discovery'; +import { CheckFilled, WarningFilled } from '../icons/FilledIcons'; +import { ArrowRight } from 'lucide-react'; + +type Props = { + result: DiscoveryResult; + overall: OverallScore; + summary: { rubricVersion: string; grade: string; score: number; aeo?: number; geo?: number; verified: number; total: number }; +}; + +export function BuildPlanSection({ result, overall, summary }: Props) { + const navigate = useNavigate(); + const [existing, setExisting] = useState(null); + const [checking, setChecking] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const weak = weakSpots(overall.categories); + const critical = result.keyFindings.filter((f) => f.severity === 'critical'); + const p0 = result.actions.filter((a) => a.priority === 'P0'); + + useEffect(() => { + let alive = true; + void (async () => { + const { data } = await supabase.from('supporter_builds').select('status') + .eq('clinic_id', result.id).order('created_at', { ascending: false }).limit(1); + if (!alive) return; + setExisting((data?.[0]?.status as BuildState) ?? null); + setChecking(false); + })(); + return () => { alive = false; }; + }, [result.id]); + + async function approve() { + setBusy(true); setError(null); + await supabase.from('supporter_inputs').insert({ + clinic_id: result.id, key: 'report_approval', + value: { ...summary, weakSpots: weak.map((w) => w.id), approvedAt: new Date().toISOString() }, + }); + const { error: e } = await supabase.from('supporter_builds').insert({ + clinic_id: result.id, clinic_name: result.clinicName, url: result.url, status: 'queued', + }); + setBusy(false); + if (e) { setError(e.message); return; } + navigate(`/build/${result.id}`); + } + + const inFlight = existing === 'queued' || existing === 'running'; + + return ( +
+
+
+

+ What We Will Fix +

+ + {/* 1. 진단 요약 */} +
+

진단 요약 · {result.auditedAt} 실측

+
+ + {summary.grade} + 종합 {summary.score}점 + + {typeof summary.aeo === 'number' && ( + {summary.aeo}AEO 답변 준비도 + )} + {typeof summary.geo === 'number' && ( + {summary.geo}GEO 출처 준비도 + )} + 실측 {summary.verified} / {summary.total}항목 +
+ {critical.length > 0 && ( +
    + {critical.slice(0, 3).map((f) => ( +
  • + + {f.title} +
  • + ))} +
+ )} +
+ + {/* 2. 이렇게 보완하겠습니다 */} +

+ 홈페이지를 고치지 않고, AI가 인용할 수 있는 사이트를 하나 더 만들어 아래를 보완하겠습니다. +

+
+ {weak.map((w, i) => ( + +
+ {w.name} + {w.pct}% +
+

지금

+

{w.now}

+

보완

+

{w.fix}

+
+ ))} +
+ + {/* 3. 빌드가 대신할 수 없는 것 */} +
+
+ +

이 빌드가 대신할 수 없는 것

+
+

+ 이 빌드는 별도 사이트를 만들 뿐, 병원 홈페이지를 고치지 않습니다.{' '} + 진단의 실행 항목 {result.actions.length}건은 그대로 병원 홈페이지에서 하셔야 합니다. +

+
    + {p0.map((a) => ( +
  • P0{a.title}
  • + ))} +
+
+ + {/* 4. 실행 + 버튼 배경은 밝은 보라로 둔다. 기본 CTA 그라디언트(#4F1DA1→#021341)는 흰 배경용이라 + 다크 섹션(#0A1128) 위에 올리면 배경에 묻혀 버튼으로 읽히지 않는다. + 문구에 "결과"를 쓰지 않는다. 빌드가 끝나도 사진 확인과 확인 항목이 남아 최종이 아니다. */} +
+ {checking ? ( + 빌드 상태를 확인하는 중입니다. + ) : existing ? ( + <> + + {!inFlight && ( + + )} + {STATE_LABEL[existing]} + + ) : ( + + )} +
+ {error &&

시작하지 못했습니다. {error}

} +

만들어진 사이트는 검색에 노출되지 않는 상태로 올라갑니다. 공개 여부는 병원이 확인한 뒤에 정합니다.

+
+
+ ); +} diff --git a/src/components/discovery/BuildSiteCta.tsx b/src/components/discovery/BuildSiteCta.tsx deleted file mode 100644 index 3cf3a97..0000000 --- a/src/components/discovery/BuildSiteCta.tsx +++ /dev/null @@ -1,156 +0,0 @@ -/** - * 진단 리포트 → 사이트 빌드로 넘어가는 구간. - * - * 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/index.css b/src/index.css index 5c505d0..294bd4d 100644 --- a/src/index.css +++ b/src/index.css @@ -246,3 +246,27 @@ h1, h2, h3, h4, h5, h6 { word-break: keep-all; text-wrap: balance; } + +/* 다크 섹션(#0A1128) 위의 주 버튼. + 기본 CTA 그라디언트(#4F1DA1 → #021341)는 흰 배경용이라 다크 위에 올리면 배경에 묻힌다. + 밝은 보라 + 글로우로 떼어 놓는다. */ +.btn-on-dark { + display: inline-flex; + align-items: center; + gap: 0.625rem; + border-radius: 9999px; + padding: 1rem 2rem; + font-size: 1.0625rem; + font-weight: 700; + color: #FFFFFF; + background-image: linear-gradient(to right, #7C6DD8, #6C5CE7); + box-shadow: 0 6px 28px rgba(108, 92, 231, 0.55); + outline: 1px solid rgba(255, 255, 255, 0.25); + outline-offset: -1px; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.btn-on-dark:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 10px 34px rgba(108, 92, 231, 0.7); +} +.btn-on-dark:disabled { opacity: 0.6; } diff --git a/src/lib/buildCoverage.ts b/src/lib/buildCoverage.ts new file mode 100644 index 0000000..4fd7a3e --- /dev/null +++ b/src/lib/buildCoverage.ts @@ -0,0 +1,47 @@ +/** + * 서포터즈 사이트 빌드가 진단의 어느 약점을 보완하는지 고른다. + * + * 중요한 전제. 이 빌드는 병원 홈페이지를 한 줄도 고치지 않는다. 별도 사이트를 하나 만들 뿐이다. + * 그래서 진단의 실행 항목(깨진 JSON-LD 수정·GBP 전화번호 등록·H1 재설계 등)은 단 하나도 + * 이 빌드로 해결되지 않는다. 전부 병원 몫이다. + * + * 빌드가 하는 일은 다르다. AI가 인용할 수 있는 새 표면을 하나 더 만들어, 홈페이지를 고치지 않고도 + * 답변·출처로 쓰일 텍스트를 확보한다. 그래서 "실행 항목 중 몇 건 해결"이 아니라 + * "어느 약점을 우회해서 보완하는가"로 말해야 한다. + */ +import type { CategoryId, CategoryScore } from '../types/discovery'; + +/** 빌드가 보완할 수 있는 카테고리. 크롤러 접근(A)·측정(E)·기술 위생(F)은 홈페이지 쪽이라 손댈 수 없다. */ +const ADDRESSABLE: CategoryId[] = ['content', 'entity', 'surface']; + +/** 카테고리별로, 빌드가 무엇을 만들어 어떻게 보완하는지 */ +export const COVERAGE_PLAN: Record = { + content: { + now: '패시지 단위로 인용할 문장이 없습니다', + fix: '병원을 알아보는 사람이 실제로 묻는 질문을 골라, 한 단락만 떼어내도 뜻이 통하는 글로 답합니다.', + }, + entity: { + now: '누가 말하는지 기계가 확정하지 못합니다', + fix: '의료진 이력과 병원 사실을 한자리에 모으고, 글마다 작성자와 감수 원장을 구조화해 밝힙니다.', + }, + surface: { + now: 'AI가 인용할 표면이 좁습니다', + fix: '원장 설명 영상과 언론 보도를 글의 근거로 연결하고, 회복 일정과 방문 안내를 별도 페이지로 만듭니다.', + }, +}; + +export type WeakSpot = { id: CategoryId; name: string; pct: number; now: string; fix: string }; + +/** 빌드가 보완할 수 있는 카테고리 중 점수가 낮은 순 */ +export function weakSpots(categories: CategoryScore[]): WeakSpot[] { + return categories + .filter((c) => ADDRESSABLE.includes(c.category.id) && COVERAGE_PLAN[c.category.id]) + .sort((a, b) => a.pct - b.pct) + .map((c) => ({ + id: c.category.id, + name: c.category.name, + pct: c.pct, + now: COVERAGE_PLAN[c.category.id].now, + fix: COVERAGE_PLAN[c.category.id].fix, + })); +} diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx index 946b05a..b3a8d93 100644 --- a/src/pages/DiscoveryReportPage.tsx +++ b/src/pages/DiscoveryReportPage.tsx @@ -7,7 +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 { BuildPlanSection } from '../components/discovery/BuildPlanSection'; import { DISCOVERY_RESULTS } from '../data/discoveryResults'; import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore'; import type { @@ -310,14 +310,18 @@ export default function DiscoveryReportPage() { - {/* ── 4b. 사이트로 빌드 (dark) ── */} - a.priority === 'P0').length, - p1: result.actions.filter((a) => a.priority === 'P1').length, + {/* ── 4b. 진단 요약 → 개선 계획 → 빌드 (dark) ── */} + n + c.verifiedCount, 0), + total: AEO_GEO_RUBRIC.criteria.length, }} /> diff --git a/src/pages/SiteBuildPage.tsx b/src/pages/SiteBuildPage.tsx index fa38588..04091e8 100644 --- a/src/pages/SiteBuildPage.tsx +++ b/src/pages/SiteBuildPage.tsx @@ -96,7 +96,7 @@ export default function SiteBuildPage() {

{loadError ? `상태를 불러오지 못했습니다. ${loadError}` : '진단 리포트에서 사이트로 빌드를 눌러 주세요.'}

- + 진단 리포트로 가기 @@ -203,7 +203,7 @@ export default function SiteBuildPage() { 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" + className="btn-on-dark justify-center" > 사이트 열어 보기