feat: webforai.kr 공개 기능 스위치 (요금·예시 리포트·Use Cases)

- src/lib/site.ts 의 SHOW_PRICING·SHOW_SAMPLE_REPORT·SHOW_USE_CASES 로 라우트·헤더·랜딩 버튼을 한 곳에서 켜고 끈다. 공개 배포 기본값은 모두 꺼짐
- 리포트 본문을 DiscoveryReportView 로 분리하고, 가상 데이터만 그리는 /discovery/sample 을 만든다
- 예시 리포트 경로는 병원 작업 공간 메뉴 대신 일반 헤더를 쓴다
- Use Cases 데이터(src/data/useCases.ts, 비어 있음)와 섹션을 두고, 사례가 있을 때만 섹션·메뉴가 보인다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-10-07 14:21:53 +09:00
parent a47b792912
commit f1f8e6f6e4
10 changed files with 157 additions and 28 deletions

View File

@ -23,6 +23,8 @@ const STEPS: Step[] = [
/** 작업 공간 경로면 병원 id 를 돌려준다. 아니면 null. */
export function clinicIdFromPath(pathname: string): string | null {
// /discovery/sample 은 병원 작업 공간이 아니라 가상 데이터 예시 리포트라 일반 헤더를 쓴다.
if (/^\/discovery\/sample\/?$/.test(pathname)) return null;
for (const s of STEPS) {
if (s.match.test(pathname)) return pathname.split('/')[2] ?? null;
}

View File

@ -24,7 +24,8 @@ import { Link, useLocation } from 'react-router';
import { ArrowRight } from 'lucide-react';
import ContactModal from './ContactModal';
import ClinicNav, { clinicIdFromPath } from './ClinicNav';
import { DISCOVERY_SITE } from '../lib/site';
import { DISCOVERY_SITE, SHOW_PRICING, SHOW_USE_CASES } from '../lib/site';
import { USE_CASES } from '../data/useCases';
export default function Navbar() {
const { pathname } = useLocation();
@ -46,14 +47,14 @@ export default function Navbar() {
{/* 가운데 메뉴 */}
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
{/* Product·Use Cases 는 INFINITH 랜딩의 섹션 앵커라 그 섹션이 없는 공개 배포(webforai.kr)에서는 뺀다 */}
{/* Product 는 INFINITH 랜딩의 섹션 앵커라 공개 배포(webforai.kr)에서는 뺀다.
Pricing·Use Cases 는 src/lib/site.ts 스위치를 따른다. */}
{!DISCOVERY_SITE && (
<a href="/#solution" className="hover:text-primary-900 transition-colors">
Product
</a>
)}
{/* 요금은 아직 확정 전이라 공개 배포(webforai.kr)에서는 숨긴다 (2026-10-07) */}
{!DISCOVERY_SITE && (
{SHOW_PRICING && (
<Link
to="/pricing?from=header"
className="hover:text-primary-900 transition-colors"
@ -61,7 +62,8 @@ export default function Navbar() {
Pricing
</Link>
)}
{!DISCOVERY_SITE && (
{/* 공개 배포에서는 사례가 있어야 섹션이 생기므로 메뉴도 그때만 보인다 */}
{SHOW_USE_CASES && (!DISCOVERY_SITE || USE_CASES.length > 0) && (
<a href="/#use-cases" className="hover:text-primary-900 transition-colors">
Use Cases
</a>

View File

@ -4,6 +4,8 @@
* 시안: docs/reports/landing/ai_discovery_landing.html (픽셀 기준)
*/
import { motion } from 'motion/react';
import { Link } from 'react-router';
import { SHOW_SAMPLE_REPORT } from '../../lib/site';
import { ArrowRight } from 'lucide-react';
import AbmrLoop from './AbmrLoop';
import type { AxisSummary } from './AbmrLoop';
@ -86,6 +88,15 @@ export default function DiscoveryHero({
AEO, GEO 진단 요청하기
<ArrowRight className="w-5 h-5" />
</button>
{/* 가상 데이터 예시 리포트로만 연결한다. 실제 병원 리포트로 연결하지 않는다 (src/lib/site.ts) */}
{SHOW_SAMPLE_REPORT && (
<Link
to="/discovery/sample"
className="inline-flex items-center justify-center px-10 py-4 text-lg font-medium text-primary-900 bg-white/80 border border-slate-200 rounded-full hover:bg-white transition-colors"
>
샘플 리포트 보기
</Link>
)}
</motion.div>
{/* ABMR loop + AI answer mock */}

View File

@ -0,0 +1,42 @@
/**
* Use Cases 섹션 (공개 랜딩). 사례는 src/data/useCases.ts 에서 읽는다.
* 헤더의 Use Cases 메뉴(/#use-cases)가 이 섹션으로 온다. 사례가 없으면 랜딩에서 그리지 않는다.
*/
import { motion } from 'motion/react';
import type { UseCase } from '../../data/useCases';
export default function UseCasesSection({ cases }: { cases: UseCase[] }) {
return (
<section id="use-cases" className="bg-white px-6 py-24">
<div className="max-w-7xl mx-auto">
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">USE CASES</div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-12 break-keep">
진단 이후 무엇이 달라졌는지
</h2>
<div className="grid md:grid-cols-3 gap-6">
{cases.map((c, index) => (
<motion.div
key={c.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: index * 0.1 }}
className="flex flex-col gap-3 bg-[#F4F6FB] border border-slate-200 rounded-2xl px-7 py-6"
>
<div className="text-[13px] font-bold text-accent">{c.specialty}</div>
<div className="text-[17px] font-semibold text-primary-900 break-keep">{c.clinicLabel}</div>
<p className="ui-note leading-[1.7]">{c.challenge}</p>
<ul className="list-disc pl-5 text-[14.5px] text-slate-600 space-y-1 break-keep">
{c.actions.map((a) => (
<li key={a}>{a}</li>
))}
</ul>
<p className="text-[15px] font-semibold text-primary-900 break-keep">{c.outcome}</p>
<p className="text-[13px] text-slate-400">{c.measuredAt}</p>
</motion.div>
))}
</div>
</div>
</section>
);
}

27
src/data/useCases.ts Normal file
View File

@ -0,0 +1,27 @@
/**
* 공개 랜딩(webforai.kr)의 Use Cases 사례.
*
* 비어 있으면 섹션과 헤더 메뉴가 나오지 않는다. 공개 배포에서 보이려면 사례를 넣고 src/lib/site.ts 의 SHOW_USE_CASES 를 켠다.
* 원칙 (docs/WEBFORAI_FEATURE_FLAGS.md 3단계):
* - 병원은 비식별 표기만 쓴다 (예: "강남 A성형외과"). 실제 병원명·도메인·의료진명·고유 수치는 넣지 않는다.
* 빌드 끝의 공개 번들 검사(plugins/public-bundle-denylist.json)가 실제 병원명을 찾으면 빌드가 실패한다.
* - 수치는 실측값만, 측정일과 함께 쓴다. 효과 보장·최상급 표현을 쓰지 않는다.
* - 병원 동의를 받은 사례만 싣는다.
*/
export interface UseCase {
id: string;
/** 비식별 표기. 예: "강남 A성형외과" */
clinicLabel: string;
/** 진료 분야. 예: "성형외과" */
specialty: string;
/** 진단 전 문제 한 문장 */
challenge: string;
/** 한 일 (2~4개) */
actions: string[];
/** 결과 한 문장. 실측 수치와 측정 시점을 함께 쓴다 */
outcome: string;
/** 측정일 또는 기간. 예: "2026-11 재측정" */
measuredAt: string;
}
export const USE_CASES: UseCase[] = [];

View File

@ -1,6 +1,22 @@
/**
* 배포 구분. Vercel 프로젝트 환경변수 VITE_SITE 로 정한다.
* 'discovery' 로 빌드한 배포(webforai.kr)는 공개 사이트라 랜딩·로그인·약관만 연다. 요금은 확정 전이라 열지 않는다.
* 실제 병원의 진단 결과와 내부 작업 화면은 이 배포에서 열지 않는다 (2026-10-07 haewon 결정).
* 배포 구분과 공개 기능 스위치. Vercel 프로젝트 환경변수 VITE_SITE 로 배포를 정한다.
* 'discovery' 로 빌드한 배포(webforai.kr)는 공개 사이트라 랜딩·로그인·약관만 연다 (2026-10-07 haewon 결정).
*
* 공개 배포에서 기능을 다시 열 때는 아래 SHOW_* 값만 바꾸고 배포한다. 라우트(main.tsx), 헤더(Navbar),
* 랜딩 버튼이 모두 이 값을 본다. 값은 빌드 때 정해지는 상수라 꺼진 기능은 공개 번들에서 통째로 빠진다.
* 켜기 전 조건과 순서는 docs/WEBFORAI_FEATURE_FLAGS.md 에 있다. 빌드 끝에 공개 번들 검사(vite.config.ts)가
* 실제 병원명·비밀 키를 찾으면 빌드를 실패시킨다.
*/
export const DISCOVERY_SITE = import.meta.env.VITE_SITE === 'discovery';
/** 요금(/pricing) 페이지와 헤더 메뉴. 요금 확정 후 공개 배포에서 켠다. */
export const SHOW_PRICING = !DISCOVERY_SITE;
/** 비식별 사례(Use Cases) 섹션과 헤더 메뉴. 공개 배포에서는 src/data/useCases.ts 에 사례가 있어야 보인다. */
export const SHOW_USE_CASES = !DISCOVERY_SITE;
/** 가상 데이터 예시 리포트(/discovery/sample)와 랜딩의 "샘플 리포트 보기" 버튼. */
export const SHOW_SAMPLE_REPORT = !DISCOVERY_SITE;
// 실제 병원 진단 결과(/discovery/:id 등 작업 화면)는 스위치로 열지 않는다.
// 로그인과 데이터베이스 권한 정리가 끝난 뒤 별도 작업으로 연다 (docs/WEBFORAI_FEATURE_FLAGS.md 4단계).

View File

@ -23,7 +23,8 @@ import ImageReviewPage from './pages/ImageReviewPage.tsx';
import SiteBuildPage from './pages/SiteBuildPage.tsx';
import PrivacyPage from './pages/PrivacyPage.tsx';
import TermsPage from './pages/TermsPage.tsx';
import { DISCOVERY_SITE } from './lib/site';
import SampleReportPage from './pages/SampleReportPage.tsx';
import { DISCOVERY_SITE, SHOW_PRICING, SHOW_SAMPLE_REPORT } from './lib/site';
import './index.css';
@ -33,12 +34,14 @@ createRoot(document.getElementById('root')!).render(
<Routes>
<Route element={<App />}>
{/* 배포마다 여는 화면이 다르다. VITE_SITE=discovery 로 빌드한 공개 배포(webforai.kr)는 AI Discovery 랜딩을
루트에 두고 공개 페이지(랜딩·로그인·약관)만 연다. 실제 병원 진단 결과·내부 작업 화면 경로는 홈으로 돌린다.
루트에 두고 공개 페이지(랜딩·로그인·약관)만 연다. 요금·예시 리포트는 src/lib/site.ts 스위치가 켜졌을 때만 연다.
실제 병원 진단 결과·내부 작업 화면 경로는 홈으로 돌린다.
값이 없으면 지금까지처럼 INFINITH 랜딩과 전체 화면을 연다. */}
{DISCOVERY_SITE ? (
<>
<Route index element={<DiscoveryLandingPage />} />
{/* 요금(/pricing)은 확정 전이라 공개 배포에서 열지 않는다. 아래 '*' 규칙으로 홈에 돌아간다 */}
{SHOW_PRICING && <Route path="pricing" element={<PricingPage />} />}
{SHOW_SAMPLE_REPORT && <Route path="discovery/sample" element={<SampleReportPage />} />}
<Route path="login" element={<LoginPage />} />
<Route path="privacy" element={<PrivacyPage />} />
<Route path="terms" element={<TermsPage />} />
@ -61,6 +64,7 @@ createRoot(document.getElementById('root')!).render(
<Route path="clinic/:id" element={<ClinicProfilePage />} />
<Route path="api-dashboard" element={<ApiDashboardPage />} />
<Route path="discovery" element={<DiscoveryLandingPage />} />
<Route path="discovery/sample" element={<SampleReportPage />} />
<Route path="discovery/:id" element={<DiscoveryReportPage />} />
<Route path="actions/:id" element={<ActionPlanPage />} />
<Route path="supporters/:id" element={<SupportersBuildPage />} />

View File

@ -21,6 +21,9 @@ import Roadmap from '../components/discovery/Roadmap';
import HonestyStrip from '../components/discovery/HonestyStrip';
import DiscoveryCta from '../components/discovery/DiscoveryCta';
import ContactModal from '../components/ContactModal';
import UseCasesSection from '../components/discovery/UseCasesSection';
import { USE_CASES } from '../data/useCases';
import { SHOW_USE_CASES } from '../lib/site';
export default function DiscoveryLandingPage() {
// 진단 요청은 문의하기와 같은 폼(구글시트 기록 + 메일 알림)으로 받는다. source 로 어느 버튼에서 왔는지 남긴다.
@ -55,6 +58,7 @@ export default function DiscoveryLandingPage() {
<WhyNow />
<HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} />
<SampleReport overall={overall} axes={axes} result={result} />
{SHOW_USE_CASES && USE_CASES.length > 0 && <UseCasesSection cases={USE_CASES} />}
<Roadmap
axes={axes}
criteriaCount={criteriaCount}

View File

@ -13,6 +13,7 @@ import type {
CategoryId,
CategoryScore,
ControlLevel,
DiscoveryResult,
EvidenceGrade,
EvidenceMethod,
RubricCriterion,
@ -71,20 +72,22 @@ const scoreColor = (pct: number) =>
export default function DiscoveryReportPage() {
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],
);
// 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
if (!result || !overall) {
if (!result) {
return <NoReport clinicId={id ?? ''} />;
}
return <DiscoveryReportView result={result} />;
}
/**
* 리포트 본문. 실제 병원 리포트(/discovery/:id)와 공개 예시 리포트(/discovery/sample)가 함께 쓴다.
* sample 이면 가상 데이터라는 표시를 붙인다. 이 컴포넌트는 실제 병원 목록(DISCOVERY_RESULTS)을 읽지 않는다.
*/
export function DiscoveryReportView({ result, sample = false }: { result: DiscoveryResult; sample?: boolean }) {
const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]);
const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]);
const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
@ -108,14 +111,21 @@ export default function DiscoveryReportPage() {
</h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="text-purple-200 text-base mb-1">{result.industry}</p>
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="text-purple-300 text-base underline underline-offset-4"
>
{result.url}
</a>
{result.url && (
<a
href={result.url}
target="_blank"
rel="noreferrer"
className="text-purple-300 text-base underline underline-offset-4"
>
{result.url}
</a>
)}
{sample && (
<p className="inline-block mt-3 rounded-full bg-white/10 border border-white/15 px-3 py-1 text-sm text-purple-100">
가상 데이터로 만든 예시 리포트입니다. 실제 병원의 진단 결과가 아닙니다.
</p>
)}
<p className="text-white/60 text-sm mt-4">
실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
{' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외

View File

@ -0,0 +1,11 @@
/**
* 공개 예시 리포트 (/discovery/sample).
* 가상 데이터(discovery_sample)만 그린다. 실제 병원 리포트 목록(discoveryResults)은 import 하지 않는다.
* 공개 배포에서는 src/lib/site.ts 의 SHOW_SAMPLE_REPORT 가 true 일 때만 라우트와 랜딩 버튼이 열린다.
*/
import { DiscoveryReportView } from './DiscoveryReportPage';
import { DISCOVERY_SAMPLE } from '../data/discovery_sample';
export default function SampleReportPage() {
return <DiscoveryReportView result={DISCOVERY_SAMPLE} sample />;
}