feat: webforai.kr 공개 전 정리 (비식별 샘플·실제 리포트 비공개·약관)

- 랜딩 샘플 리포트·AI 답변 예시·문의 서식 예시를 가상 A성형외과로 바꾼다 (discovery_sample, 고정 시드 무작위 판정)
- VITE_SITE=discovery 배포는 랜딩·요금·로그인·약관만 연다. 실제 병원 진단 결과와 내부 화면 경로는 홈으로 돌린다
- src/data·src/services 를 부수효과 없는 모듈로 표시해 쓰지 않는 실제 병원 목업이 공개 번들에서 빠지게 한다
- 공개 배포 번들에 GEMINI_API_KEY 를 넣지 않는다
- 헤더 Product·Use Cases 는 공개 배포에서 숨기고, 푸터에 개인정보처리방침·이용약관 페이지를 연결한다
- 기준표 설명의 실제 병원 예시 문구를 일반 예시로 바꾼다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-10-07 11:23:41 +09:00
parent 63e71b73cc
commit f03c6a5b90
14 changed files with 442 additions and 50 deletions

View File

@ -200,7 +200,7 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
type="text" type="text"
value={company} value={company}
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
placeholder="예: 뷰성형외과" placeholder="예: OO성형외과"
className={`${INPUT_CLASS} mb-4`} className={`${INPUT_CLASS} mb-4`}
/> />

View File

@ -35,8 +35,8 @@ export default function Footer() {
Infinite Marketing for Premium Medical Business &amp; Marketing Agency Infinite Marketing for Premium Medical Business &amp; Marketing Agency
</div> </div>
<div className="flex items-center gap-6 text-sm font-medium text-slate-600 shrink-0"> <div className="flex items-center gap-6 text-sm font-medium text-slate-600 shrink-0">
<a href="#" className="hover:text-primary-900 transition-colors">Privacy Policy</a> <Link to="/privacy" className="font-bold text-primary-900 hover:opacity-80 transition-opacity">개인정보처리방침</Link>
<a href="#" className="hover:text-primary-900 transition-colors">Terms of Service</a> <Link to="/terms" className="hover:text-primary-900 transition-colors">이용약관</Link>
<button <button
type="button" type="button"
onClick={() => setContactOpen(true)} onClick={() => setContactOpen(true)}

View File

@ -0,0 +1,61 @@
/**
* LegalLayout: 개인정보처리방침·이용약관 공통 틀.
* 조항은 { title, body } 배열로 받고, body 의 각 줄은 문단 또는 목록 항목으로 그린다.
* '- ' 로 시작하는 줄은 목록 항목이다.
*/
import type { ReactNode } from 'react';
export interface LegalSection {
title: string;
body: (string | ReactNode)[];
}
interface LegalLayoutProps {
title: string;
intro: string;
effectiveDate: string;
sections: LegalSection[];
}
function renderBody(body: LegalSection['body']) {
const out: ReactNode[] = [];
let list: ReactNode[] = [];
const flush = () => {
if (list.length) {
out.push(<ul key={`ul-${out.length}`} className="list-disc pl-5 space-y-1.5">{list}</ul>);
list = [];
}
};
body.forEach((line, i) => {
if (typeof line === 'string' && line.startsWith('- ')) {
list.push(<li key={i}>{line.slice(2)}</li>);
return;
}
flush();
out.push(<p key={i}>{line}</p>);
});
flush();
return out;
}
export default function LegalLayout({ title, intro, effectiveDate, sections }: LegalLayoutProps) {
return (
<section className="min-h-screen pt-32 pb-24 px-6 bg-white [word-break:keep-all]">
<div className="max-w-3xl mx-auto">
<h1 className="font-serif text-3xl md:text-4xl font-bold text-primary-900 mb-4">{title}</h1>
<p className="text-[15px] leading-[1.8] text-slate-600 mb-3">{intro}</p>
<p className="text-sm text-slate-400 mb-12">시행일 {effectiveDate}</p>
<div className="flex flex-col gap-10">
{sections.map((s, i) => (
<div key={s.title}>
<h2 className="text-lg font-bold text-primary-900 mb-3">
제{i + 1}조 {s.title}
</h2>
<div className="flex flex-col gap-2.5 text-[15px] leading-[1.8] text-slate-600">{renderBody(s.body)}</div>
</div>
))}
</div>
</div>
</section>
);
}

View File

@ -24,6 +24,7 @@ import { Link, useLocation } from 'react-router';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import ContactModal from './ContactModal'; import ContactModal from './ContactModal';
import ClinicNav, { clinicIdFromPath } from './ClinicNav'; import ClinicNav, { clinicIdFromPath } from './ClinicNav';
import { DISCOVERY_SITE } from '../lib/site';
export default function Navbar() { export default function Navbar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -45,18 +46,23 @@ export default function Navbar() {
{/* 가운데 메뉴 */} {/* 가운데 메뉴 */}
<div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600"> <div className="hidden md:flex items-center gap-8 text-sm font-medium text-slate-600">
{/* Product·Use Cases 는 INFINITH 랜딩의 섹션 앵커라 그 섹션이 없는 공개 배포(webforai.kr)에서는 뺀다 */}
{!DISCOVERY_SITE && (
<a href="/#solution" className="hover:text-primary-900 transition-colors"> <a href="/#solution" className="hover:text-primary-900 transition-colors">
Product Product
</a> </a>
)}
<Link <Link
to="/pricing?from=header" to="/pricing?from=header"
className="hover:text-primary-900 transition-colors" className="hover:text-primary-900 transition-colors"
> >
Pricing Pricing
</Link> </Link>
{!DISCOVERY_SITE && (
<a href="/#use-cases" className="hover:text-primary-900 transition-colors"> <a href="/#use-cases" className="hover:text-primary-900 transition-colors">
Use Cases Use Cases
</a> </a>
)}
</div> </div>
{/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */} {/* 우측 CTA — Login(Secondary) + 문의하기(Primary) */}

View File

@ -5,7 +5,8 @@
*/ */
import { motion } from 'motion/react'; import { motion } from 'motion/react';
const CITED_DOMAINS = ['viewclinic.com', 'gangnamunni.com', 'youtube.com']; // 예시 카드라 실제 병원 도메인을 쓰지 않는다. 병원 사이트 자리는 일반 명칭으로 둔다.
const CITED_DOMAINS = ['병원 공식 홈페이지', 'gangnamunni.com', 'youtube.com'];
interface AiAnswerMockProps { interface AiAnswerMockProps {
clinicName: string; clinicName: string;
@ -45,12 +46,11 @@ export default function AiAnswerMock({ clinicName, criteriaCount }: AiAnswerMock
{/* 목표 상태의 답변 예시 */} {/* 목표 상태의 답변 예시 */}
<p className="text-[15px] leading-[1.8] text-slate-600 mb-4 break-keep"> <p className="text-[15px] leading-[1.8] text-slate-600 mb-4 break-keep">
모티바 공식 KOL 의료진이 있는{' '} 의료진 14명의 약력을 공개한{' '}
<span className="bg-[#F3F0FF] text-[#4A3A7C] font-semibold px-1.5 py-0.5 rounded-md"> <span className="bg-[#F3F0FF] text-[#4A3A7C] font-semibold px-1.5 py-0.5 rounded-md">
{clinicName} {clinicName}
</span> </span>
는 강남언니 후기 19,316건과 의료진 26명 정보가 공개되어 있으며, 시술별 FAQ와 비교표를 는 후기 7,800여 건이 공개되어 있고, 시술별 FAQ와 회복 일정표를 제공합니다.
제공합니다.
</p> </p>
{/* 인용 도메인 칩 + 목표 상태 라벨 (제거 금지) */} {/* 인용 도메인 칩 + 목표 상태 라벨 (제거 금지) */}

View File

@ -1,5 +1,5 @@
/** /**
* 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷. * 샘플 리포트 섹션. 가상 데이터(discovery_sample)로 만든 예시이며 실제 병원 진단 결과가 아니다.
* 점수·등급·항목 %·항목 수는 전부 discoveryScore(v1 실측)·discoveryScoreV2(AEO/GEO) 계산값에서 렌더 (하드코딩 금지). * 점수·등급·항목 %·항목 수는 전부 discoveryScore(v1 실측)·discoveryScoreV2(AEO/GEO) 계산값에서 렌더 (하드코딩 금지).
* 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다. * 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다.
*/ */
@ -16,28 +16,28 @@ const GRADE_BADGE_STYLE: Record<OverallScore['grade'], string> = {
D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]', D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
}; };
/** keyFindings 압축본 (시안 확정 카피). 수치는 2026-09-07 재검증(v1.1) 스냅샷과 일치한다. */ /** 발견 칩 예시 카피. 특정 병원을 알아볼 수 없도록 고유 명칭을 빼고 수치는 가상 값으로 둔다. */
const FINDING_CHIPS = [ const FINDING_CHIPS = [
{ {
label: '치명', label: '치명',
chipClass: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]', chipClass: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]',
dotColor: '#D4889A', dotColor: '#D4889A',
title: 'AI가 누구인지 확정 못 함', title: 'AI가 누구인지 확정 못 함',
detail: 'MedicalClinic 스키마 없음. 시술 목록 JSON-LD는 문법 오류로 통째로 버려집니다.', detail: 'MedicalClinic 스키마가 없고, 시술 정보 JSON-LD는 문법 오류로 읽히지 않습니다.',
}, },
{ {
label: '보완', label: '보완',
chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]', chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]',
dotColor: '#D4A872', dotColor: '#D4A872',
title: '인용할 문장이 없음', title: '인용할 문장이 없음',
detail: '정의문 0개, HTML 표 0개, FAQ는 프리저베 한 페이지뿐. 답변엔진이 잘라 갈 단락이 없습니다.', detail: '정의문 2개, HTML 표 0개, FAQ는 시술 페이지 한 곳뿐. 답변엔진이 잘라 갈 단락이 적습니다.',
}, },
{ {
label: '강점', label: '강점',
chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]', chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]',
dotColor: '#9B8AD4', dotColor: '#9B8AD4',
title: '표면 자산은 이미 강함', title: '표면 자산은 이미 강함',
detail: '강남언니 후기 19,373건, GBP 4.9점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.', detail: '후기 플랫폼 후기 7,800여 건, 지도 평점 4.7점. 홈페이지가 따라가면 바로 인용 근거가 됩니다.',
}, },
]; ];
@ -62,13 +62,13 @@ export default function SampleReport({ overall, axes, result }: SampleReportProp
transition={{ duration: 0.6 }} transition={{ duration: 0.6 }}
> >
<div className="text-sm font-bold tracking-[0.24em] text-accent mb-4"> <div className="text-sm font-bold tracking-[0.24em] text-accent mb-4">
SAMPLE REPORT · 실제 사례 SAMPLE REPORT · 예시
</div> </div>
<h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep"> <h2 className="font-serif text-3xl md:text-[40px] font-bold leading-tight text-primary-900 mb-3 break-keep">
읽을 수는 있지만, 인용할 근거가 없습니다. 읽을 수는 있지만, 인용할 근거가 없습니다.
</h2> </h2>
<p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep"> <p className="text-[17px] leading-[1.7] text-slate-500 mb-12 break-keep">
실제 1차 진단 결과입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 가상의 A성형외과로 만든 예시 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고,
어디가 강하고 어디가 비어 있는지 항목 단위로 드러냅니다. 어디가 강하고 어디가 비어 있는지 항목 단위로 드러냅니다.
</p> </p>
</motion.div> </motion.div>

View File

@ -279,7 +279,7 @@ export const AEO_GEO_RUBRIC: Rubric = {
id: 'C3', id: 'C3',
category: 'content', category: 'content',
name: '제목 계층 의미성', name: '제목 계층 의미성',
rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "VIEW SELFIE" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.', rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "MAIN VISUAL" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.',
weight: 3, weight: 3,
evidenceGrade: 'measured', evidenceGrade: 'measured',
control: 'direct', control: 'direct',

View File

@ -0,0 +1,61 @@
/**
* 랜딩용 예시 진단 결과 (가상 데이터).
* 실제 병원의 진단 결과가 아니다. 병원명·주소·수치는 모두 비식별 가상 값이고, 항목 판정은 고정 시드(20261007)로 무작위 생성했다.
* 공개 사이트(webforai.kr)에는 실제 병원 리포트를 싣지 않는다 (2026-10-07 haewon 결정).
*/
import type { DiscoveryResult } from '../types/discovery';
export const DISCOVERY_SAMPLE: DiscoveryResult = {
id: 'sample',
clinicName: 'A성형외과',
url: '',
industry: '성형외과 (예시)',
auditedAt: '2026-10-01',
rubricVersion: '2.0',
conditions: ['가상 데이터로 만든 예시 리포트입니다. 실제 병원의 진단 결과가 아닙니다.'],
results: [
{ criterionId: 'A1', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'A2', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'A3', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'A4', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'A5', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'A6', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
{ criterionId: 'A7', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B1', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B2', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B3', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B4', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B5', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B6', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'B7', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C1', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C2', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C3', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C4', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C5', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C6', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C7', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'C8', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'D1', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'D2', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
{ criterionId: 'D3', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'D4', level: 3, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'D5', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'D6', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'E1', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
{ criterionId: 'E2', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
{ criterionId: 'E3', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
{ criterionId: 'E4', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'F1', level: 2, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'F2', level: 1, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'F3', level: 0, evidence: '예시 데이터 (가상 값)' },
{ criterionId: 'F4', level: 'unverified', evidence: '예시 데이터: 병원 계정 접근이 필요한 항목이라 미검증으로 둡니다' },
],
keyFindings: [
{ title: 'AI가 병원을 하나의 기관으로 확정하지 못함', detail: '예시 데이터: 병원 정보를 구조화한 스키마가 없습니다.', severity: 'critical' },
{ title: '인용할 문장이 부족함', detail: '예시 데이터: 정의문·비교표·FAQ가 적습니다.', severity: 'warning' },
{ title: '외부 평판 자산은 있음', detail: '예시 데이터: 후기 플랫폼과 지도 서비스의 공개 정보가 충분합니다.', severity: 'good' },
],
actions: [],
notDoing: [],
};

6
src/lib/site.ts Normal file
View File

@ -0,0 +1,6 @@
/**
* 배포 구분. Vercel 프로젝트 환경변수 VITE_SITE 로 정한다.
* 'discovery' 로 빌드한 배포(webforai.kr)는 공개 사이트라 랜딩·요금·로그인·약관만 연다.
* 실제 병원의 진단 결과와 내부 작업 화면은 이 배포에서 열지 않는다 (2026-10-07 haewon 결정).
*/
export const DISCOVERY_SITE = import.meta.env.VITE_SITE === 'discovery';

View File

@ -1,6 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router';
import App from './App.tsx'; import App from './App.tsx';
import LandingPage from './pages/LandingPage.tsx'; import LandingPage from './pages/LandingPage.tsx';
import PricingPage from './pages/PricingPage.tsx'; import PricingPage from './pages/PricingPage.tsx';
@ -21,20 +21,32 @@ 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';
import PrivacyPage from './pages/PrivacyPage.tsx';
import TermsPage from './pages/TermsPage.tsx';
import { DISCOVERY_SITE } from './lib/site';
import './index.css'; import './index.css';
// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다.
const DISCOVERY_SITE = import.meta.env.VITE_SITE === 'discovery';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<App />}> <Route element={<App />}>
{/* 루트에서 무엇을 그릴지는 배포마다 다르다. VITE_SITE=discovery 로 빌드한 배포(webforai.kr)는 {/* 배포마다 여는 화면이 다르다. VITE_SITE=discovery 로 빌드한 공개 배포(webforai.kr)는 AI Discovery 랜딩을
AI Discovery 랜딩을 루트에 놓고, 값이 없으면 지금까지처럼 INFINITH 랜딩을 놓는다. 루트에 두고 공개 페이지만 연다. 실제 병원 진단 결과·내부 작업 화면 경로는 홈으로 돌린다.
한 코드베이스로 두 사이트를 내되 기존 배포의 동작은 바꾸지 않는다. */} 값이 없으면 지금까지처럼 INFINITH 랜딩과 전체 화면을 연다. */}
<Route index element={DISCOVERY_SITE ? <DiscoveryLandingPage /> : <LandingPage />} /> {DISCOVERY_SITE ? (
<>
<Route index element={<DiscoveryLandingPage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="login" element={<LoginPage />} />
<Route path="privacy" element={<PrivacyPage />} />
<Route path="terms" element={<TermsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</>
) : (
<>
<Route index element={<LandingPage />} />
<Route path="pricing" element={<PricingPage />} /> <Route path="pricing" element={<PricingPage />} />
<Route path="login" element={<LoginPage />} /> <Route path="login" element={<LoginPage />} />
<Route path="report/loading" element={<AnalysisLoadingPage />} /> <Route path="report/loading" element={<AnalysisLoadingPage />} />
@ -54,6 +66,10 @@ createRoot(document.getElementById('root')!).render(
<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 />} />
<Route path="privacy" element={<PrivacyPage />} />
<Route path="terms" element={<TermsPage />} />
</>
)}
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -8,7 +8,7 @@
*/ */
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric'; import { AEO_GEO_RUBRIC } from '../data/aeoGeoRubric';
import { DISCOVERY_VIEWCLINIC } from '../data/discovery_viewclinic'; import { DISCOVERY_SAMPLE } from '../data/discovery_sample';
import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore'; import { scoreDiscovery, nextGradeTarget } from '../lib/discoveryScore';
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';
@ -25,7 +25,8 @@ import ContactModal from '../components/ContactModal';
export default function DiscoveryLandingPage() { export default function DiscoveryLandingPage() {
// 진단 요청은 문의하기와 같은 폼(구글시트 기록 + 메일 알림)으로 받는다. source 로 어느 버튼에서 왔는지 남긴다. // 진단 요청은 문의하기와 같은 폼(구글시트 기록 + 메일 알림)으로 받는다. source 로 어느 버튼에서 왔는지 남긴다.
const [leadSource, setLeadSource] = useState<string | null>(null); const [leadSource, setLeadSource] = useState<string | null>(null);
const result = DISCOVERY_VIEWCLINIC; // 공개 랜딩은 가상 예시 데이터만 쓴다. 실제 병원 진단 결과를 싣지 않는다.
const result = DISCOVERY_SAMPLE;
const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]); const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]);
const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]); const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]);
const axes: AxisSummary[] = [overallV2.aeo, overallV2.geo].map((ax) => ({ const axes: AxisSummary[] = [overallV2.aeo, overallV2.geo].map((ax) => ({

122
src/pages/PrivacyPage.tsx Normal file
View File

@ -0,0 +1,122 @@
/**
* 개인정보처리방침 (/privacy).
* 이 사이트가 실제로 처리하는 것만 적는다: 문의·진단 요청 서식(이름·연락처·회사·문의 내용) → /api/lead(Vercel) →
* Google Apps Script → 구글 시트 저장 + 알림 메일. 방문 분석 쿠키는 아직 쓰지 않는다. GA4 등을 붙이면 제8조를 고친다.
* 구조는 의료 분야 SaaS 방침(메디팔·애프터닥)의 조항 구성을 참고했고, 문장은 이 서비스에 맞춰 새로 썼다.
*/
import LegalLayout, { type LegalSection } from '../components/LegalLayout';
import { COMPANY_LEGAL_NAME, COMPANY_HQ, CONTACT_PHONE } from '../lib/contact';
const PRIVACY_EMAIL = 'o2oteam@o2o.kr';
const SECTIONS: LegalSection[] = [
{
title: '수집하는 개인정보 항목과 수집 방법',
body: [
'회사는 웹사이트의 문의하기 및 AEO, GEO 진단 요청 서식을 통해 아래 항목을 수집합니다.',
'- 필수: 성함, 연락처(전화번호 또는 이메일 중 하나 이상)',
'- 선택: 회사(병원)명, 문의 내용',
'서비스 이용 과정에서 접속 IP 주소, 브라우저 정보, 접속 일시가 웹 호스팅 서버의 로그로 자동 생성될 수 있습니다.',
'회사는 만 14세 미만 아동의 개인정보를 수집하지 않으며, 환자의 건강 정보 등 민감정보를 서식으로 받지 않습니다. 문의 내용에 환자 개인정보를 적지 말아 주십시오.',
],
},
{
title: '개인정보의 수집 및 이용 목적',
body: [
'- 문의 및 진단 요청에 대한 회신과 상담',
'- 요청하신 AEO, GEO 진단의 수행과 결과 전달',
'- 서비스 도입 관련 안내(요청하신 범위에 한함)',
'회사는 위 목적 외의 용도로 개인정보를 이용하지 않으며, 목적이 바뀌는 경우 미리 동의를 받습니다.',
],
},
{
title: '개인정보의 보유 및 이용 기간',
body: [
'회사는 문의 처리가 끝난 날부터 1년간 개인정보를 보관한 뒤 지체 없이 파기합니다. 정보주체가 삭제를 요청하면 그 전이라도 파기합니다.',
'서비스 계약을 체결한 경우에는 관계 법령에 따라 아래 기간 동안 보관합니다.',
'- 계약 또는 청약철회 등에 관한 기록: 5년 (전자상거래 등에서의 소비자보호에 관한 법률)',
'- 대금결제 및 재화 등의 공급에 관한 기록: 5년 (같은 법)',
'- 소비자의 불만 또는 분쟁처리에 관한 기록: 3년 (같은 법)',
'- 웹사이트 방문 기록: 3개월 (통신비밀보호법)',
],
},
{
title: '개인정보의 제3자 제공',
body: ['회사는 정보주체의 개인정보를 제3자에게 제공하지 않습니다. 법령에 특별한 규정이 있거나 정보주체가 동의한 경우에만 예외로 합니다.'],
},
{
title: '개인정보 처리업무의 위탁 및 국외 이전',
body: [
'회사는 서비스 운영을 위해 아래와 같이 개인정보 처리업무를 위탁하며, 수탁자의 서버가 국외에 있어 개인정보가 국외로 이전됩니다.',
'- Google LLC (미국): 문의 내용 저장(Google 스프레드시트) 및 접수 알림 메일 발송. 이전 항목은 제1조의 서식 입력 항목이며, 서식을 제출하는 시점에 네트워크로 전송됩니다. 보유 기간은 제3조와 같습니다.',
'- Vercel Inc. (미국): 웹사이트 호스팅 및 서식 전송 처리. 이전 항목은 서식 입력 항목과 접속 기록이며, 웹사이트 이용 및 서식 제출 시 네트워크로 전송됩니다. 보유 기간은 위탁 계약 종료 시까지입니다.',
'국외 이전을 원하지 않으시면 서식 대신 이메일 또는 전화로 문의하실 수 있습니다. 다만 이 경우에도 회신 메일은 회사의 메일 서비스를 통해 처리됩니다.',
],
},
{
title: '정보주체의 권리와 행사 방법',
body: [
'정보주체는 언제든지 자신의 개인정보에 대한 열람, 정정, 삭제, 처리정지를 요청할 수 있습니다.',
`요청은 이메일(${PRIVACY_EMAIL}) 또는 전화(${CONTACT_PHONE})로 하실 수 있으며, 회사는 본인 확인 후 10일 이내에 조치하고 결과를 알려 드립니다.`,
'법정대리인이나 위임을 받은 사람을 통해서도 권리를 행사할 수 있습니다.',
],
},
{
title: '개인정보의 파기 절차 및 방법',
body: [
'보유 기간이 끝나거나 처리 목적이 달성된 개인정보는 지체 없이 파기합니다.',
'- 전자 파일: 복구할 수 없는 방법으로 영구 삭제',
'- 종이 문서: 분쇄 또는 소각',
],
},
{
title: '쿠키 등 자동 수집 장치',
body: [
'회사는 현재 이 웹사이트에서 광고 또는 방문 분석을 위한 쿠키를 사용하지 않습니다.',
'방문 분석 도구를 도입하는 경우 이 방침을 개정해 수집 항목, 목적, 거부 방법을 미리 알려 드립니다.',
],
},
{
title: '개인정보의 안전성 확보 조치',
body: [
'- 개인정보에 접근할 수 있는 담당자를 최소한으로 지정하고 접근 권한을 관리합니다.',
'- 서식 전송 구간을 HTTPS로 암호화합니다.',
'- 서식 전송에 쓰는 인증 정보는 서버에만 보관하고 웹 페이지에 노출하지 않습니다.',
'- 자동 입력 방지 장치와 전송 횟수 제한으로 무단 접근을 막습니다.',
],
},
{
title: '개인정보 보호책임자',
body: [
'회사는 개인정보 처리에 관한 업무를 총괄하고 정보주체의 불만 처리와 피해 구제를 위해 아래와 같이 개인정보 보호 담당을 두고 있습니다.',
`- 담당: ${COMPANY_LEGAL_NAME} 개인정보 보호 담당`,
`- 이메일: ${PRIVACY_EMAIL}`,
`- 전화: ${CONTACT_PHONE}`,
],
},
{
title: '권익침해 구제 방법',
body: [
'개인정보 침해에 대한 신고나 상담이 필요하시면 아래 기관에 문의하실 수 있습니다.',
'- 개인정보분쟁조정위원회: 1833-6972 (www.kopico.go.kr)',
'- 개인정보침해신고센터: 118 (privacy.kisa.or.kr)',
'- 대검찰청 사이버수사과: 1301 (www.spo.go.kr)',
'- 경찰청 사이버수사국: 182 (ecrm.police.go.kr)',
],
},
{
title: '개인정보처리방침의 변경',
body: ['이 방침은 2026년 10월 7일부터 적용됩니다. 내용이 바뀌면 시행 7일 전부터 웹사이트에 알리며, 정보주체의 권리에 중요한 변경은 30일 전부터 알립니다.'],
},
];
export default function PrivacyPage() {
return (
<LegalLayout
title="개인정보처리방침"
intro={`${COMPANY_LEGAL_NAME}(이하 "회사")는 INFINITH AI Discovery 웹사이트(webforai.kr)를 운영하면서 개인정보 보호법에 따라 정보주체의 개인정보를 보호하고 관련 고충을 원활하게 처리하기 위해 다음과 같이 개인정보처리방침을 둡니다. 회사 소재지: ${COMPANY_HQ}.`}
effectiveDate="2026년 10월 7일"
sections={SECTIONS}
/>
);
}

109
src/pages/TermsPage.tsx Normal file
View File

@ -0,0 +1,109 @@
/**
* 이용약관 (/terms).
* 공개 웹사이트(진단 소개·문의 접수)의 이용 조건을 정한다. 유료 서비스의 범위·요금·기간은 별도 계약이 우선한다.
* 구조는 의료 분야 SaaS 약관(메디팔·애프터닥)의 조항 구성을 참고했고, 문장은 이 서비스에 맞춰 새로 썼다.
*/
import LegalLayout, { type LegalSection } from '../components/LegalLayout';
import { COMPANY_LEGAL_NAME } from '../lib/contact';
const SECTIONS: LegalSection[] = [
{
title: '목적',
body: [`이 약관은 ${COMPANY_LEGAL_NAME}(이하 "회사")가 운영하는 INFINITH AI Discovery 웹사이트(webforai.kr, 이하 "사이트")와 사이트를 통해 제공하는 서비스의 이용 조건, 회사와 이용자의 권리·의무를 정하는 것을 목적으로 합니다.`],
},
{
title: '정의',
body: [
'- "서비스"란 회사가 사이트에서 제공하는 AEO(답변엔진 최적화)·GEO(생성형 검색 최적화) 진단 소개, 진단 요청 및 문의 접수, 그리고 별도 계약에 따라 제공하는 진단·구축·측정 서비스를 말합니다.',
'- "이용자"란 사이트에 접속해 서비스를 이용하는 개인 또는 의료기관 등 사업자를 말합니다.',
'- "진단 결과"란 회사가 기준표에 따라 이용자의 웹사이트와 공개 채널을 측정해 제공하는 점수, 항목별 판정, 개선 권고를 말합니다.',
],
},
{
title: '약관의 효력과 변경',
body: [
'이 약관은 사이트에 게시함으로써 효력이 생깁니다.',
'회사는 관계 법령을 위반하지 않는 범위에서 약관을 바꿀 수 있으며, 바꾸는 경우 시행일 7일 전부터 사이트에 알립니다. 이용자에게 불리한 변경은 30일 전부터 알립니다.',
'이 약관에 정하지 않은 사항은 관계 법령과 일반적인 상관례를 따릅니다.',
],
},
{
title: '서비스의 내용',
body: [
'- 사이트: 서비스 소개, 예시 리포트, 요금 안내, 문의 및 진단 요청 접수',
'- 별도 계약 서비스: 진단 리포트 작성, 공식 답변 허브 구축, 정기 측정 및 보고',
'별도 계약 서비스의 범위, 요금, 기간, 산출물은 회사와 이용자가 맺는 개별 계약에서 정하며, 개별 계약이 이 약관과 다르면 개별 계약이 우선합니다.',
],
},
{
title: '진단 결과의 성격',
body: [
'진단 결과는 측정한 날짜와 측정 조건을 기준으로 한 것이며, 측정 이후 웹사이트나 외부 서비스가 바뀌면 결과도 달라질 수 있습니다.',
'AI 답변엔진(ChatGPT, Gemini, Perplexity 등)의 답변 내용과 노출 여부는 각 서비스 운영사가 정합니다. 회사는 진단과 개선 작업을 통해 AI가 정보를 정확히 읽고 인용할 수 있도록 정비하지만, 특정 답변에 노출되거나 특정 순위에 오르는 것을 보장하지 않습니다.',
'사이트에 실린 예시 리포트는 가상 데이터로 만든 것이며 실제 의료기관의 진단 결과가 아닙니다.',
],
},
{
title: '의료 관련 고지',
body: [
'서비스는 의료기관의 온라인 정보 정비를 돕는 마케팅 서비스이며, 의학적 진단이나 치료 판단을 제공하지 않습니다.',
'의료기관이 서비스 산출물을 광고 목적으로 게시하는 경우, 의료법에 따른 의료광고 사전심의 등 필요한 절차는 해당 의료기관이 책임지고 이행해야 합니다. 회사의 검토는 이 절차를 대신하지 않습니다.',
],
},
{
title: '이용자의 의무',
body: [
'- 문의 및 진단 요청 시 사실에 맞는 정보를 입력해야 합니다.',
'- 권한이 없는 타인의 웹사이트나 정보를 자신의 것처럼 진단 요청해서는 안 됩니다.',
'- 문의 내용에 환자의 개인정보나 건강 정보를 적어서는 안 됩니다.',
'- 사이트의 정상적인 운영을 방해하거나, 자동화된 수단으로 서식을 반복 제출해서는 안 됩니다.',
],
},
{
title: '회사의 의무',
body: [
'회사는 관계 법령과 이 약관을 지키며, 안정적으로 서비스를 제공하기 위해 노력합니다.',
'회사는 개인정보처리방침에 따라 이용자의 개인정보를 보호하며, 이용자의 정당한 의견과 불만은 지체 없이 처리합니다.',
],
},
{
title: '지식재산권',
body: [
'사이트의 글, 디자인, 진단 기준표, 리포트 양식 등에 대한 권리는 회사에 있습니다. 이용자는 회사의 사전 동의 없이 이를 복제, 배포, 2차 가공해 영리 목적으로 이용할 수 없습니다.',
'별도 계약에 따른 산출물의 권리 귀속은 개별 계약에서 정합니다.',
],
},
{
title: '서비스의 변경과 중단',
body: [
'회사는 운영상, 기술상 필요한 경우 사이트의 내용을 바꿀 수 있습니다.',
'설비 점검, 장애, 천재지변 등 부득이한 사유가 있으면 사이트 제공을 일시 중단할 수 있으며, 가능한 경우 미리 알립니다.',
],
},
{
title: '책임의 제한',
body: [
'- 회사는 천재지변, 외부 서비스(호스팅·AI 답변엔진·검색엔진 등)의 장애나 정책 변경 등 회사가 통제할 수 없는 사유로 생긴 손해에 대해 책임을 지지 않습니다.',
'- 회사는 이용자가 입력한 정보의 오류나 이용자의 귀책사유로 생긴 손해에 대해 책임을 지지 않습니다.',
'- 회사는 무료로 제공하는 사이트 정보와 예시 리포트의 이용으로 생긴 손해에 대해 고의 또는 중대한 과실이 없는 한 책임을 지지 않습니다.',
],
},
{
title: '분쟁 해결과 관할',
body: [
'회사와 이용자는 서비스와 관련한 분쟁을 성실히 협의해 해결합니다.',
'협의로 해결되지 않는 분쟁은 대한민국 법을 따르며, 민사소송법에 따른 관할 법원에서 해결합니다.',
],
},
];
export default function TermsPage() {
return (
<LegalLayout
title="이용약관"
intro={`${COMPANY_LEGAL_NAME}가 운영하는 INFINITH AI Discovery 웹사이트의 이용 조건입니다.`}
effectiveDate="2026년 10월 7일"
sections={SECTIONS}
/>
);
}

View File

@ -88,7 +88,17 @@ export default defineConfig(({mode}) => {
}), }),
], ],
define: { define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), // 공개 배포(webforai.kr)에는 이미지 생성 기능이 없으므로 키를 번들에 넣지 않는다. 브라우저로 내려간 값은 공개다.
'process.env.GEMINI_API_KEY': JSON.stringify(isDiscovery ? '' : env.GEMINI_API_KEY),
},
build: {
rollupOptions: {
treeshake: {
// src/data(목업·진단 데이터)와 src/services 는 import 만으로 할 일이 없는 모듈이다. 쓰는 곳이 없으면 번들에서 뺀다.
// 파생 목업의 최상단 객체 펼치기를 부수효과로 보고 실제 병원 목업이 공개 번들에 남던 문제를 막는다.
moduleSideEffects: (id: string) => !/[\\/]src[\\/](data|services)[\\/]/.test(id),
},
},
}, },
resolve: { resolve: {
alias: { alias: {