feat(discovery): 진단 리포트에서 개선 제안(Action Plan) 단계 분리
증거(진단)와 처방(할 일)을 한 페이지에 두면 스크롤이 너무 길어져
둘 다 읽기 힘들다는 지적에 따라 단계를 나눴다.
- /actions/:id 신규: Action Plan(P0~P2) + What We Don't Sell + 빌드 제안
(ActionPlanSection·IntegritySection 신규 컴포넌트, Tag는 두 페이지가
같이 써서 공용 컴포넌트로 뺌)
- /discovery/🆔 위 섹션 제거, 진단(점수·근거·Scorecard·Rubric)만 남김.
페이지 맨 아래 중복으로 박혀 있던 확인 항목 패널(ClinicInputsPanel)도
제거 — /supporters/:id 전용 단계가 이미 있어 두 번 보여줄 필요가 없었다
- 진단 데이터가 없는 병원이 /actions/:id 로 오면 /discovery/:id 로 되돌림
- ClinicNav: 5단계로 확장(진단 리포트→개선 제안→사이트 빌드→사진 확인→
확인 항목), 다음 단계 플로팅 버튼 추가(기존엔 이전 단계만 있었다)
- App.tsx: 라우트 전환 시 스크롤을 맨 위로 되돌림. 스크롤을 내린 채
다음 단계를 누르면 새 페이지가 같은 위치(짧으면 빈 하단)에서 시작하던
문제 수정. 인앱 해시 앵커(#breakdown)가 있으면 건드리지 않음
- 사이트 빌드/확인 항목 화면의 미리보기 버튼을 "초안 사이트 열기"로
통일(공개 후에는 "공개본 사이트 열기")
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
36731d3ad1
commit
890da0ecdf
10
src/App.tsx
10
src/App.tsx
@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Footer from './components/Footer';
|
import Footer from './components/Footer';
|
||||||
@ -8,6 +9,15 @@ export default function App() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isLoadingPage = location.pathname.startsWith('/report/loading');
|
const isLoadingPage = location.pathname.startsWith('/report/loading');
|
||||||
|
|
||||||
|
// 라우트가 바뀌어도 브라우저가 스크롤 위치를 그대로 들고 온다. 진단 리포트처럼 긴 페이지를
|
||||||
|
// 내려 보다가 "다음 단계"를 누르면, 새로 연 페이지가 그 위치(짧으면 하단 빈 공간)에서 시작해
|
||||||
|
// 항상 위부터 보게 되지 않는다. 라우트 전환마다 맨 위로 되돌린다.
|
||||||
|
// 해시(#breakdown 같은 인앱 앵커)가 있으면 그 앵커로 가려는 의도이므로 건드리지 않는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.hash) return;
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
}, [location.pathname, location.hash]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
<div className="min-h-screen bg-slate-50 selection:bg-purple-200 selection:text-primary-900">
|
||||||
{!isLoadingPage && <Navbar />}
|
{!isLoadingPage && <Navbar />}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ type Step = { key: string; label: string; to: (id: string) => string; match: Reg
|
|||||||
|
|
||||||
const STEPS: Step[] = [
|
const STEPS: Step[] = [
|
||||||
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
|
{ key: 'report', label: '진단 리포트', to: (id) => `/discovery/${id}`, match: /^\/discovery\/[^/]+/ },
|
||||||
|
{ key: 'actions', label: '개선 제안', to: (id) => `/actions/${id}`, match: /^\/actions\/[^/]+/ },
|
||||||
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
|
{ key: 'build', label: '사이트 빌드', to: (id) => `/build/${id}`, match: /^\/build\/[^/]+/ },
|
||||||
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
|
{ key: 'images', label: '사진 확인', to: (id) => `/images/${id}`, match: /^\/images\/[^/]+/ },
|
||||||
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
|
{ key: 'inputs', label: '확인 항목', to: (id) => `/supporters/${id}`, match: /^\/supporters\/[^/]+/ },
|
||||||
@ -32,6 +33,7 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
|
const currentIndex = STEPS.findIndex((s) => s.match.test(pathname));
|
||||||
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
|
const prev = currentIndex > 0 ? STEPS[currentIndex - 1] : null;
|
||||||
|
const next = currentIndex > -1 && currentIndex < STEPS.length - 1 ? STEPS[currentIndex + 1] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -87,7 +89,11 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 이전 단계로. 상단 절차 메뉴는 좁은 화면에서 가로 스크롤 안으로 밀려 눈에 안 들어온다.
|
{/* 이전 단계로. 상단 절차 메뉴는 좁은 화면에서 가로 스크롤 안으로 밀려 눈에 안 들어온다.
|
||||||
되돌아가는 길은 늘 같은 자리에 있어야 한다. 첫 단계에서는 돌아갈 곳이 없어 띄우지 않는다. */}
|
되돌아가는 길은 늘 같은 자리에 있어야 한다. 첫 단계에서는 돌아갈 곳이 없어 띄우지 않는다.
|
||||||
|
|
||||||
|
다음 단계로 가는 길도 같은 이유로 여기에 둔다. 화면마다 따로 심으면 빠지는 곳이 생긴다.
|
||||||
|
실제로 사이트 빌드 화면은 빌드가 끝났을 때만 다음 단계 카드를 그려서, 빌드 중이거나
|
||||||
|
멈췄을 때는 상단 메뉴 말고는 넘어갈 길이 없었다. 마지막 단계에서는 갈 곳이 없어 띄우지 않는다. */}
|
||||||
{prev && (
|
{prev && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, x: -12 }}
|
initial={{ opacity: 0, x: -12 }}
|
||||||
@ -114,6 +120,33 @@ export default function ClinicNav({ clinicId }: { clinicId: string }) {
|
|||||||
</Link>
|
</Link>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{next && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 12 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.3, duration: 0.3 }}
|
||||||
|
className="fixed right-4 bottom-4 md:right-6 md:bottom-6 z-40"
|
||||||
|
data-no-print
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={next.to(clinicId)}
|
||||||
|
className="group inline-flex items-center gap-2.5 rounded-full bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white
|
||||||
|
shadow-[0_4px_20px_rgba(29,0,36,0.24)] pl-5 pr-3 py-3 transition-shadow hover:shadow-[0_6px_26px_rgba(29,0,36,0.32)]"
|
||||||
|
>
|
||||||
|
<span className="text-right leading-tight">
|
||||||
|
<span className="block text-[11px] font-semibold text-white/60">다음 단계</span>
|
||||||
|
<span className="block text-sm font-bold whitespace-nowrap">{next.label}</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-7 h-7 rounded-full bg-white/15 flex items-center justify-center
|
||||||
|
transition-colors group-hover:bg-white/25">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true">
|
||||||
|
<path d="M5.1 1.4 10.7 7l-5.6 5.6a1.1 1.1 0 0 1-1.6-1.6L7.5 7l-4-4A1.1 1.1 0 0 1 5.1 1.4Z" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/components/discovery/ActionPlanSection.tsx
Normal file
67
src/components/discovery/ActionPlanSection.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 개선 제안(/actions/:id) 1부: 우선순위별 실행 항목.
|
||||||
|
* 원래 진단 리포트 안의 "Action Plan" 섹션이었다. 진단(증거)과 처방(할 일)은 읽는 목적이 달라
|
||||||
|
* 별도 단계로 뗐다. 내용은 그대로다.
|
||||||
|
*/
|
||||||
|
import { motion } from 'motion/react';
|
||||||
|
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||||
|
import { Tag } from './Tag';
|
||||||
|
import type { ActionPriority, DiscoveryResult } from '../../types/discovery';
|
||||||
|
|
||||||
|
const PRIORITY_STYLE: Record<ActionPriority, string> = {
|
||||||
|
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
|
||||||
|
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
|
||||||
|
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_HEADING: Record<ActionPriority, string> = {
|
||||||
|
P0: '즉시: 크롤러 접근·엔티티·측정',
|
||||||
|
P1: '4주 내: 콘텐츠 구조',
|
||||||
|
P2: '지속: 신선도·시딩',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActionPlanSection({ result }: { result: DiscoveryResult }) {
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
id="actions"
|
||||||
|
title="Action Plan"
|
||||||
|
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
|
||||||
|
<div key={p}>
|
||||||
|
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
|
||||||
|
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-[#0A1128]">{PRIORITY_HEADING[p]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
{result.actions.filter((a) => a.priority === p).map((a, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={a.title}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ delay: i * 0.08 }}
|
||||||
|
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">
|
||||||
|
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
|
||||||
|
<span className="ui-note shrink-0">{a.effort}</span>
|
||||||
|
</div>
|
||||||
|
<p className="ui-body mb-3">{a.detail}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{a.criterionIds.map((cid) => (
|
||||||
|
<Tag key={cid}>{cid}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -117,7 +117,7 @@ export function ClinicInputsPanel({ clinicId }: { clinicId: string }) {
|
|||||||
<p className="ui-note mt-1">갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
|
<p className="ui-note mt-1">갱신 {build.updated_at.slice(0, 16).replace('T', ' ')} · 글 {posts.length}편{typeof build.report?.totalCostUsd === 'number' ? ` · 생성 비용 $${build.report.totalCostUsd}` : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
{build.preview_url && (
|
{build.preview_url && (
|
||||||
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">미리보기 열기 (noindex)</a>
|
<a href={build.preview_url} target="_blank" rel="noreferrer" className="inline-flex items-center justify-center rounded-full bg-primary-900 text-white font-semibold px-6 py-3 text-base">{build.status === 'published' || build.status === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
53
src/components/discovery/IntegritySection.tsx
Normal file
53
src/components/discovery/IntegritySection.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 개선 제안(/actions/:id) 2부: 하지 않을 것 + 측정 조건.
|
||||||
|
* "이 개선안이 무엇을 안 파는지"라 처방 쪽 내용이다. 진단 리포트에서 옮겨왔다.
|
||||||
|
*/
|
||||||
|
import { SectionWrapper } from '../report/ui/SectionWrapper';
|
||||||
|
import { CheckFilled, CrossFilled } from '../icons/FilledIcons';
|
||||||
|
import type { DiscoveryResult } from '../../types/discovery';
|
||||||
|
|
||||||
|
export function IntegritySection({ result }: { result: DiscoveryResult }) {
|
||||||
|
return (
|
||||||
|
<SectionWrapper
|
||||||
|
id="integrity"
|
||||||
|
title="What We Don't Sell"
|
||||||
|
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
|
||||||
|
dark
|
||||||
|
>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<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>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{result.notDoing.map((n) => (
|
||||||
|
<li key={n.item} className="flex gap-3">
|
||||||
|
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
|
||||||
|
<div>
|
||||||
|
<p className="text-base font-semibold">{n.item}</p>
|
||||||
|
<p className="ui-note mt-0.5">{n.reason}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{result.conditions.map((c) => (
|
||||||
|
<li key={c} className="flex gap-3">
|
||||||
|
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
|
||||||
|
<p className="ui-body">{c}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
|
||||||
|
<p className="text-sm text-slate-600 leading-relaxed">
|
||||||
|
점수는 <strong>검증된 항목만</strong>으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을
|
||||||
|
확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며,
|
||||||
|
크롤러 접근·엔티티·콘텐츠 구조라는 <strong>전제조건</strong>의 충족 여부를 진단합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/discovery/Tag.tsx
Normal file
13
src/components/discovery/Tag.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 작은 라벨 배지. 진단 리포트(Scorecard의 근거/통제/채점 태그)와 개선 제안(항목 ID 태그)
|
||||||
|
* 양쪽에서 쓰여 공용 파일로 뺐다.
|
||||||
|
*/
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export function Tag({ children }: { key?: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import ApiDashboardPage from './pages/ApiDashboardPage.tsx';
|
|||||||
import LoginPage from './pages/LoginPage.tsx';
|
import LoginPage from './pages/LoginPage.tsx';
|
||||||
import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
|
import DiscoveryLandingPage from './pages/DiscoveryLandingPage.tsx';
|
||||||
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
|
import DiscoveryReportPage from './pages/DiscoveryReportPage.tsx';
|
||||||
|
import ActionPlanPage from './pages/ActionPlanPage.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 SiteBuildPage from './pages/SiteBuildPage.tsx';
|
||||||
@ -49,6 +50,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
<Route path="api-dashboard" element={<ApiDashboardPage />} />
|
||||||
<Route path="discovery" element={<DiscoveryLandingPage />} />
|
<Route path="discovery" element={<DiscoveryLandingPage />} />
|
||||||
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
|
||||||
|
<Route path="actions/:id" element={<ActionPlanPage />} />
|
||||||
<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 path="build/:id" element={<SiteBuildPage />} />
|
||||||
|
|||||||
80
src/pages/ActionPlanPage.tsx
Normal file
80
src/pages/ActionPlanPage.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* /actions/:id — 개선 제안 (Action Plan). 진단 리포트(/discovery/:id)에서 분리된 처방 단계.
|
||||||
|
*
|
||||||
|
* 진단은 "지금 무엇이 문제인가"(증거), 여기는 "그래서 무엇을 하는가"(처방)다.
|
||||||
|
* 읽는 목적이 다르고, 둘을 한 페이지에 두면 스크롤이 길어져 둘 다 읽기 힘들어진다.
|
||||||
|
* 진단 데이터가 없으면 처방도 쓸 수 없으므로 진단 리포트로 돌려보낸다.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useParams, Navigate } from 'react-router';
|
||||||
|
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||||
|
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||||
|
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||||
|
import { scoreDiscovery } from '../lib/discoveryScore';
|
||||||
|
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||||
|
import { ActionPlanSection } from '../components/discovery/ActionPlanSection';
|
||||||
|
import { IntegritySection } from '../components/discovery/IntegritySection';
|
||||||
|
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
|
||||||
|
import { PrismFilled } from '../components/icons/FilledIcons';
|
||||||
|
|
||||||
|
export default function ActionPlanPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const result = id ? DISCOVERY_RESULTS[id] : undefined;
|
||||||
|
const overall = useMemo(
|
||||||
|
() => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null),
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
const overallV2 = useMemo(
|
||||||
|
() => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null),
|
||||||
|
[result],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!id) return null;
|
||||||
|
if (!result || !overall) {
|
||||||
|
// 개선 제안은 진단 항목 위에 세운다. 진단이 없으면 여기서 보여줄 근거가 없으니
|
||||||
|
// 진단 리포트로 보낸다. 그 페이지가 "아직 진단 전" 안내와 다음 링크를 갖고 있다.
|
||||||
|
return <Navigate to={`/discovery/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-report-content 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-7xl 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 · Action Plan
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="font-serif text-4xl md:text-6xl font-bold tracking-[-0.02em] mb-4">
|
||||||
|
<span className="bg-gradient-to-r from-purple-300 to-blue-300 bg-clip-text text-transparent">
|
||||||
|
개선 제안
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
|
||||||
|
<p className="ui-body text-white/70 max-w-2xl">
|
||||||
|
진단(v{result.rubricVersion} · {overall.score}/{overall.grade})에서 확인된 항목을 근거로,
|
||||||
|
무엇을 언제까지 할지와 저희가 하지 않을 것을 정리했습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ActionPlanSection result={result} />
|
||||||
|
<IntegritySection result={result} />
|
||||||
|
<BuildPlanSection
|
||||||
|
result={result}
|
||||||
|
overall={overall}
|
||||||
|
summary={{
|
||||||
|
rubricVersion: result.rubricVersion,
|
||||||
|
grade: overall.grade,
|
||||||
|
score: overall.score,
|
||||||
|
aeo: overallV2?.aeo.score,
|
||||||
|
geo: overallV2?.geo.score,
|
||||||
|
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
|
||||||
|
total: AEO_GEO_RUBRIC.criteria.length,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,17 +1,15 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ComponentType, ReactNode } from 'react';
|
import type { ComponentType } from 'react';
|
||||||
import { useParams, Link } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
|
||||||
import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
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 { Tag } from '../components/discovery/Tag';
|
||||||
import { BuildPlanSection } from '../components/discovery/BuildPlanSection';
|
|
||||||
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 {
|
||||||
ActionPriority,
|
|
||||||
CategoryId,
|
CategoryId,
|
||||||
CategoryScore,
|
CategoryScore,
|
||||||
ControlLevel,
|
ControlLevel,
|
||||||
@ -65,12 +63,6 @@ const METHOD_LABEL: Record<EvidenceMethod, string> = {
|
|||||||
manual: '수동',
|
manual: '수동',
|
||||||
};
|
};
|
||||||
|
|
||||||
const PRIORITY_STYLE: Record<ActionPriority, string> = {
|
|
||||||
P0: 'bg-[#FFF0F0] text-[#7C3A4B] border-[#F5D5DC]',
|
|
||||||
P1: 'bg-[#FFF6ED] text-[#7C5C3A] border-[#F5E0C5]',
|
|
||||||
P2: 'bg-[#F3F0FF] text-[#4A3A7C] border-[#D5CDF5]',
|
|
||||||
};
|
|
||||||
|
|
||||||
const scoreColor = (pct: number) =>
|
const scoreColor = (pct: number) =>
|
||||||
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
pct <= 40 ? '#C084CF' : pct <= 60 ? '#8B9CF7' : pct <= 80 ? '#7C6DD8' : '#6C5CE7';
|
||||||
|
|
||||||
@ -265,113 +257,11 @@ export default function DiscoveryReportPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SectionWrapper>
|
</SectionWrapper>
|
||||||
|
|
||||||
{/* ── 4. 실행 계획 (light) ── */}
|
{/* 개선 제안(Action Plan)·하지 않을 것·빌드 제안·확인 항목 패널은 /actions/:id 로 옮겼다.
|
||||||
<SectionWrapper
|
증거(진단)와 처방(할 일)은 읽는 목적이 달라 여기 한 페이지에 두면 스크롤만 길어진다.
|
||||||
id="actions"
|
확인 항목 패널은 /supporters/:id 라는 전용 단계가 이미 있어 여기서 다시 보여줄 필요가 없다. */}
|
||||||
title="Action Plan"
|
|
||||||
subtitle="우선순위별 실행 항목. 각 항목이 어느 기준을 올리는지 연결"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{(['P0', 'P1', 'P2'] as ActionPriority[]).map((p) => (
|
|
||||||
<div key={p}>
|
|
||||||
<div className="flex items-center gap-3 mb-3 mt-6 first:mt-0">
|
|
||||||
<span className={`inline-flex items-center rounded-full text-sm font-semibold px-3 py-1 border ${PRIORITY_STYLE[p]}`}>
|
|
||||||
{p}
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-[#0A1128]">
|
|
||||||
{p === 'P0' ? '즉시: 크롤러 접근·엔티티·측정' : p === 'P1' ? '4주 내: 콘텐츠 구조' : '지속: 신선도·시딩'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{result.actions.filter((a) => a.priority === p).map((a, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={a.title}
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
|
||||||
viewport={{ once: true }}
|
|
||||||
transition={{ delay: i * 0.08 }}
|
|
||||||
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">
|
|
||||||
<h4 className="font-bold text-[#0A1128]">{a.title}</h4>
|
|
||||||
<span className="ui-note shrink-0">{a.effort}</span>
|
|
||||||
</div>
|
|
||||||
<p className="ui-body mb-3">{a.detail}</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{a.criterionIds.map((cid) => (
|
|
||||||
<Tag key={cid}>{cid}</Tag>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SectionWrapper>
|
|
||||||
|
|
||||||
{/* ── 4b. 진단 요약 → 개선 계획 → 빌드 (dark) ── */}
|
{/* ── 4. 채점 기준표 (light) ── */}
|
||||||
<BuildPlanSection
|
|
||||||
result={result}
|
|
||||||
overall={overall}
|
|
||||||
summary={{
|
|
||||||
rubricVersion: result.rubricVersion,
|
|
||||||
grade: overall.grade,
|
|
||||||
score: overall.score,
|
|
||||||
aeo: overallV2?.aeo.score,
|
|
||||||
geo: overallV2?.geo.score,
|
|
||||||
verified: overall.categories.reduce((n, c) => n + c.verifiedCount, 0),
|
|
||||||
total: AEO_GEO_RUBRIC.criteria.length,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── 5. 하지 않을 것 + 측정 조건 (dark) ── */}
|
|
||||||
<SectionWrapper
|
|
||||||
id="integrity"
|
|
||||||
title="What We Don't Sell"
|
|
||||||
subtitle="근거 없는 것은 팔지 않는다. 이 리포트의 정직성 규약"
|
|
||||||
dark
|
|
||||||
>
|
|
||||||
<div className="grid lg:grid-cols-2 gap-6">
|
|
||||||
<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>
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{result.notDoing.map((n) => (
|
|
||||||
<li key={n.item} className="flex gap-3">
|
|
||||||
<span className="shrink-0 text-[#D4889A] mt-0.5"><CrossFilled size={18} /></span>
|
|
||||||
<div>
|
|
||||||
<p className="text-base font-semibold">{n.item}</p>
|
|
||||||
<p className="ui-note mt-0.5">{n.reason}</p>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{result.conditions.map((c) => (
|
|
||||||
<li key={c} className="flex gap-3">
|
|
||||||
<span className="shrink-0 text-[#9B8AD4] mt-0.5"><CheckFilled size={18} /></span>
|
|
||||||
<p className="ui-body">{c}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="mt-6 rounded-xl p-4 bg-[linear-gradient(to_right,#fff3eb,#e4cfff,#f5f9ff)]">
|
|
||||||
<p className="text-sm text-slate-600 leading-relaxed">
|
|
||||||
점수는 <strong>검증된 항목만</strong>으로 정규화했습니다. 미검증 항목(강남언니·GBP·GA4·로그)을
|
|
||||||
확인하면 점수가 오를 수도, 내릴 수도 있습니다. 이 리포트는 답변엔진 노출을 보장하지 않으며,
|
|
||||||
크롤러 접근·엔티티·콘텐츠 구조라는 <strong>전제조건</strong>의 충족 여부를 진단합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionWrapper>
|
|
||||||
|
|
||||||
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
|
|
||||||
<ClinicInputsPanel clinicId={result.id} />
|
|
||||||
|
|
||||||
{/* ── 6. 채점 기준표 (light) ── */}
|
|
||||||
<SectionWrapper
|
<SectionWrapper
|
||||||
id="rubric"
|
id="rubric"
|
||||||
title="Scoring Rubric"
|
title="Scoring Rubric"
|
||||||
@ -494,14 +384,6 @@ function FindingIcon({ severity }: { severity: 'critical' | 'warning' | 'good' }
|
|||||||
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
|
return <span className="text-[#D4889A] shrink-0"><CrossFilled size={22} /></span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Tag({ children }: { key?: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-600 text-sm font-medium px-2 py-0.5">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
|
function RubricRows({ categoryName, criteria }: { key?: string; categoryName: string; criteria: RubricCriterion[] }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -205,7 +205,7 @@ export default function SiteBuildPage() {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="btn-on-dark justify-center"
|
className="btn-on-dark justify-center"
|
||||||
>
|
>
|
||||||
<EyeFilled size={16} /> 사이트 열어 보기
|
<EyeFilled size={16} /> {state === 'published' || state === 'approved' ? '공개본 사이트 열기' : '초안 사이트 열기'}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user