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:
parent
a47b792912
commit
f1f8e6f6e4
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 */}
|
||||
|
||||
42
src/components/discovery/UseCasesSection.tsx
Normal file
42
src/components/discovery/UseCasesSection.tsx
Normal 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
27
src/data/useCases.ts
Normal 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[] = [];
|
||||
@ -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단계).
|
||||
|
||||
10
src/main.tsx
10
src/main.tsx
@ -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 />} />
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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점 독립 지표 · 미검증 항목은 분모 제외
|
||||
|
||||
11
src/pages/SampleReportPage.tsx
Normal file
11
src/pages/SampleReportPage.tsx
Normal 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 />;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user