Merge pull request 'webforai.kr 공개 전 정리, API 키 서버 이전, 번들 검사 추가' (#1) from feat/webforai-feature-flags into main

Reviewed-on: #1
This commit is contained in:
haewonkam 2026-10-07 06:51:05 +00:00
commit dd11173d81
38 changed files with 1744 additions and 444 deletions

89
api/_studio.js Normal file
View File

@ -0,0 +1,89 @@
/**
* 콘텐츠 스튜디오 서버 공용 모듈 (Vercel 서버 함수 전용, '_' 로 시작해 주소로 열리지 않는다).
*
* 왜 서버에 두는가
* Gemini·Creatomate 키는 브라우저로 내려가면 공개다. 2026-10-07 공개 번들에서 Gemini 키가 발견됐고,
* 새 Gemini 키는 서비스 계정 바인딩 키라 웹사이트(리퍼러) 제한을 걸 수 없다. 그래서 호출을 서버로 옮긴다.
* 브라우저는 채널·포맷·주제 선택값만 보내고, 프롬프트와 영상 구성은 여기서 만든다.
* 임의의 프롬프트·렌더를 대신 실행해 주는 통로가 되지 않도록 선택값은 아래 목록에 있는 것만 받는다.
*
* 채널·포맷 목록은 src/types/studio.ts 의 CHANNEL_OPTIONS 와 같아야 한다 (서버 함수는 src 의 TS 를 불러올 수 없다).
*/
export const CHANNELS = {
youtube: { label: 'YouTube', formats: { shorts: ['Shorts', '9:16'], long_form: ['Long-form', '16:9'] } },
instagram: {
label: 'Instagram',
formats: { reels: ['Reels', '9:16'], carousel: ['Carousel', '1:1'], feed_image: ['Feed Image', '1:1'], stories: ['Stories', '9:16'] },
},
naver_blog: { label: 'Naver Blog', formats: { seo_post: ['SEO Post', '16:9'], faq_post: ['FAQ Post', '16:9'] } },
tiktok: { label: 'TikTok', formats: { short_video: ['Short Video', '9:16'] } },
facebook: { label: 'Facebook', formats: { ad_creative: ['Ad Creative', '1:1'], retarget_content: ['Retarget Content', '16:9'] } },
};
export const PILLARS = ['safety', 'expertise', 'results', 'care'];
/** 이 주소들에서 온 요청만 받는다. 키를 쓰는 유료 API 라 Origin 이 없는 요청(브라우저 밖 호출)도 받지 않는다. */
const ALLOWED_ORIGINS = [
'https://infinith-demo.vercel.app',
'http://localhost:3000',
'http://127.0.0.1:3000',
];
/** 이 프로젝트의 미리보기 배포 주소. 미리보기는 Vercel 로그인(배포 보호) 뒤에 있어 팀원만 연다. */
const PREVIEW_ORIGIN = /^https:\/\/infinith-demo-[a-z0-9]+-o2odev-5530s-projects\.vercel\.app$/;
const isAllowedOrigin = (o) => ALLOWED_ORIGINS.includes(o) || PREVIEW_ORIGIN.test(o);
/**
* CORS·메서드·출처를 확인한다. 통과하면 null, 아니면 이미 응답을 보낸 뒤 true 를 돌려준다.
* @param {string[]} methods 허용 메서드
*/
export function guard(req, res, methods) {
const origin = req.headers.origin || '';
const allowed = isAllowedOrigin(origin);
if (allowed) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', [...methods, 'OPTIONS'].join(', '));
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}
if (req.method === 'OPTIONS') {
res.status(allowed ? 204 : 403).end();
return true;
}
if (!methods.includes(req.method)) {
res.status(405).json({ ok: false, error: 'method not allowed' });
return true;
}
// 같은 출처 GET 은 브라우저가 Origin 을 붙이지 않을 수 있어 Referer 로도 확인한다.
const referer = req.headers.referer || '';
const refOrigin = (referer.match(/^https?:\/\/[^/]+/) || [''])[0];
const sameSite = isAllowedOrigin(refOrigin);
if (!allowed && !sameSite) {
res.status(403).json({ ok: false, error: 'forbidden origin' });
return true;
}
return null;
}
/**
* 브라우저가 보낸 선택값을 목록과 대조한다. 목록 밖이면 null.
* @returns {{ channel: string, channelLabel: string, format: string, formatLabel: string, aspectRatio: string, pillarId: string | null } | null}
*/
export function readSelection(body) {
const b = typeof body === 'string' ? safeParse(body) : body ?? {};
const ch = CHANNELS[b?.channel];
const fmt = ch?.formats[b?.format];
if (!ch || !fmt) return null;
const pillarId = b?.pillarId == null ? null : String(b.pillarId);
if (pillarId !== null && !PILLARS.includes(pillarId)) return null;
return { channel: b.channel, channelLabel: ch.label, format: b.format, formatLabel: fmt[0], aspectRatio: fmt[1], pillarId };
}
export function safeParse(s) {
try {
return JSON.parse(s);
} catch {
return null;
}
}

111
api/creatomate-render.js Normal file
View File

@ -0,0 +1,111 @@
/**
* 콘텐츠 스튜디오 영상 렌더 (Vercel 서버 함수).
* POST { channel, format, pillarId } → { ok, id, status, url? } 렌더 시작
* GET ?id=<renderId> → { ok, id, status, url?, error? } 렌더 상태
*
* Creatomate 키는 서버 환경변수 CREATOMATE_API_KEY 에만 둔다 (예전 VITE_CREATOMATE_API_KEY 는 번들에 들어가 공개됐다).
* 렌더는 수십 초 걸릴 수 있어 서버 함수가 기다리지 않고, 브라우저가 GET 으로 상태를 묻는다.
* 영상 구성은 브라우저가 아니라 여기서 선택값으로 만든다. 배경은 api/_studio.js 주석.
*/
import { guard, readSelection } from './_studio.js';
// 이미지·렌더 요청은 외부 API 응답을 기다리므로 실행 시간 한도를 명시한다.
export const config = { maxDuration: 30 };
const API_BASE = 'https://api.creatomate.com/v1';
const RENDER_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const ASPECT_DIMENSIONS = {
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:5': { width: 1080, height: 1350 },
};
const PILLAR_TITLES = {
safety: { ko: '안전한 수술 시스템', en: 'Safe Surgery System' },
expertise: { ko: '전문 의료진', en: 'Expert Medical Team' },
results: { ko: '자연스러운 결과', en: 'Natural Results' },
care: { ko: '환자 중심 케어', en: 'Patient-Centered Care' },
};
/** 템플릿 없이 만드는 브랜드 슬라이드 영상 구성. 예전 src/services/creatomateVideoGen.ts 의 buildSource 와 같다. */
function buildSource(sel) {
const dims = ASPECT_DIMENSIONS[sel.aspectRatio] ?? ASPECT_DIMENSIONS['16:9'];
const pillar = sel.pillarId ? PILLAR_TITLES[sel.pillarId] : null;
const wide = dims.width > dims.height;
return {
output_format: 'mp4',
width: dims.width,
height: dims.height,
duration: 10,
elements: [
{ type: 'shape', shape: 'rectangle', width: '100%', height: '100%', fill_color: 'linear-gradient(180deg, #0A1128 0%, #1A2B5E 50%, #4F1DA1 100%)' },
{
type: 'text', text: pillar?.ko || 'INFINITH Marketing', font_family: 'Noto Sans KR', font_weight: '700',
font_size: wide ? '72px' : '56px', fill_color: '#FFFFFF', x: '50%', y: '40%', x_anchor: '50%', y_anchor: '50%',
width: '80%', text_align: 'center', animations: [{ type: 'text-appear', duration: 1, split: 'word' }],
},
{
type: 'text', text: pillar?.en || 'Premium Medical Marketing', font_family: 'Noto Sans KR', font_weight: '400',
font_size: wide ? '36px' : '28px', fill_color: 'rgba(255,255,255,0.7)', x: '50%', y: '55%', x_anchor: '50%', y_anchor: '50%',
width: '80%', text_align: 'center', animations: [{ type: 'fade', fade_in: true, duration: 0.8, start: 0.5 }],
},
{
type: 'shape', shape: 'rectangle', width: '120px', height: '4px', fill_color: '#6C5CE7', x: '50%', y: '48%',
x_anchor: '50%', y_anchor: '50%', animations: [{ type: 'wipe', duration: 0.6, start: 0.3 }],
},
{
type: 'text', text: `${sel.channelLabel} · ${sel.formatLabel}`, font_family: 'Noto Sans KR', font_weight: '500',
font_size: '20px', fill_color: 'rgba(255,255,255,0.5)', x: '50%', y: '90%', x_anchor: '50%', y_anchor: '50%', text_align: 'center',
},
],
};
}
/** Creatomate 응답에서 브라우저에 필요한 것만 넘긴다. */
const pick = (r) => ({ ok: true, id: r.id, status: r.status, url: r.url ?? null, error: r.error_message ?? null });
export default async function handler(req, res) {
if (guard(req, res, ['POST', 'GET'])) return;
const key = process.env.CREATOMATE_API_KEY;
if (!key) {
console.error('[api/creatomate-render] CREATOMATE_API_KEY 가 설정되지 않았습니다.');
return res.status(500).json({ ok: false, error: 'unconfigured' });
}
const auth = { Authorization: `Bearer ${key}` };
try {
if (req.method === 'GET') {
const id = String(req.query?.id ?? '');
if (!RENDER_ID.test(id)) return res.status(400).json({ ok: false, error: 'bad id' });
const r = await fetch(`${API_BASE}/renders/${id}`, { headers: auth });
if (!r.ok) {
console.error('[api/creatomate-render] 상태 조회 실패', r.status);
return res.status(502).json({ ok: false, error: 'status failed' });
}
return res.status(200).json(pick(await r.json()));
}
const sel = readSelection(req.body);
if (!sel) return res.status(400).json({ ok: false, error: 'bad selection' });
const r = await fetch(`${API_BASE}/renders`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ source: buildSource(sel) }),
});
if (!r.ok) {
const detail = await r.text().catch(() => '');
console.error('[api/creatomate-render] 렌더 요청 실패', r.status, detail.slice(0, 300));
const status = [401, 429, 400].includes(r.status) ? r.status : 502;
return res.status(status).json({ ok: false, error: `render ${status}` });
}
const renders = await r.json();
if (!renders?.[0]?.id) return res.status(502).json({ ok: false, error: 'no render id' });
return res.status(200).json(pick(renders[0]));
} catch (e) {
console.error('[api/creatomate-render] 연결 실패', e);
return res.status(502).json({ ok: false, error: 'upstream unreachable' });
}
}

82
api/gemini-image.js Normal file
View File

@ -0,0 +1,82 @@
/**
* 콘텐츠 스튜디오 이미지 생성 (Vercel 서버 함수). POST { channel, format, pillarId } → { ok, imageDataUrl, prompt }
*
* Gemini 키는 서버 환경변수 GEMINI_API_KEY 에만 둔다 (VITE_ 접두사·vite.config define 금지).
* 프롬프트는 브라우저가 아니라 여기서 선택값으로 만든다. 배경은 api/_studio.js 주석.
*/
import { guard, readSelection } from './_studio.js';
// 이미지·렌더 요청은 외부 API 응답을 기다리므로 실행 시간 한도를 명시한다.
export const config = { maxDuration: 60 };
const MODEL = 'gemini-2.5-flash-image';
const PILLAR_DESCRIPTIONS = {
safety: 'hospital safety systems, clean surgical rooms, CCTV monitoring, professional medical team, trust and reliability',
expertise: 'medical expertise, advanced surgical equipment, professional doctors, medical certifications, precision and skill',
results: 'natural beautiful results, before and after transformation, patient satisfaction, subtle elegant outcomes',
care: 'patient-centered care, warm consultation, personalized treatment plan, caring medical staff, comfortable clinic interior',
};
const CHANNEL_HINTS = {
youtube: 'YouTube thumbnail style, bold text overlay area, high contrast, eye-catching',
instagram: 'Instagram aesthetic, clean minimalist, lifestyle photography style, warm tones',
naver_blog: 'Korean blog header image, informative, medical illustration, clean layout',
tiktok: 'TikTok cover image, vertical format, trendy, dynamic, youthful',
facebook: 'Facebook ad creative, professional, compelling, clear messaging space',
};
function buildPrompt(sel) {
return [
'Generate a premium medical marketing image for a plastic surgery clinic.',
`Theme: ${sel.pillarId ? PILLAR_DESCRIPTIONS[sel.pillarId] : ''}`,
`Style: ${CHANNEL_HINTS[sel.channel] ?? ''}`,
`Aspect ratio: ${sel.aspectRatio}`,
'Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).',
'Premium, luxurious, trustworthy aesthetic.',
'No text or logos in the image.',
'Photorealistic, high quality, professional medical marketing.',
].filter(Boolean).join(' ');
}
export default async function handler(req, res) {
if (guard(req, res, ['POST'])) return;
const key = process.env.GEMINI_API_KEY;
if (!key) {
console.error('[api/gemini-image] GEMINI_API_KEY 가 설정되지 않았습니다.');
return res.status(500).json({ ok: false, error: 'unconfigured' });
}
const sel = readSelection(req.body);
if (!sel) return res.status(400).json({ ok: false, error: 'bad selection' });
const prompt = buildPrompt(sel);
let upstream;
try {
upstream = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-goog-api-key': key },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { responseModalities: ['TEXT', 'IMAGE'] },
}),
});
} catch (e) {
console.error('[api/gemini-image] 연결 실패', e);
return res.status(502).json({ ok: false, error: 'upstream unreachable' });
}
const data = await upstream.json().catch(() => null);
if (!upstream.ok) {
// 브라우저에는 상태만 넘기고, Google 의 상세 메시지는 서버 로그에만 남긴다.
console.error('[api/gemini-image] 업스트림 오류', upstream.status, JSON.stringify(data?.error ?? {}).slice(0, 300));
const status = upstream.status === 429 ? 429 : upstream.status === 400 ? 400 : 502;
return res.status(status).json({ ok: false, error: status === 429 ? 'rate limited' : 'upstream error' });
}
const parts = data?.candidates?.[0]?.content?.parts ?? [];
const image = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/'))?.inlineData;
if (!image) return res.status(502).json({ ok: false, error: 'no image' });
return res.status(200).json({ ok: true, imageDataUrl: `data:${image.mimeType};base64,${image.data}`, prompt });
}

View File

@ -0,0 +1,45 @@
# webforai.kr 공개 기능 스위치
공개 배포(webforai.kr, `VITE_SITE=discovery`)에서 숨겨 둔 기능을 다시 여는 방법. 2026-10-07 보도자료 공개일 기준.
## 지금 공개된 것
랜딩(`/`), 로그인(`/login`), 개인정보처리방침(`/privacy`), 이용약관(`/terms`). 나머지 경로는 홈으로 돌아간다.
infinith-demo 배포(VITE_SITE 없음)는 모든 화면이 그대로 열린다.
## 스위치 (`src/lib/site.ts`)
| 스위치 | 여는 것 | 켜기 전 조건 | 같이 고칠 것 |
|---|---|---|---|
| `SHOW_PRICING` | `/pricing`, 헤더 Pricing | 요금 확정 | `src/data/pricingTiers.ts` 요금표 |
| `SHOW_SAMPLE_REPORT` | `/discovery/sample`, 랜딩 "샘플 리포트 보기" | 없음 (가상 데이터 `src/data/discovery_sample.ts`) | 필요하면 가상 데이터 문구 |
| `SHOW_USE_CASES` | 랜딩 Use Cases 섹션, 헤더 Use Cases | 병원 동의를 받은 비식별 사례 | `src/data/useCases.ts` 에 사례 추가 (비어 있으면 섹션·메뉴가 나오지 않는다) |
켜는 방법: 값을 `!DISCOVERY_SITE` 에서 `true` 로 바꾸고 커밋·배포한다. 값은 빌드 때 정해지는 상수라 꺼진 기능은 공개 번들에서 통째로 빠진다.
## 공개 번들 검사 (배포 차단)
공개 배포 빌드 끝에 `plugins/vite-plugin-public-bundle-guard.ts` 가 결과물 전체를 검사한다. 아래가 하나라도 있으면 빌드가 실패해 Vercel 배포가 진행되지 않는다.
- 실제 병원명·도메인 (`plugins/public-bundle-denylist.json` 의 `clinics`)
- 비밀 키: Google API 키(AIza·AQ.), Supabase 주소·JWT (`secrets`)
- 실제 병원 파일: `reports/`, `assets/clients/` (`paths`)
새 병원과 계약하거나 진단하면 `public-bundle-denylist.json` 에 이름·도메인을 추가한다. 실패 메시지에 어느 파일에서 무엇이 걸렸는지 나온다.
## 켜기 전 확인 순서
1. 스위치를 켠 상태로 로컬 빌드: `VITE_SITE=discovery npx vite build` → 번들 검사 통과 확인
2. 로컬 미리보기로 화면 확인 (`npx vite preview`, 루트가 아니라 해당 경로)
3. 헤더 메뉴가 빈 곳을 가리키지 않는지 확인
4. 배포 후 라이브에서 같은 경로 확인
## 4단계: 실제 병원 리포트 (스위치로 열지 않음)
`/discovery/:id`, `/actions/:id`, `/supporters/:id`, `/images/:id`, `/build/:id`, `/report/:id` 는 실제 병원 데이터를 보여 준다.
공개 배포에서 열려면 아래가 먼저 끝나야 한다. 스위치를 만들지 않은 이유다.
1. 로그인(Supabase Auth)과 병원별 접근 권한
2. 데이터베이스 익명 읽기 정리: `docs/security/2026-10-07_rls_anon_read_DRAFT.sql` (infinith-demo 화면 영향 확인 후 실행)
3. `src/lib/supabase.ts` 가 공개 번들에 들어가게 되므로 번들 검사 `secrets` 의 Supabase 항목을 어떻게 다룰지 결정
4. 진단 신청 모달(`DiscoveryLeadModal.tsx`, 현재 미사용)은 신청마다 자동 빌드 큐(`supporter_builds`)에 넣는다. 공개 폼에 다시 붙이려면 사람 승인 단계를 먼저 둔다.

View File

@ -0,0 +1,268 @@
{
"exportFormatVersion": 2,
"exportTime": "2026-10-07 12:00:00",
"containerVersion": {
"accountId": "6381018301",
"containerId": "266335040",
"containerVersionId": "0",
"container": {
"accountId": "6381018301",
"containerId": "266335040",
"name": "webforai.kr",
"publicId": "GTM-WQNTZVZH",
"usageContext": [
"WEB"
]
},
"tag": [
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "1",
"name": "GA4 - 기본 태그",
"type": "googtag",
"parameter": [
{
"type": "TEMPLATE",
"key": "tagId",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"2147479553"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "2",
"name": "GA4 이벤트 - generate_lead",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "generate_lead"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
},
{
"type": "LIST",
"key": "eventSettingsTable",
"list": [
{
"type": "MAP",
"map": [
{
"type": "TEMPLATE",
"key": "parameter",
"value": "lead_source"
},
{
"type": "TEMPLATE",
"key": "parameterValue",
"value": "{{DLV - lead_source}}"
}
]
}
]
}
],
"firingTriggerId": [
"10"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "3",
"name": "GA4 이벤트 - click_tel",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "click_tel"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"11"
],
"tagFiringOption": "ONCE_PER_EVENT"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"tagId": "4",
"name": "GA4 이벤트 - click_email",
"type": "gaawe",
"parameter": [
{
"type": "TEMPLATE",
"key": "eventName",
"value": "click_email"
},
{
"type": "TEMPLATE",
"key": "measurementIdOverride",
"value": "G-013Z3BFY35"
}
],
"firingTriggerId": [
"12"
],
"tagFiringOption": "ONCE_PER_EVENT"
}
],
"trigger": [
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "10",
"name": "CE - generate_lead",
"type": "CUSTOM_EVENT",
"customEventFilter": [
{
"type": "EQUALS",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{_event}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "generate_lead"
}
]
}
]
},
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "11",
"name": "링크 클릭 - tel",
"type": "LINK_CLICK",
"filter": [
{
"type": "STARTS_WITH",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{Click URL}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "tel:"
}
]
}
],
"waitForTags": {
"type": "BOOLEAN",
"key": "waitForTags",
"value": "false"
},
"checkValidation": {
"type": "BOOLEAN",
"key": "checkValidation",
"value": "false"
}
},
{
"accountId": "6381018301",
"containerId": "266335040",
"triggerId": "12",
"name": "링크 클릭 - mailto",
"type": "LINK_CLICK",
"filter": [
{
"type": "STARTS_WITH",
"parameter": [
{
"type": "TEMPLATE",
"key": "arg0",
"value": "{{Click URL}}"
},
{
"type": "TEMPLATE",
"key": "arg1",
"value": "mailto:"
}
]
}
],
"waitForTags": {
"type": "BOOLEAN",
"key": "waitForTags",
"value": "false"
},
"checkValidation": {
"type": "BOOLEAN",
"key": "checkValidation",
"value": "false"
}
}
],
"variable": [
{
"accountId": "6381018301",
"containerId": "266335040",
"variableId": "20",
"name": "DLV - lead_source",
"type": "v",
"parameter": [
{
"type": "INTEGER",
"key": "dataLayerVersion",
"value": "2"
},
{
"type": "BOOLEAN",
"key": "setDefaultValue",
"value": "false"
},
{
"type": "TEMPLATE",
"key": "name",
"value": "lead_source"
}
]
}
],
"builtInVariable": [
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "CLICK_URL",
"name": "Click URL"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "PAGE_PATH",
"name": "Page Path"
},
{
"accountId": "6381018301",
"containerId": "266335040",
"type": "EVENT",
"name": "Event"
}
]
}
}

View File

@ -0,0 +1,154 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>webforai.kr 약관 법무 검토</title>
<style>
:root { --ink:#1D0024; --navy:#021341; --violet:#4F1DA1; --line:#E5E7EF; --bg:#F7F8FC; --hi:#B42318; --mid:#B54708; --lo:#175CD3; --ok:#067647; }
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--ink); font-family: Pretendard, -apple-system, "Apple SD Gothic Neo", sans-serif; font-size:16px; line-height:1.75; word-break:keep-all; }
.wrap { max-width:980px; margin:0 auto; padding:40px 20px 80px; }
h1 { font-size:30px; margin:0 0 6px; color:var(--navy); }
h2 { font-size:21px; margin:44px 0 12px; color:var(--navy); border-bottom:2px solid var(--line); padding-bottom:6px; }
h3 { font-size:17px; margin:22px 0 6px; }
.meta { color:#555; font-size:14px; }
.note { background:#fff; border:1px solid var(--line); border-left:4px solid var(--violet); padding:14px 18px; border-radius:8px; margin:16px 0; }
table { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:8px; overflow:hidden; font-size:15px; }
th, td { text-align:left; vertical-align:top; padding:10px 12px; border-bottom:1px solid var(--line); }
th { background:#EEF0F7; color:var(--navy); font-weight:700; }
.badge { display:inline-block; font-weight:800; font-size:14px; padding:2px 10px; border-radius:999px; color:#fff; white-space:nowrap; }
.hi { background:var(--hi); } .mid { background:var(--mid); } .lo { background:var(--lo); } .ok { background:var(--ok); }
.card { background:#fff; border:1px solid var(--line); border-radius:10px; padding:16px 20px; margin:14px 0; }
.card h3 { margin-top:0; }
.fix { background:#F4F3FE; border-radius:8px; padding:10px 14px; margin-top:8px; font-size:15px; }
code { background:#EEF0F7; padding:1px 6px; border-radius:4px; font-size:14px; }
a { color:var(--violet); }
ul { margin:6px 0; padding-left:22px; }
.src li { font-size:14px; }
</style>
</head>
<body>
<div class="wrap">
<h1>webforai.kr 개인정보처리방침·이용약관 법무 검토</h1>
<p class="meta">검토일 2026-10-07 · 대상: <code>webforai.kr/privacy</code>, <code>webforai.kr/terms</code> (2026-10-07 게시본) · 작성: Claude (웹 검색 근거 기반 1차 검토)</p>
<div class="note">
이 문서는 공개 법령 해설과 감독기관 자료를 근거로 한 1차 검토입니다. 법률 자문을 대신하지 않습니다.
"반드시 고칠 것"은 법령 문언상 필수 기재사항이 빠진 경우만 넣었고, 해석이 갈리는 부분은 "확인 필요"로 분리했습니다.
최종 게시 전에는 개인정보·의료 분야 변호사 검토를 권합니다.
</div>
<h2>요약</h2>
<table>
<tr><th style="width:120px">등급</th><th>건수</th><th>내용</th></tr>
<tr><td><span class="badge hi">반드시 고칠 것</span></td><td>3</td><td>국외 이전 필수 기재사항 누락 · 개인정보 보호책임자 표기 요건 미충족 · 개인 Gmail 계정에서 고객 정보 처리</td></tr>
<tr><td><span class="badge mid">권장</span></td><td>4</td><td>처리 목적별 법적 근거 기재 · AI 기본법 사전 고지 · 이행보조자 면책 문구 · 거래기록 보관 근거 법령</td></tr>
<tr><td><span class="badge lo">확인 필요</span></td><td>2</td><td>분석 쿠키(GA4)의 처리 근거 · 수집 시점 안내 문구</td></tr>
<tr><td><span class="badge ok">적합</span></td><td>-</td><td>필수 기재 13개 항목 중 대부분, 의료 관련 고지, 재판관할, 약관 변경 고지 기간, 처리방침 명칭 구분 표시</td></tr>
</table>
<h2>1. 반드시 고칠 것</h2>
<div class="card">
<h3><span class="badge hi">필수</span> ① 국외 이전 고지에 이전받는 자의 연락처와 거부 효과가 빠져 있음 (개인정보처리방침 제5조)</h3>
<p><b>근거:</b> 개인정보 보호법 제28조의8 제2항. 처리방침 공개를 근거로 국외 이전을 하려면 이전 항목, 국가·시기·방법, <b>이전받는 자의 성명(법인은 명칭과 연락처)</b>, 이용 목적과 보유·이용 기간, <b>거부 방법·절차와 거부의 효과</b>를 모두 적어야 합니다.</p>
<p><b>현재:</b> 항목·국가·시기·방법·목적·기간은 있으나 Google·Vercel의 연락처가 없고, 거부 방법은 "이메일 또는 전화로 문의"뿐이며 거부 효과(서비스 이용 제한 여부)가 없습니다. 이전 근거 조항(제28조의8 제1항 제3호 가목)도 적혀 있지 않습니다.</p>
<div class="fix"><b>수정안:</b>
<ul>
<li>Google LLC: 문의 구글코리아 유한회사, 02-722-7778, data-access-requests@google.com (Google 한국 거주자용 개인정보 추가 정보 페이지 기준)</li>
<li>Vercel Inc.: privacy@vercel.com, 440 N Barranca Avenue #4133, Covina, CA 91723, United States (Vercel 개인정보 고지 기준)</li>
<li>거부 방법·효과: "서식 대신 이메일(o2oteam@o2o.kr)이나 전화로 문의하면 서식 저장(Google)으로의 이전을 거부할 수 있습니다. 웹사이트 접속 자체는 호스팅(Vercel)을 거치므로, 접속 기록 이전을 거부하려면 웹사이트 이용을 중단해야 합니다. 분석 쿠키는 제8조 방법으로 거부할 수 있고, 거부해도 이용 제한은 없습니다."</li>
<li>근거 명시: "정보주체와의 계약 체결·이행을 위하여 필요한 처리위탁·보관으로서 이 방침에 공개한 경우(법 제28조의8 제1항 제3호 가목)"</li>
</ul>
</div>
</div>
<div class="card">
<h3><span class="badge hi">필수</span> ② 개인정보 보호책임자 표기가 요건에 맞지 않음 (제10조)</h3>
<p><b>근거:</b> 개인정보 보호법 시행령 제31조. 처리방침에는 보호책임자의 <b>성명</b> 또는 개인정보 보호업무·고충 처리를 맡는 <b>부서의 명칭</b>과 전화번호 등 연락처를 적어야 합니다. 또한 법 제31조에 따라 보호책임자를 실제로 지정해야 합니다.</p>
<p><b>현재:</b> "(주)AI오투오 개인정보 보호 담당"으로 적혀 있어 성명도 실제 부서명도 아닙니다. 열람 청구를 받는 부서도 따로 적혀 있지 않습니다.</p>
<div class="fix"><b>수정안:</b> 회사가 지정한 보호책임자의 성명·직책, 또는 실제 담당 부서명(예: "경영지원팀")과 전화·이메일을 적고, "열람청구 접수·처리 부서: 위와 같음"을 덧붙입니다. <b>어느 사람·부서로 할지는 회사가 정해야 합니다.</b></div>
</div>
<div class="card">
<h3><span class="badge hi">필수</span> ③ 고객 정보가 개인 Gmail 계정(aio2odev@gmail.com)의 시트에 쌓임</h3>
<p><b>근거:</b> 개인정보 보호법 제26조(업무위탁)는 위탁자와 수탁자 사이에 처리 목적·안전조치·재위탁 제한 등을 문서로 정하도록 합니다. 처리방침 제5조는 Google을 "수탁자"로 적었지만, 개인용 Gmail 계정은 기업용 데이터 처리 계약이 붙는 구조가 아니어서 이 표기를 뒷받침하기 어렵습니다. 문의 서식은 이름·연락처를 받으므로 회사 계정에서 관리되어야 합니다.</p>
<div class="fix"><b>수정안:</b> 문의 수신 시트와 Apps Script를 회사 계정으로 옮깁니다(예: 회사 Google Workspace, 또는 회사가 쓰는 Microsoft 365의 Excel·Power Automate). 옮긴 뒤 처리방침의 수탁자 표기를 실제 계약 주체에 맞춥니다. <b>어느 계정으로 옮길지는 결정이 필요합니다.</b></div>
</div>
<h2>2. 권장</h2>
<div class="card">
<h3><span class="badge mid">권장</span> ④ 처리 목적별 법적 근거를 함께 적기 (제1·2조)</h3>
<p><b>근거:</b> 개인정보보호위원회 「개인정보 처리방침 작성지침」(2025.4 개정)은 처리 목적별로 동의, 계약 이행, 법령 등 근거를 구분해 적도록 안내합니다. 개인정보 보호법 제15조 제1항 제4호(2023 개정)는 "계약을 체결하는 과정에서 정보주체의 요청에 따른 조치"에 필요한 경우 동의 없이 수집할 수 있게 합니다.</p>
<div class="fix"><b>수정안:</b> "문의·진단 요청 회신: 정보주체의 요청에 따른 조치 이행(법 제15조 제1항 제4호)" · "서비스 계약 이행: 계약 이행(같은 호)" · "거래기록 보관: 관계 법령(같은 항 제2호)".</div>
</div>
<div class="card">
<h3><span class="badge mid">권장</span> ⑤ AI 기본법에 따른 생성형 AI 사전 고지 (이용약관)</h3>
<p><b>근거:</b> 「인공지능 발전과 신뢰 기반 조성 등에 관한 기본법」(2026.1.22 시행) 제31조. 생성형 AI를 이용한 제품·서비스를 제공할 때 그 사실을 이용자에게 미리 알리고(이용약관 등), 결과물이 생성형 AI로 만들어졌음을 표시해야 합니다. 정부는 시행 초기 1년 이상 과태료를 부과하지 않는 계도기간을 운영 중이며, 위반 시 과태료는 1회 500만 원부터입니다.</p>
<p><b>현재:</b> 이용약관에 AI 사용 고지가 없습니다. 진단 리포트 작성, 공식 답변 허브 콘텐츠 작성, 이미지·영상 생성(데모 스튜디오)에 생성형 AI를 씁니다.</p>
<div class="fix"><b>수정안:</b> 이용약관에 조항 추가. "서비스의 일부(진단 리포트 문안, 공식 답변 허브 콘텐츠 초안, 이미지·영상)는 생성형 인공지능을 이용해 만들어지며, 회사는 해당 결과물에 그 사실을 표시합니다. 의료 관련 내용은 의료기관 확인을 거쳐 게시합니다." 산출물(공식 답변 허브 글 등)에도 표시 방식을 정해야 합니다.</div>
</div>
<div class="card">
<h3><span class="badge mid">권장</span> ⑥ 외부 서비스 장애 면책 문구를 좁히기 (이용약관 제11조)</h3>
<p><b>근거:</b> 약관의 규제에 관한 법률 제7조. 사업자·<b>이행보조자</b>의 고의·중과실 책임을 배제하거나, 상당한 이유 없이 사업자의 손해배상 범위를 제한하는 조항은 무효입니다.</p>
<p><b>현재:</b> "외부 서비스(호스팅·AI 답변엔진·검색엔진 등)의 장애나 정책 변경 등 회사가 통제할 수 없는 사유"로 면책합니다. 호스팅처럼 회사가 고른 업체는 이행보조자로 볼 여지가 있습니다.</p>
<div class="fix"><b>수정안:</b> "…회사의 고의 또는 과실 없이 발생한 외부 서비스의 장애나 정책 변경으로 생긴 손해"로 한정합니다. AI 답변엔진·검색엔진의 노출 변화처럼 성질상 통제할 수 없는 부분은 제5조(진단 결과의 성격)에 이미 있어 유지합니다.</div>
</div>
<div class="card">
<h3><span class="badge mid">권장</span> ⑦ 거래기록 보관 근거 법령 바로잡기 (개인정보처리방침 제3조)</h3>
<p><b>근거:</b> 「전자상거래 등에서의 소비자보호에 관한 법률」의 보관 의무는 전자상거래·통신판매 거래를 대상으로 합니다. 이 서비스는 병원(사업자)과의 개별 계약이 중심이라 근거로 맞지 않을 수 있습니다.</p>
<div class="fix"><b>수정안:</b> 계약·대금 기록은 「국세기본법」(장부·증빙서류 5년) 등 실제로 적용되는 법령으로 적고, 전자상거래법 문구는 온라인 결제 등 해당 거래가 생길 때 추가합니다. 웹사이트 방문기록 3개월(통신비밀보호법)은 유지해도 무방합니다.</div>
</div>
<h2>3. 확인 필요 (해석이 갈림)</h2>
<div class="card">
<h3><span class="badge lo">확인</span> ⑧ 분석 쿠키(GA4)의 처리 근거</h3>
<p>국내에는 분석 쿠키에 사전 동의 배너를 요구하는 명문 규정이 없고, 처리방침에 수집 항목·목적·거부 방법을 공개하는 방식이 관행입니다(현재 제8조가 이 방식). 다만 쿠키 식별자와 결합된 방문 기록을 개인정보로 볼지, 근거를 "정당한 이익(법 제15조 제1항 제6호)"으로 둘지는 해석이 갈립니다. 해외 이용자(특히 EU) 비중이 커지면 동의 배너가 필요할 수 있습니다.</p>
</div>
<div class="card">
<h3><span class="badge lo">확인</span> ⑨ 문의 서식의 수집 시점 안내</h3>
<p>제15조 제1항 제4호로 동의 없이 받을 수 있지만, 서식 하단의 "입력 정보는 문의 회신 용도로만 사용합니다" 옆에 처리방침 링크와 "해외 서버(Google·Vercel)에 저장" 한 줄을 붙이면 투명성이 높아집니다(작성지침의 알기 쉬운 고지 원칙).</p>
</div>
<h2>4. 적합으로 본 항목</h2>
<table>
<tr><th>항목</th><th>근거</th><th>판단</th></tr>
<tr><td>처리 목적·항목·보유기간·파기·제3자 제공·정보주체 권리(10일 내 조치)·안전성 확보조치·자동수집장치·권익침해 구제·변경 고지</td><td>개인정보 보호법 제30조, 시행령 제31조</td><td>기재됨</td></tr>
<tr><td>처리방침 명칭을 다른 링크와 구분해 표시(푸터 굵게)</td><td>시행령 제31조 공개 방법</td><td>적합</td></tr>
<tr><td>만 14세 미만·민감정보 미수집, 문의에 환자 정보 금지 안내</td><td>법 제22조의2, 제23조</td><td>적합</td></tr>
<tr><td>의료광고 사전심의 책임은 의료기관에 있음을 고지</td><td>의료법 제56조 제1항(광고 주체), 제57조(사전심의)</td><td>적합. 회사는 의료광고의 주체가 될 수 없으므로 산출물 게시 책임 구분이 맞음</td></tr>
<tr><td>AI 답변 노출을 보장하지 않는다는 고지</td><td>의료법 제56조 제2항(효과 보장 금지 취지), 표시광고법</td><td>적합</td></tr>
<tr><td>재판관할: 민사소송법에 따른 관할 법원</td><td>약관규제법 제14조(고객에게 부당하게 불리한 관할 합의 금지)</td><td>적합</td></tr>
<tr><td>약관 변경 7일 전, 불리한 변경 30일 전 고지</td><td>약관규제법 제3조 취지, 업계 표준약관</td><td>적합</td></tr>
<tr><td>권익침해 구제 기관 연락처 (1833-6972, 118, 1301, 182)</td><td>작성지침</td><td>적합</td></tr>
</table>
<h2>5. 다음 단계 제안</h2>
<ul>
<li>①⑥⑦④⑤ 문구 수정은 바로 반영 가능 (페이지 2개, 배포 1회)</li>
<li>②는 보호책임자(사람 또는 부서) 지정, ③은 문의 수신 계정 이전 결정이 먼저 필요</li>
<li>⑧⑨는 변호사 검토 시 함께 질의</li>
</ul>
<h2>근거 자료</h2>
<ul class="src">
<li>개인정보보호위원회, 개인정보 처리방침 작성지침 개정(2025.4): <a href="https://www.daeryunlaw.com/newsletter/news/151">대륜 뉴스레터</a>, <a href="https://www.newsseoul.co.kr/news/view/1065585619471455">뉴스서울</a></li>
<li>개인정보 보호법 제28조의8 국외 이전: <a href="https://itwiki.kr/w/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4_%EB%B3%B4%ED%98%B8%EB%B2%95_%EC%A0%9C28%EC%A1%B0%EC%9D%988">조문</a>, <a href="https://www.nepla.ai/wiki/it-정보-방송통신/개인정보-위치정보-신용정보/개인정보의-국외이전-mo59y3rr81vj">해설</a></li>
<li>개인정보 보호법 시행령 제31조: <a href="https://itwiki.kr/w/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4_%EB%B3%B4%ED%98%B8%EB%B2%95_%EC%8B%9C%ED%96%89%EB%A0%B9_%EC%A0%9C31%EC%A1%B0">조문</a></li>
<li>개인정보 보호법 제15조 제1항 제4호(2023 개정): <a href="https://korea.legal/wiki/%ea%b0%9c%ec%9d%b8%ec%a0%95%eb%b3%b4-%eb%b3%b4%ed%98%b8%eb%b2%95-%ec%a0%9c15%ec%a1%b0/">조문</a></li>
<li>약관의 규제에 관한 법률 제7조: <a href="https://korea.legal/wiki/%ec%95%bd%ea%b4%80%ec%9d%98-%ea%b7%9c%ec%a0%9c%ec%97%90-%ea%b4%80%ed%95%9c-%eb%b2%95%eb%a5%a0-%ec%a0%9c7%ec%a1%b0/">조문</a></li>
<li>의료법 제56조·제57조: <a href="https://cheongchul.com/blog/의료광고-의료법-변호사-의료광고에-적용되는-법령(1)-의료법">해설</a></li>
<li>AI 기본법 제31조 투명성 의무: <a href="https://www.shinkim.com/kor/media/newsletter/pdf/3142">세종 뉴스레터</a>, <a href="https://www.skax.co.kr/insight/trend/3666">SK AX 해설</a></li>
<li>Google 한국 거주자용 개인정보 추가 정보: <a href="https://policies.google.com/privacy/additional?gl=kr">policies.google.com</a></li>
<li>Vercel 개인정보 고지: <a href="https://vercel.com/legal/privacy-notice">vercel.com/legal/privacy-notice</a></li>
</ul>
</div>
</body>
</html>

View File

@ -0,0 +1,43 @@
-- ════════════════════════════════════════════════════════════════════════
-- 초안 (실행하지 않음) · 2026-10-07
-- 익명(anon) 키로 실제 병원 데이터를 읽을 수 있는 RLS 정책 정리
--
-- 확인한 사실 (2026-10-07, anon 키로 행 수만 조회):
-- marketing_reports 103 · clinics 12 · channel_snapshots 189 · analysis_runs 53
-- supporter_builds 3 · supporter_inputs 2 행이 anon 키로 읽힌다.
-- anon 키는 infinith-demo 번들에 공개로 들어 있다 (webforai.kr 번들에서는 2026-10-07 c2e7d5a 로 제거).
--
-- 실행 전 확인할 것 (haewon 결정 필요):
-- 1. infinith-demo 의 리포트·플랜·스튜디오·병원 확인 화면(/report, /plan, /clinic, /supporters, /build)은
-- anon 키로 이 테이블을 읽는다. 아래를 실행하면 그 화면들이 빈 화면이 된다.
-- 대안: 로그인(Supabase Auth) 도입 후 authenticated 로 바꾸거나, 읽기를 Edge Function(service role) 뒤로 옮긴다.
-- 2. 운영 DB 변경이다. 실행 전에 영향 범위를 확인받고, Supabase 대시보드 SQL 편집기에서 실행한다.
-- 3. 되돌리기: 각 정책을 원래 마이그레이션의 CREATE POLICY 문으로 다시 만들면 된다 (파일명은 아래 주석).
-- ════════════════════════════════════════════════════════════════════════
begin;
-- 20260330_create_tables.sql
drop policy if exists "anon_read_reports" on public.marketing_reports;
-- 20260405_saas_schema_v3.sql
drop policy if exists "anon_read_clinics" on public.clinics;
drop policy if exists "anon_read_runs" on public.analysis_runs;
drop policy if exists "anon_read_snapshots" on public.channel_snapshots;
drop policy if exists "anon_read_screenshots" on public.screenshots;
drop policy if exists "anon_read_plans" on public.content_plans;
drop policy if exists "anon_read_performance" on public.performance_metrics;
drop policy if exists "anon_read_content_perf" on public.content_performance;
drop policy if exists "anon_read_adjustments" on public.strategy_adjustments;
-- 20260406_clinic_registry.sql
drop policy if exists "public_read_registry" on public.clinic_registry;
-- 20260907_supporter_builds.sql
drop policy if exists "anon can read builds" on public.supporter_builds;
drop policy if exists "anon can read inputs" on public.supporter_inputs;
-- 스토리지 버킷의 공개 읽기(20260407_screenshots_storage.sql "public_read_screenshots")는
-- 병원 채널 스크린샷 이미지다. 버킷 공개 여부와 함께 별도로 결정한다.
commit;

View File

@ -0,0 +1,29 @@
{
"_comment": "공개 배포(webforai.kr) 빌드 결과에 있으면 빌드를 실패시키는 목록. plugins/vite-plugin-public-bundle-guard.ts 가 읽는다. 계약·공개 상황이 바뀌면 이 파일만 고친다. pattern 은 정규식(대소문자 무시)이다.",
"clinics": [
{ "label": "뷰성형외과", "pattern": "뷰성형외과|viewclinic|viewplastic|ViewclinicKR" },
{ "label": "원진성형외과", "pattern": "원진성형외과|k-wonjin|wonjin" },
{ "label": "바노바기", "pattern": "바노바기|banobagi" },
{ "label": "그랜드성형외과", "pattern": "그랜드성형외과|grandsurgery|grand_korea" },
{ "label": "서울이룸", "pattern": "이룸성형외과|seoulips" },
{ "label": "TS성형외과", "pattern": "TS성형외과|tsprs" },
{ "label": "태하", "pattern": "태하성형외과|taeha" },
{ "label": "JK성형외과", "pattern": "JK성형외과|jkplastic" },
{ "label": "ID병원", "pattern": "ID병원|idhospital" },
{ "label": "오라클피부과", "pattern": "오라클피부과" },
{ "label": "차앤박", "pattern": "차앤박" },
{ "label": "톡스앤필", "pattern": "톡스앤필" },
{ "label": "더스완성형외과", "pattern": "더스완|sswan" },
{ "label": "실측 고유값", "pattern": "프리저베|19,316|19,373" }
],
"secrets": [
{ "label": "Google API 키(AIza)", "pattern": "AIza[0-9A-Za-z_\\-]{35}" },
{ "label": "Google API 키(AQ.)", "pattern": "AQ\\.[0-9A-Za-z_\\-]{20,}" },
{ "label": "Supabase 프로젝트 주소", "pattern": "[a-z0-9]{20}\\.supabase\\.co" },
{ "label": "JWT(Supabase 키 등)", "pattern": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\\.[A-Za-z0-9_\\-]{10,}" }
],
"paths": [
{ "label": "실제 병원 리포트 파일", "pattern": "^reports/" },
{ "label": "병원 채널 스크린샷", "pattern": "^assets/clients/" }
]
}

View File

@ -0,0 +1,105 @@
/**
* 공개 배포 번들 검사.
*
* 공개 배포(webforai.kr) 빌드 결과에 실제 병원명·비밀 키·실제 병원 파일이 남아 있으면 빌드를 실패시킨다.
* 빌드가 실패하면 Vercel 배포도 진행되지 않는다. 기능을 다시 켤 때(src/lib/site.ts) 실제 데이터가 딸려 나가는
* 사고를 사람이 놓쳐도 여기서 막는다. 2026-10-07 공개 전 점검에서 손으로 반복한 검사를 옮긴 것이다.
*
* 금지 목록은 plugins/public-bundle-denylist.json 에 있다.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
interface Rule {
label: string;
pattern: string;
}
interface Denylist {
clinics: Rule[];
secrets: Rule[];
paths: Rule[];
}
const TEXT_EXT = new Set(['.js', '.mjs', '.css', '.html', '.json', '.txt', '.xml', '.svg', '.map', '.webmanifest']);
function listFiles(dir: string, base = dir): string[] {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const full = path.join(dir, e.name);
return e.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
});
}
/** 위반 목록을 돌려준다. 비어 있으면 통과다. 비밀 키 값은 그대로 출력하지 않고 앞 6자만 보인다. */
export function findPublicBundleViolations(outDir: string, denylistPath?: string): string[] {
const here = path.dirname(fileURLToPath(import.meta.url));
const list: Denylist = JSON.parse(fs.readFileSync(denylistPath ?? path.join(here, 'public-bundle-denylist.json'), 'utf8'));
const files = listFiles(outDir);
const out: string[] = [];
for (const rule of list.paths) {
const re = new RegExp(rule.pattern, 'i');
const hits = files.filter((f) => re.test(f));
if (hits.length) out.push(`[파일] ${rule.label}: ${hits.slice(0, 3).join(', ')}${hits.length > 3 ? ` 외 ${hits.length - 3}개` : ''}`);
}
const textRules = [
...list.clinics.map((r) => ({ ...r, kind: '병원명', secret: false })),
...list.secrets.map((r) => ({ ...r, kind: '비밀 키', secret: true })),
];
for (const file of files) {
if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue;
const text = fs.readFileSync(path.join(outDir, file), 'utf8');
for (const rule of textRules) {
const m = text.match(new RegExp(rule.pattern, 'i'));
if (!m) continue;
const shown = rule.secret ? `${m[0].slice(0, 6)}…` : m[0];
out.push(`[${rule.kind}] ${rule.label}: "${shown}" (${file})`);
}
}
return out;
}
/** 위반이 있으면 빌드를 멈춘다. */
export function assertPublicBundleClean(outDir: string): void {
const violations = findPublicBundleViolations(outDir);
if (violations.length) {
throw new Error(
`공개 배포 번들 검사 실패 (${violations.length}건). 실제 병원 정보나 비밀 키가 공개 번들에 들어갔습니다.\n` +
violations.map((v) => ` - ${v}`).join('\n') +
'\n 목록: plugins/public-bundle-denylist.json · 스위치: src/lib/site.ts',
);
}
}
// ─── 모든 배포 공통: 서버 전용 비밀값이 번들에 들어갔는지 ─────────────────────────────
// 이름에 KEY·SECRET·TOKEN·PASSWORD·SERVICE_ROLE 이 들어간 환경변수 값을 서버 전용으로 본다.
// 공개를 전제로 만든 값만 예외로 둔다. 값 자체를 대조하므로 키 형식이 바뀌어도 잡는다.
const SECRET_NAME = /(KEY|SECRET|TOKEN|PASSWORD|SERVICE_ROLE)/;
const PUBLIC_BY_DESIGN = new Set(['VITE_SUPABASE_ANON_KEY']);
/** 위반한 환경변수 이름 목록을 돌려준다. 값은 출력하지 않는다. */
export function findSecretValueLeaks(outDir: string, env: Record<string, string>): string[] {
const secrets = Object.entries(env).filter(
([name, value]) => SECRET_NAME.test(name) && !PUBLIC_BY_DESIGN.has(name) && typeof value === 'string' && value.length >= 16,
);
if (!secrets.length) return [];
const leaks = new Set<string>();
for (const file of listFiles(outDir)) {
if (!TEXT_EXT.has(path.extname(file).toLowerCase())) continue;
const text = fs.readFileSync(path.join(outDir, file), 'utf8');
for (const [name, value] of secrets) if (text.includes(value)) leaks.add(`${name} (${file})`);
}
return [...leaks];
}
/** 서버 전용 비밀값이 번들에 있으면 빌드를 멈춘다. 모든 배포(webforai.kr·infinith-demo)에 건다. */
export function assertNoSecretValues(outDir: string, env: Record<string, string>): void {
const leaks = findSecretValueLeaks(outDir, env);
if (leaks.length) {
throw new Error(
`번들 비밀값 검사 실패 (${leaks.length}건). 서버 전용 키가 브라우저 번들에 들어갔습니다. VITE_ 접두사나 vite.config define 을 확인하세요.\n` +
leaks.map((v) => ` - ${v}`).join('\n'),
);
}
}

View File

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

View File

@ -12,6 +12,7 @@ import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'motion/react'; import { AnimatePresence, motion } from 'motion/react';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { submitLead } from '../lib/submitLead'; import { submitLead } from '../lib/submitLead';
import { trackEvent } from '../lib/analytics';
import { CONTACT_EMAIL } from '../lib/contact'; import { CONTACT_EMAIL } from '../lib/contact';
interface ContactModalProps { interface ContactModalProps {
@ -86,6 +87,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp
source, source,
website, website,
}); });
// 접수에 성공한 경우만 리드로 센다. 어느 버튼에서 열었는지(source)만 보낸다.
if (result.ok) trackEvent('generate_lead', { lead_source: source });
setState(result.ok ? 'done' : 'error'); setState(result.ok ? 'done' : 'error');
}; };
@ -200,7 +203,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,8 @@ 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, SHOW_PRICING, SHOW_USE_CASES } from '../lib/site';
import { USE_CASES } from '../data/useCases';
export default function Navbar() { export default function Navbar() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@ -45,18 +47,27 @@ 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 는 INFINITH 랜딩의 섹션 앵커라 공개 배포(webforai.kr)에서는 뺀다.
Pricing·Use Cases 는 src/lib/site.ts 스위치를 따른다. */}
{!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>
)}
{SHOW_PRICING && (
<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>
)}
{/* 공개 배포에서는 사례가 있어야 섹션이 생기므로 메뉴도 그때만 보인다 */}
{SHOW_USE_CASES && (!DISCOVERY_SITE || USE_CASES.length > 0) && (
<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,12 +1,11 @@
/** /**
* 최종 CTA (다크). 연락처는 lib/contact.ts 의 sales@o2o.kr 단일 채널 (허위 연락처 금지). * 최종 CTA (다크). 문의는 진단 요청 버튼이 여는 문의 서식으로 받는다. mailto 링크는 메일 앱이 없으면 동작하지 않아 두지 않는다.
*/ */
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { buildDiscoveryMailto, DISCOVERY_CONTACT_EMAIL } from './discoveryContact';
interface DiscoveryCtaProps { interface DiscoveryCtaProps {
criteriaCount: number; criteriaCount: number;
/** CTA 클릭 → URL 입력 신청 모달 열기 */ /** CTA 클릭 → 진단 요청(문의 서식) 열기 */
onStart: () => void; onStart: () => void;
} }
@ -21,7 +20,7 @@ export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaPro
className="max-w-7xl mx-auto flex flex-col items-center text-center" className="max-w-7xl mx-auto flex flex-col items-center text-center"
> >
<h2 className="font-serif text-3xl md:text-[44px] font-bold leading-tight text-white mb-[18px] break-keep"> <h2 className="font-serif text-3xl md:text-[44px] font-bold leading-tight text-white mb-[18px] break-keep">
AI가 당신의 병원을 인용하게 하세요. AI가 여러분의 병원을 정확히 읽고 인용할 수 있도록 준비하세요.
</h2> </h2>
<p className="text-lg leading-[1.7] text-slate-400 mb-10 break-keep"> <p className="text-lg leading-[1.7] text-slate-400 mb-10 break-keep">
진단은 무료입니다. {criteriaCount}개 항목 실측 리포트를 받아보고 결정하세요. 진단은 무료입니다. {criteriaCount}개 항목 실측 리포트를 받아보고 결정하세요.
@ -32,14 +31,8 @@ export default function DiscoveryCta({ criteriaCount, onStart }: DiscoveryCtaPro
onClick={onStart} onClick={onStart}
className="on-light inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors" className="on-light inline-flex items-center h-[52px] px-[30px] bg-white rounded-full text-base font-bold text-primary-900 hover:bg-slate-100 transition-colors"
> >
URL 입력으로 시작하기 AEO, GEO 진단 요청하기
</button> </button>
<a
href={buildDiscoveryMailto('무료 진단 신청')}
className="text-[15px] text-slate-500 hover:text-slate-300 transition-colors"
>
{DISCOVERY_CONTACT_EMAIL}
</a>
</div> </div>
</motion.div> </motion.div>
</section> </section>

View File

@ -3,8 +3,9 @@
* Hero.tsx의 배경·배지·헤드라인 구조를 따르고, 하단에 ABMR 루프 + AI 답변 목업 2열 그리드. * Hero.tsx의 배경·배지·헤드라인 구조를 따르고, 하단에 ABMR 루프 + AI 답변 목업 2열 그리드.
* 시안: docs/reports/landing/ai_discovery_landing.html (픽셀 기준) * 시안: docs/reports/landing/ai_discovery_landing.html (픽셀 기준)
*/ */
import { Link } from 'react-router';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Link } from 'react-router';
import { SHOW_SAMPLE_REPORT } from '../../lib/site';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import AbmrLoop from './AbmrLoop'; import AbmrLoop from './AbmrLoop';
import type { AxisSummary } from './AbmrLoop'; import type { AxisSummary } from './AbmrLoop';
@ -13,16 +14,14 @@ import AiAnswerMock from './AiAnswerMock';
interface DiscoveryHeroProps { interface DiscoveryHeroProps {
axes: AxisSummary[]; axes: AxisSummary[];
clinicName: string; clinicName: string;
sampleReportPath: string;
criteriaCount: number; criteriaCount: number;
/** CTA 클릭 → URL 입력 신청 모달 열기 */ /** CTA 클릭 → 진단 요청 폼(문의 폼과 같은 ContactModal) 열기 */
onStart: () => void; onStart: () => void;
} }
export default function DiscoveryHero({ export default function DiscoveryHero({
axes, axes,
clinicName, clinicName,
sampleReportPath,
criteriaCount, criteriaCount,
onStart, onStart,
}: DiscoveryHeroProps) { }: DiscoveryHeroProps) {
@ -86,15 +85,18 @@ export default function DiscoveryHero({
onClick={onStart} onClick={onStart}
className="inline-flex items-center justify-center gap-2 px-10 py-4 text-lg font-medium text-white rounded-full shadow-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90 transition-opacity" className="inline-flex items-center justify-center gap-2 px-10 py-4 text-lg font-medium text-white rounded-full shadow-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] hover:opacity-90 transition-opacity"
> >
URL 입력으로 시작하기 AEO, GEO 진단 요청하기
<ArrowRight className="w-5 h-5" /> <ArrowRight className="w-5 h-5" />
</button> </button>
{/* 가상 데이터 예시 리포트로만 연결한다. 실제 병원 리포트로 연결하지 않는다 (src/lib/site.ts) */}
{SHOW_SAMPLE_REPORT && (
<Link <Link
to={sampleReportPath} 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" 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> </Link>
)}
</motion.div> </motion.div>
{/* ABMR loop + AI answer mock */} {/* ABMR loop + AI answer mock */}

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

@ -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>
);
}

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: [],
};

80
src/data/pricingTiers.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* 요금표 (INFINITH Product 1.0). /pricing 화면은 이 데이터만 그린다.
* 요금이 확정되면 이 파일만 고치고, 공개 배포(webforai.kr)에서 열려면 src/lib/site.ts 의 SHOW_PRICING 을 켠다.
* 2026-10-07 기준 확정 전 값이다.
*/
// plan 섹션 2·5 기준. Feature Comparison Table(Step 3)에서도 재사용 예정.
export type TierId = 'insight' | 'intelligence' | 'intelligence-plus';
export interface Tier {
id: TierId;
name: string;
tagline: string;
monthlyKRW: number; // 원화 (월 단가 · 계약 기준)
annualMonthlyKRW: number; // 원화 (연 계약 시 월 환산)
annualTotalKRW: number; // 원화 (연 계약 총액)
isPopular?: boolean;
/** 모든 Tier는 계약 기반 영업 — 온라인 결제 없음. 상담 문의 mailto로 통일. */
ctaLabel: string;
bullets: string[];
footnote?: string;
}
export const PRICING_TIERS: Tier[] = [
{
id: 'insight',
name: 'INSIGHT',
tagline: '매월 1번, 병원의 온라인 좌표를 점검하세요',
monthlyKRW: 90_000,
annualMonthlyKRW: 72_000,
annualTotalKRW: 864_000,
ctaLabel: '상담 문의',
bullets: [
'월 1회 분석 리포트',
'전 채널 분석 (홈페이지 · 강남언니 · YouTube · Instagram · Facebook · 네이버 플레이스 · 블로그)',
'4주 콘텐츠 플랜',
'경쟁사 추적 1개',
'PDF 내보내기',
],
footnote: '신규 개업의 · 1인 의원 추천',
},
{
id: 'intelligence',
name: 'INTELLIGENCE',
tagline: '경쟁사가 지금 무엇을 바꾸는지, 월 2번 확인하세요',
monthlyKRW: 290_000,
annualMonthlyKRW: 232_000,
annualTotalKRW: 2_784_000,
isPopular: true,
ctaLabel: '상담 문의',
bullets: [
'월 4회 분석 리포트',
'8주 콘텐츠 캘린더 + 주간 KPI 기반 조정',
'Vision AI (의료진·슬로건·인증 자동 추출)',
'경쟁사 추적 3개 · 주간 변동 알림',
'KPI 대시보드 (3/12개월 목표)',
'브랜드 가이드 + 콘텐츠 필러 5종',
'스크린샷 증거 기반 심층 리포트',
],
footnote: '중형 성형외과 · 메인 타겟',
},
{
id: 'intelligence-plus',
name: 'INTELLIGENCE+',
tagline: '매일 변하는 시장에 즉시 대응하세요',
monthlyKRW: 990_000,
annualMonthlyKRW: 792_000,
annualTotalKRW: 9_504_000,
ctaLabel: '상담 문의',
bullets: [
'월 10회 분석 리포트',
'12개월 로드맵 + 월간 전략 리뷰',
'최대 3개 분원 통합 대시보드',
'경쟁사 추적 5개 · 일간 변동 모니터링',
'브랜드 가이드 + 콘텐츠 필러 10종',
'커스텀 리포트 템플릿 (병원 CI 반영)',
'신규 기능 베타 우선 접근',
],
footnote: '대형 · 멀티 분원 병원',
},
];

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[] = [];

11
src/lib/analytics.ts Normal file
View File

@ -0,0 +1,11 @@
/**
* 방문 측정 이벤트. GTM 이 있는 배포(webforai.kr)에서만 dataLayer 로 넘기고, 없으면 아무것도 하지 않는다.
* 이벤트에는 개인정보(이름·연락처·문의 내용)를 넣지 않는다. 어느 버튼에서 왔는지 같은 화면 정보만 보낸다.
*/
type DataLayerWindow = Window & { dataLayer?: Record<string, unknown>[] };
export function trackEvent(event: string, params: Record<string, string> = {}): void {
const w = window as DataLayerWindow;
if (!Array.isArray(w.dataLayer)) return;
w.dataLayer.push({ event, ...params });
}

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

@ -0,0 +1,22 @@
/**
* 배포 구분과 공개 기능 스위치. 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

@ -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,35 @@ 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 SampleReportPage from './pages/SampleReportPage.tsx';
import { DISCOVERY_SITE, SHOW_PRICING, SHOW_SAMPLE_REPORT } 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 랜딩을 놓는다. 루트에 두고 공개 페이지(랜딩·로그인·약관)만 연다. 요금·예시 리포트는 src/lib/site.ts 스위치가 켜졌을 때만 연다.
한 코드베이스로 두 사이트를 내되 기존 배포의 동작은 바꾸지 않는다. */} 실제 병원 진단 결과·내부 작업 화면 경로는 홈으로 돌린다.
<Route index element={DISCOVERY_SITE ? <DiscoveryLandingPage /> : <LandingPage />} /> 값이 없으면 지금까지처럼 INFINITH 랜딩과 전체 화면을 연다. */}
{DISCOVERY_SITE ? (
<>
<Route index element={<DiscoveryLandingPage />} />
{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 />} />
<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 />} />
@ -49,11 +64,16 @@ createRoot(document.getElementById('root')!).render(
<Route path="clinic/:id" element={<ClinicProfilePage />} /> <Route path="clinic/:id" element={<ClinicProfilePage />} />
<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/sample" element={<SampleReportPage />} />
<Route path="discovery/:id" element={<DiscoveryReportPage />} /> <Route path="discovery/:id" element={<DiscoveryReportPage />} />
<Route path="actions/:id" element={<ActionPlanPage />} /> <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 />} />
<Route path="privacy" element={<PrivacyPage />} />
<Route path="terms" element={<TermsPage />} />
</>
)}
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -147,7 +147,7 @@ const API_REGISTRY: ApiConfig[] = [
name: 'Google Gemini', name: 'Google Gemini',
description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)', description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)',
envKeys: ['GEMINI_API_KEY'], envKeys: ['GEMINI_API_KEY'],
layer: 'both', layer: 'edge-function',
docsUrl: 'https://ai.google.dev/docs', docsUrl: 'https://ai.google.dev/docs',
IconComponent: DatabaseFilled, IconComponent: DatabaseFilled,
category: 'ai', category: 'ai',
@ -161,8 +161,8 @@ const API_REGISTRY: ApiConfig[] = [
id: 'creatomate', id: 'creatomate',
name: 'Creatomate', name: 'Creatomate',
description: '마케팅 영상 자동 생성 (템플릿 기반)', description: '마케팅 영상 자동 생성 (템플릿 기반)',
envKeys: ['VITE_CREATOMATE_API_KEY'], envKeys: ['CREATOMATE_API_KEY'],
layer: 'frontend', layer: 'edge-function',
docsUrl: 'https://creatomate.com/docs/api/introduction', docsUrl: 'https://creatomate.com/docs/api/introduction',
IconComponent: VideoFilled, IconComponent: VideoFilled,
category: 'ai', category: 'ai',
@ -176,12 +176,19 @@ const API_REGISTRY: ApiConfig[] = [
/* ────────────────── Environment Variable Checker ─────────────── */ /* ────────────────── Environment Variable Checker ─────────────── */
// 브라우저에서 확인할 수 있는 값은 공개를 전제로 만든 변수뿐이다. import.meta.env 를 이름으로 동적 조회하면
// Vite 가 VITE_ 변수 전체를 번들에 넣으므로(2026-10-07 VITE_CREATOMATE_API_KEY 노출 원인), 여기 적은 것만 읽는다.
const PUBLIC_ENV: Record<string, string | undefined> = {
VITE_SUPABASE_URL: import.meta.env.VITE_SUPABASE_URL,
VITE_SUPABASE_ANON_KEY: import.meta.env.VITE_SUPABASE_ANON_KEY,
};
function checkEnvVars(): ApiStatusInfo[] { function checkEnvVars(): ApiStatusInfo[] {
return API_REGISTRY.map((api) => { return API_REGISTRY.map((api) => {
const keyPresent: Record<string, boolean> = {}; const keyPresent: Record<string, boolean> = {};
for (const key of api.envKeys) { for (const key of api.envKeys) {
if (key.startsWith('VITE_')) { if (key.startsWith('VITE_')) {
const val = (import.meta.env as Record<string, string | undefined>)[key]; const val = PUBLIC_ENV[key];
keyPresent[key] = !!val && val.length > 0; keyPresent[key] = !!val && val.length > 0;
} else { } else {
keyPresent[key] = true; keyPresent[key] = true;
@ -269,12 +276,12 @@ function EnvKeyRow({ envKey, present, isServerSide }: { key?: string; envKey: st
const value = isServerSide const value = isServerSide
? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)' ? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)'
: showKey : showKey
? (import.meta.env as Record<string, string | undefined>)[envKey] || '' ? PUBLIC_ENV[envKey] || ''
: '••••••••••••••••'; : '••••••••••••••••';
const handleCopy = () => { const handleCopy = () => {
if (!isServerSide) { if (!isServerSide) {
navigator.clipboard.writeText((import.meta.env as Record<string, string | undefined>)[envKey] || ''); navigator.clipboard.writeText(PUBLIC_ENV[envKey] || '');
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 1500); setTimeout(() => setCopied(false), 1500);
} }

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';
@ -20,11 +20,16 @@ import SampleReport from '../components/discovery/SampleReport';
import Roadmap from '../components/discovery/Roadmap'; import Roadmap from '../components/discovery/Roadmap';
import HonestyStrip from '../components/discovery/HonestyStrip'; import HonestyStrip from '../components/discovery/HonestyStrip';
import DiscoveryCta from '../components/discovery/DiscoveryCta'; import DiscoveryCta from '../components/discovery/DiscoveryCta';
import DiscoveryLeadModal from '../components/discovery/DiscoveryLeadModal'; 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() { export default function DiscoveryLandingPage() {
const [leadOpen, setLeadOpen] = useState(false); // 진단 요청은 문의하기와 같은 폼(구글시트 기록 + 메일 알림)으로 받는다. source 로 어느 버튼에서 왔는지 남긴다.
const result = DISCOVERY_VIEWCLINIC; const [leadSource, setLeadSource] = useState<string | null>(null);
// 공개 랜딩은 가상 예시 데이터만 쓴다. 실제 병원 진단 결과를 싣지 않는다.
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) => ({
@ -47,21 +52,21 @@ export default function DiscoveryLandingPage() {
<DiscoveryHero <DiscoveryHero
axes={axes} axes={axes}
clinicName={result.clinicName} clinicName={result.clinicName}
sampleReportPath={`/discovery/${result.id}`}
criteriaCount={criteriaCount} criteriaCount={criteriaCount}
onStart={() => setLeadOpen(true)} onStart={() => setLeadSource('discovery_hero')}
/> />
<WhyNow /> <WhyNow />
<HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} /> <HowItWorks criteriaCount={criteriaCount} categoryCount={categoryCount} />
<SampleReport overall={overall} axes={axes} result={result} /> <SampleReport overall={overall} axes={axes} result={result} />
{SHOW_USE_CASES && USE_CASES.length > 0 && <UseCasesSection cases={USE_CASES} />}
<Roadmap <Roadmap
axes={axes} axes={axes}
criteriaCount={criteriaCount} criteriaCount={criteriaCount}
unverifiedCount={unverifiedCount} unverifiedCount={unverifiedCount}
/> />
<HonestyStrip /> <HonestyStrip />
<DiscoveryCta criteriaCount={criteriaCount} onStart={() => setLeadOpen(true)} /> <DiscoveryCta criteriaCount={criteriaCount} onStart={() => setLeadSource('discovery_cta')} />
<DiscoveryLeadModal open={leadOpen} onClose={() => setLeadOpen(false)} /> <ContactModal open={leadSource !== null} onClose={() => setLeadSource(null)} source={leadSource ?? 'discovery'} />
</div> </div>
); );
} }

View File

@ -13,6 +13,7 @@ import type {
CategoryId, CategoryId,
CategoryScore, CategoryScore,
ControlLevel, ControlLevel,
DiscoveryResult,
EvidenceGrade, EvidenceGrade,
EvidenceMethod, EvidenceMethod,
RubricCriterion, RubricCriterion,
@ -71,20 +72,22 @@ const scoreColor = (pct: number) =>
export default function DiscoveryReportPage() { export default function DiscoveryReportPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const result = id ? DISCOVERY_RESULTS[id] : undefined; 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항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라 // 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라
// 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다. // 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다.
if (!result || !overall) { if (!result) {
return <NoReport clinicId={id ?? ''} />; 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])); const resultById = new Map(result.results.map((r) => [r.criterionId, r]));
@ -108,6 +111,7 @@ export default function DiscoveryReportPage() {
</h1> </h1>
<p className="text-3xl font-bold mb-2">{result.clinicName}</p> <p className="text-3xl font-bold mb-2">{result.clinicName}</p>
<p className="text-purple-200 text-base mb-1">{result.industry}</p> <p className="text-purple-200 text-base mb-1">{result.industry}</p>
{result.url && (
<a <a
href={result.url} href={result.url}
target="_blank" target="_blank"
@ -116,6 +120,12 @@ export default function DiscoveryReportPage() {
> >
{result.url} {result.url}
</a> </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"> <p className="text-white/60 text-sm mt-4">
실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} / 실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} /
{' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외 {' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외

View File

@ -13,7 +13,7 @@
import { useState, type FormEvent } from 'react'; import { useState, type FormEvent } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { buildContactMailto, CONTACT_EMAIL } from '../lib/contact'; import { CONTACT_EMAIL } from '../lib/contact';
export default function LoginPage() { export default function LoginPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@ -23,7 +23,7 @@ export default function LoginPage() {
const handleSubmit = (e: FormEvent) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
setNotice( setNotice(
`로그인 기능은 준비 중입니다. 계약을 희망하시는 경우 ${CONTACT_EMAIL} 로 문의 주세요.`, `계약을 희망하시는 경우 ${CONTACT_EMAIL} 로 문의 주세요.`,
); );
}; };
@ -49,8 +49,6 @@ export default function LoginPage() {
{/* 준비중 배너 */} {/* 준비중 배너 */}
<div className="mb-6 rounded-2xl bg-amber-50/80 border border-amber-200 p-4 text-center"> <div className="mb-6 rounded-2xl bg-amber-50/80 border border-amber-200 p-4 text-center">
<p className="text-xs font-semibold text-amber-800 break-keep leading-relaxed"> <p className="text-xs font-semibold text-amber-800 break-keep leading-relaxed">
현재 로그인 기능은 준비 중입니다.
<br />
신규 도입 문의는 하단 "문의하기"를 이용해 주세요. 신규 도입 문의는 하단 "문의하기"를 이용해 주세요.
</p> </p>
</div> </div>
@ -113,17 +111,6 @@ export default function LoginPage() {
</div> </div>
)} )}
</form> </form>
{/* 하단 문의하기 */}
<div className="mt-6 text-center">
<p className="text-xs text-slate-500 mb-2">계약을 원하시나요?</p>
<a
href={buildContactMailto('도입 문의 (LoginPage)')}
className="inline-flex items-center gap-1 text-sm font-semibold text-[#4F1DA1] hover:text-[#021341] transition-colors underline-offset-4 hover:underline"
>
문의하기 →
</a>
</div>
</div> </div>
</section> </section>
); );

View File

@ -16,7 +16,7 @@
* (analytics 연동은 Supabase `analytics_events` 테이블 도입 후 후속 작업) * (analytics 연동은 Supabase `analytics_events` 테이블 도입 후 후속 작업)
* *
* 핵심 데이터 소스: * 핵심 데이터 소스:
* - src/data/pricingTiers.ts ← (이 파일 내부에 임시 정의, Step 3에서 분리 고려) * - src/data/pricingTiers.ts ← 요금표 데이터 (2026-10-07 분리. 요금 확정 시 이 파일만 고친다)
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
@ -32,83 +32,7 @@ import Badge from '../components/Badge';
import FeatureComparisonTable from '../components/pricing/FeatureComparisonTable'; import FeatureComparisonTable from '../components/pricing/FeatureComparisonTable';
import FAQ from '../components/pricing/FAQ'; import FAQ from '../components/pricing/FAQ';
import { buildContactMailto } from '../lib/contact'; import { buildContactMailto } from '../lib/contact';
import { PRICING_TIERS as tiers, type Tier } from '../data/pricingTiers';
// ─── Tier 데이터 정의 ──────────────────────────────────────────────
// plan 섹션 2·5 기준. Feature Comparison Table(Step 3)에서도 재사용 예정.
type TierId = 'insight' | 'intelligence' | 'intelligence-plus';
interface Tier {
id: TierId;
name: string;
tagline: string;
monthlyKRW: number; // 원화 (월 단가 · 계약 기준)
annualMonthlyKRW: number; // 원화 (연 계약 시 월 환산)
annualTotalKRW: number; // 원화 (연 계약 총액)
isPopular?: boolean;
/** 모든 Tier는 계약 기반 영업 — 온라인 결제 없음. 상담 문의 mailto로 통일. */
ctaLabel: string;
bullets: string[];
footnote?: string;
}
const tiers: Tier[] = [
{
id: 'insight',
name: 'INSIGHT',
tagline: '매월 1번, 병원의 온라인 좌표를 점검하세요',
monthlyKRW: 90_000,
annualMonthlyKRW: 72_000,
annualTotalKRW: 864_000,
ctaLabel: '상담 문의',
bullets: [
'월 1회 분석 리포트',
'전 채널 분석 (홈페이지 · 강남언니 · YouTube · Instagram · Facebook · 네이버 플레이스 · 블로그)',
'4주 콘텐츠 플랜',
'경쟁사 추적 1개',
'PDF 내보내기',
],
footnote: '신규 개업의 · 1인 의원 추천',
},
{
id: 'intelligence',
name: 'INTELLIGENCE',
tagline: '경쟁사가 지금 무엇을 바꾸는지, 월 2번 확인하세요',
monthlyKRW: 290_000,
annualMonthlyKRW: 232_000,
annualTotalKRW: 2_784_000,
isPopular: true,
ctaLabel: '상담 문의',
bullets: [
'월 4회 분석 리포트',
'8주 콘텐츠 캘린더 + 주간 KPI 기반 조정',
'Vision AI (의료진·슬로건·인증 자동 추출)',
'경쟁사 추적 3개 · 주간 변동 알림',
'KPI 대시보드 (3/12개월 목표)',
'브랜드 가이드 + 콘텐츠 필러 5종',
'스크린샷 증거 기반 심층 리포트',
],
footnote: '중형 성형외과 · 메인 타겟',
},
{
id: 'intelligence-plus',
name: 'INTELLIGENCE+',
tagline: '매일 변하는 시장에 즉시 대응하세요',
monthlyKRW: 990_000,
annualMonthlyKRW: 792_000,
annualTotalKRW: 9_504_000,
ctaLabel: '상담 문의',
bullets: [
'월 10회 분석 리포트',
'12개월 로드맵 + 월간 전략 리뷰',
'최대 3개 분원 통합 대시보드',
'경쟁사 추적 5개 · 일간 변동 모니터링',
'브랜드 가이드 + 콘텐츠 필러 10종',
'커스텀 리포트 템플릿 (병원 CI 반영)',
'신규 기능 베타 우선 접근',
],
footnote: '대형 · 멀티 분원 병원',
},
];
// ─── 가격 포맷터 ────────────────────────────────────────────────── // ─── 가격 포맷터 ──────────────────────────────────────────────────
// 29_0000원 → "29만원" 포맷. 1000원 단위까지는 표기 안 함 (B2B 가격은 만원 단위) // 29_0000원 → "29만원" 포맷. 1000원 단위까지는 표기 안 함 (B2B 가격은 만원 단위)

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

@ -0,0 +1,130 @@
/**
* 개인정보처리방침 (/privacy).
* 이 사이트가 실제로 처리하는 것만 적는다: 문의·진단 요청 서식(이름·연락처·회사·문의 내용) → /api/lead(Vercel) →
* Google Apps Script → 구글 시트 저장 + 알림 메일. 방문 분석은 GTM(GTM-WQNTZVZH) 안의 GA4(G-013Z3BFY35)로 한다.
* 광고 픽셀(Meta·네이버 등)을 GTM 에 추가하면 제1·5·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 진단의 수행과 결과 전달',
'- 서비스 도입 관련 안내(요청하신 범위에 한함)',
'위 목적의 처리 근거는 정보주체의 요청에 따른 조치 이행입니다(개인정보 보호법 제15조 제1항 제4호).',
'- 서비스 계약의 이행과 거래 기록 보관: 계약 이행과 관계 법령에 따른 의무 준수(같은 법 제15조 제1항 제2호·제4호)',
'- 웹사이트 이용 현황 분석: 제8조의 분석 쿠키로 수집하며, 이 정보로 개인을 직접 식별하지 않습니다.',
'회사는 위 목적 외의 용도로 개인정보를 이용하지 않으며, 목적이 바뀌는 경우 미리 동의를 받습니다.',
],
},
{
title: '개인정보의 보유 및 이용 기간',
body: [
'회사는 문의 처리가 끝난 날부터 1년간 개인정보를 보관한 뒤 지체 없이 파기합니다. 정보주체가 삭제를 요청하면 그 전이라도 파기합니다.',
'서비스 계약을 체결한 경우에는 관계 법령에 따라 아래 기간 동안 보관합니다.',
'- 계약 및 대금 거래에 관한 기록(장부·증빙서류): 5년 (국세기본법)',
'- 웹사이트 방문 기록: 3개월 (통신비밀보호법)',
],
},
{
title: '개인정보의 제3자 제공',
body: ['회사는 정보주체의 개인정보를 제3자에게 제공하지 않습니다. 법령에 특별한 규정이 있거나 정보주체가 동의한 경우에만 예외로 합니다.'],
},
{
title: '개인정보 처리업무의 위탁 및 국외 이전',
body: [
'회사는 서비스 운영을 위해 아래와 같이 개인정보 처리업무를 위탁하며, 수탁자의 서버가 국외에 있어 개인정보가 국외로 이전됩니다. 이 이전은 정보주체와의 계약 체결·이행을 위하여 필요한 처리위탁·보관으로서 이 방침에 공개한 경우에 해당합니다(개인정보 보호법 제28조의8 제1항 제3호 가목).',
'- Google LLC (미국): 문의 내용 저장(Google 스프레드시트) 및 접수 알림 메일 발송. 이전 항목은 제1조의 서식 입력 항목이며, 서식을 제출하는 시점에 네트워크로 전송됩니다. 보유 기간은 제3조와 같습니다. 연락처: data-access-requests@google.com, 02-722-7778 (Google 한국 이용자 개인정보 문의 창구)',
'- Google LLC (미국): 웹사이트 방문 분석(Google 애널리틱스, Google 태그 관리자). 이전 항목은 제1조의 방문 분석 정보이며, 웹사이트를 이용하는 시점에 네트워크로 전송됩니다. 보유 기간은 수집일부터 최대 14개월입니다. 연락처는 위와 같습니다.',
'- Vercel Inc. (미국): 웹사이트 호스팅 및 서식 전송 처리. 이전 항목은 서식 입력 항목과 접속 기록이며, 웹사이트 이용 및 서식 제출 시 네트워크로 전송됩니다. 보유 기간은 위탁 계약 종료 시까지입니다. 연락처: privacy@vercel.com, 440 N Barranca Avenue #4133, Covina, CA 91723, United States',
`국외 이전을 거부하는 방법과 효과: 서식 대신 이메일(${PRIVACY_EMAIL})이나 전화(${CONTACT_PHONE})로 문의하시면 서식 내용의 저장(Google)을 위한 이전을 거부할 수 있으며, 문의와 회신은 그대로 이용하실 수 있습니다. 웹사이트 접속은 호스팅(Vercel)을 거치므로 접속 기록의 이전을 거부하려면 웹사이트 이용을 중단하셔야 합니다. 방문 분석 쿠키는 제8조의 방법으로 거부할 수 있고, 거부해도 웹사이트 이용에 제한은 없습니다.`,
],
},
{
title: '정보주체의 권리와 행사 방법',
body: [
'정보주체는 언제든지 자신의 개인정보에 대한 열람, 정정, 삭제, 처리정지를 요청할 수 있습니다.',
`요청은 이메일(${PRIVACY_EMAIL}) 또는 전화(${CONTACT_PHONE})로 하실 수 있으며, 회사는 본인 확인 후 10일 이내에 조치하고 결과를 알려 드립니다.`,
'법정대리인이나 위임을 받은 사람을 통해서도 권리를 행사할 수 있습니다.',
],
},
{
title: '개인정보의 파기 절차 및 방법',
body: [
'보유 기간이 끝나거나 처리 목적이 달성된 개인정보는 지체 없이 파기합니다.',
'- 전자 파일: 복구할 수 없는 방법으로 영구 삭제',
'- 종이 문서: 분쇄 또는 소각',
],
},
{
title: '쿠키 등 자동 수집 장치',
body: [
'회사는 웹사이트 이용 현황을 파악하고 서비스를 개선하기 위해 Google 애널리틱스의 분석 쿠키를 사용합니다. 광고 목적의 쿠키는 사용하지 않습니다.',
'- 수집 항목: 제1조의 방문 분석 정보',
'- 이용 목적: 방문자 수, 많이 본 페이지, 유입 경로, 문의 전환 등 이용 현황 분석',
'- 거부 방법: 브라우저의 개인정보 보호 또는 쿠키 및 사이트 데이터 설정에서 이 사이트(webforai.kr)의 쿠키를 차단하거나 삭제할 수 있습니다. Google 애널리틱스 차단 브라우저 부가기능(tools.google.com/dlpage/gaoptout)으로도 수집을 거부할 수 있습니다.',
'쿠키 저장을 거부해도 웹사이트 이용과 문의에는 제한이 없습니다.',
'광고 목적의 쿠키나 추적 도구를 도입하는 경우 이 방침을 개정해 미리 알려 드립니다.',
],
},
{
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}
/>
);
}

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 />;
}

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

@ -0,0 +1,117 @@
/**
* 이용약관 (/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: [
'서비스의 일부는 생성형 인공지능을 이용해 운용됩니다(인공지능 발전과 신뢰 기반 조성 등에 관한 기본법 제31조).',
'- 생성형 인공지능을 이용하는 부분: 공식 답변 허브에 실을 콘텐츠 초안, 이미지·영상 결과물',
'회사는 관련 법령에 따라 생성형 인공지능으로 만든 결과물에 그 사실을 표시합니다. 의료 관련 내용은 의료기관의 확인을 거친 뒤 게시합니다.',
],
},
{
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

@ -1,202 +1,63 @@
import type { StudioState } from '../types/studio'; import type { StudioState } from '../types/studio';
import { CHANNEL_OPTIONS } from '../types/studio';
const CREATOMATE_API_KEY = import.meta.env.VITE_CREATOMATE_API_KEY ?? ''; /**
const API_BASE = 'https://api.creatomate.com/v1'; * 콘텐츠 스튜디오 영상 생성. Creatomate 는 서버 함수(api/creatomate-render.js)에서만 호출한다.
* 브라우저는 키를 모르고 선택값만 보낸다. 렌더 시작 뒤 서버에 상태를 물어 완료를 기다린다 (2026-10-07).
*/
export interface VideoResult { export interface VideoResult {
videoUrl: string; videoUrl: string;
prompt: string; prompt: string;
} }
interface CreatomateRender { interface RenderState {
id: string; ok: boolean;
status: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed'; id?: string;
url?: string; status?: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed';
error_message?: string; url?: string | null;
error?: string | null;
} }
const ASPECT_DIMENSIONS: Record<string, { width: number; height: number }> = { const ENDPOINT = '/api/creatomate-render';
'9:16': { width: 1080, height: 1920 },
'16:9': { width: 1920, height: 1080 },
'1:1': { width: 1080, height: 1080 },
'4:5': { width: 1080, height: 1350 },
};
const PILLAR_TITLES: Record<string, { ko: string; en: string }> = { async function pollRender(renderId: string, maxAttempts = 30): Promise<RenderState> {
safety: { ko: '안전한 수술 시스템', en: 'Safe Surgery System' },
expertise: { ko: '전문 의료진', en: 'Expert Medical Team' },
results: { ko: '자연스러운 결과', en: 'Natural Results' },
care: { ko: '환자 중심 케어', en: 'Patient-Centered Care' },
};
/**
* Build a Creatomate source object programmatically.
* This creates a simple branded slideshow video without needing
* a pre-built template — ideal for initial integration.
*/
function buildSource(state: StudioState) {
const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel);
const format = channel?.formats.find(f => f.key === state.format);
const dims = ASPECT_DIMENSIONS[format?.aspectRatio ?? '16:9'];
const pillar = state.pillarId ? PILLAR_TITLES[state.pillarId] : null;
return {
output_format: 'mp4',
width: dims.width,
height: dims.height,
duration: 10,
elements: [
// Background gradient
{
type: 'shape',
shape: 'rectangle',
width: '100%',
height: '100%',
fill_color: 'linear-gradient(180deg, #0A1128 0%, #1A2B5E 50%, #4F1DA1 100%)',
},
// Title text
{
type: 'text',
text: pillar?.ko || 'INFINITH Marketing',
font_family: 'Noto Sans KR',
font_weight: '700',
font_size: dims.width > dims.height ? '72px' : '56px',
fill_color: '#FFFFFF',
x: '50%',
y: '40%',
x_anchor: '50%',
y_anchor: '50%',
width: '80%',
text_align: 'center',
animations: [
{ type: 'text-appear', duration: 1, split: 'word' },
],
},
// Subtitle
{
type: 'text',
text: pillar?.en || 'Premium Medical Marketing',
font_family: 'Noto Sans KR',
font_weight: '400',
font_size: dims.width > dims.height ? '36px' : '28px',
fill_color: 'rgba(255,255,255,0.7)',
x: '50%',
y: '55%',
x_anchor: '50%',
y_anchor: '50%',
width: '80%',
text_align: 'center',
animations: [
{ type: 'fade', fade_in: true, duration: 0.8, start: 0.5 },
],
},
// Brand accent line
{
type: 'shape',
shape: 'rectangle',
width: '120px',
height: '4px',
fill_color: '#6C5CE7',
x: '50%',
y: '48%',
x_anchor: '50%',
y_anchor: '50%',
animations: [
{ type: 'wipe', duration: 0.6, start: 0.3 },
],
},
// Channel badge
{
type: 'text',
text: `${channel?.label || ''} · ${format?.label || ''}`,
font_family: 'Noto Sans KR',
font_weight: '500',
font_size: '20px',
fill_color: 'rgba(255,255,255,0.5)',
x: '50%',
y: '90%',
x_anchor: '50%',
y_anchor: '50%',
text_align: 'center',
},
],
};
}
async function pollRender(renderId: string, maxAttempts = 30): Promise<CreatomateRender> {
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
const res = await fetch(`${API_BASE}/renders/${renderId}`, { const res = await fetch(`${ENDPOINT}?id=${encodeURIComponent(renderId)}`);
headers: { Authorization: `Bearer ${CREATOMATE_API_KEY}` }, if (!res.ok) throw new Error(`렌더 상태 확인 실패 (${res.status})`);
}); const render: RenderState = await res.json();
if (render.status === 'succeeded' && render.url) return render;
if (!res.ok) { if (render.status === 'failed') throw new Error(render.error || '영상 렌더링에 실패했습니다');
throw new Error(`렌더 상태 확인 실패 (${res.status})`); await new Promise((resolve) => setTimeout(resolve, 2000));
} }
const render: CreatomateRender = await res.json();
if (render.status === 'succeeded' && render.url) {
return render;
}
if (render.status === 'failed') {
throw new Error(render.error_message || '영상 렌더링에 실패했습니다');
}
// Wait 2 seconds before next poll
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요'); throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요');
} }
export async function generateVideo(state: StudioState): Promise<VideoResult> { export async function generateVideo(state: StudioState): Promise<VideoResult> {
if (!CREATOMATE_API_KEY) {
throw new Error('Creatomate API 키가 설정되지 않았습니다');
}
const source = buildSource(state);
const prompt = `${state.channel}/${state.format} — ${state.pillarId || 'general'}`; const prompt = `${state.channel}/${state.format} — ${state.pillarId || 'general'}`;
let renderResponse; let res: Response;
try { try {
renderResponse = await fetch(`${API_BASE}/renders`, { res = await fetch(ENDPOINT, {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Authorization': `Bearer ${CREATOMATE_API_KEY}`, body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
'Content-Type': 'application/json',
},
body: JSON.stringify({ source }),
}); });
} catch (err) { } catch (err) {
throw new Error('Creatomate 서버에 연결할 수 없습니다'); console.error('[generateVideo] 서버 연결 실패', err);
throw new Error('영상 생성 서버에 연결할 수 없습니다');
} }
if (!renderResponse.ok) { const render: RenderState | null = await res.json().catch(() => null);
const status = renderResponse.status; if (!res.ok || !render?.ok) {
if (status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요'); if (res.status === 500) throw new Error('Creatomate API 키가 설정되지 않았습니다');
if (status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요'); if (res.status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요');
if (status === 400) { if (res.status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요');
const body = await renderResponse.json().catch(() => ({})); if (res.status === 400) throw new Error('영상 설정 오류: 잘못된 요청');
throw new Error(`영상 설정 오류: ${body.message || '잘못된 요청'}`); throw new Error(`영상 생성 요청 실패 (${res.status})`);
}
throw new Error(`영상 생성 요청 실패 (${status})`);
} }
if (!render.id) throw new Error('렌더 ID를 받지 못했습니다');
const renders: CreatomateRender[] = await renderResponse.json(); if (render.status === 'succeeded' && render.url) return { videoUrl: render.url, prompt };
const render = renders[0];
if (!render?.id) {
throw new Error('렌더 ID를 받지 못했습니다');
}
// If already succeeded (fast render)
if (render.status === 'succeeded' && render.url) {
return { videoUrl: render.url, prompt };
}
// Poll for completion
const completed = await pollRender(render.id); const completed = await pollRender(render.id);
return { videoUrl: completed.url!, prompt }; return { videoUrl: completed.url!, prompt };
} }

View File

@ -1,44 +1,9 @@
import { GoogleGenAI } from '@google/genai';
import type { StudioState } from '../types/studio'; import type { StudioState } from '../types/studio';
import { CHANNEL_OPTIONS } from '../types/studio';
const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? '' }); /**
* 콘텐츠 스튜디오 이미지 생성. Gemini 는 서버 함수(api/gemini-image.js)에서만 호출한다.
function buildPrompt(state: StudioState): string { * 브라우저는 키를 모르고 선택값(채널·포맷·주제)만 보낸다. 프롬프트도 서버에서 만든다 (2026-10-07).
const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel); */
const format = channel?.formats.find(f => f.key === state.format);
const pillarDescriptions: Record<string, string> = {
safety: 'hospital safety systems, clean surgical rooms, CCTV monitoring, professional medical team, trust and reliability',
expertise: 'medical expertise, advanced surgical equipment, professional doctors, medical certifications, precision and skill',
results: 'natural beautiful results, before and after transformation, patient satisfaction, subtle elegant outcomes',
care: 'patient-centered care, warm consultation, personalized treatment plan, caring medical staff, comfortable clinic interior',
};
const pillarContext = state.pillarId ? pillarDescriptions[state.pillarId] ?? '' : '';
const aspectRatio = format?.aspectRatio ?? '16:9';
const channelHints: Record<string, string> = {
youtube: 'YouTube thumbnail style, bold text overlay area, high contrast, eye-catching',
instagram: 'Instagram aesthetic, clean minimalist, lifestyle photography style, warm tones',
naver_blog: 'Korean blog header image, informative, medical illustration, clean layout',
tiktok: 'TikTok cover image, vertical format, trendy, dynamic, youthful',
facebook: 'Facebook ad creative, professional, compelling, clear messaging space',
};
const channelHint = state.channel ? channelHints[state.channel] ?? '' : '';
return [
'Generate a premium medical marketing image for a plastic surgery clinic.',
`Theme: ${pillarContext}`,
`Style: ${channelHint}`,
`Aspect ratio: ${aspectRatio}`,
'Color palette: soft purple (#7B2D8E), gold (#E8B931), warm white (#FAF8F5).',
'Premium, luxurious, trustworthy aesthetic.',
'No text or logos in the image.',
'Photorealistic, high quality, professional medical marketing.',
].filter(Boolean).join(' ');
}
export interface GenerateResult { export interface GenerateResult {
imageDataUrl: string; imageDataUrl: string;
@ -46,36 +11,24 @@ export interface GenerateResult {
} }
export async function generateImage(state: StudioState): Promise<GenerateResult> { export async function generateImage(state: StudioState): Promise<GenerateResult> {
const prompt = buildPrompt(state); let res: Response;
let response;
try { try {
response = await client.models.generateContent({ res = await fetch('/api/gemini-image', {
model: 'gemini-2.5-flash-image', method: 'POST',
contents: prompt, headers: { 'Content-Type': 'application/json' },
config: { body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }),
responseModalities: ['Text', 'Image'],
},
}); });
} catch (err: any) { } catch (err) {
const code = err?.status ?? err?.code ?? err?.message?.match(/"code":(\d+)/)?.[1]; console.error('[generateImage] 서버 연결 실패', err);
if (String(code) === '429' || err?.message?.includes('RESOURCE_EXHAUSTED')) { throw new Error('이미지 생성 서버에 연결할 수 없습니다');
throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
}
if (String(code) === '400' || err?.message?.includes('INVALID_ARGUMENT')) {
throw new Error('API 설정 오류 — 관리자에게 문의하세요');
} }
const data = await res.json().catch(() => null);
if (!res.ok || !data?.ok) {
if (res.status === 429) throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요');
if (res.status === 400 || res.status === 500) throw new Error('API 설정 오류 — 관리자에게 문의하세요');
if (data?.error === 'no image') throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
throw new Error('이미지 생성 중 오류가 발생했습니다'); throw new Error('이미지 생성 중 오류가 발생했습니다');
} }
return { imageDataUrl: data.imageDataUrl, prompt: data.prompt };
// Extract image from response parts
const parts = response.candidates?.[0]?.content?.parts;
if (!parts) throw new Error('AI 응답을 받지 못했습니다');
const imagePart = parts.find((p: any) => p.inlineData?.mimeType?.startsWith('image/'));
if (!imagePart?.inlineData) throw new Error('이미지가 생성되지 않았습니다 — 다시 시도해주세요');
const { mimeType, data } = imagePart.inlineData;
const imageDataUrl = `data:${mimeType};base64,${data}`;
return { imageDataUrl, prompt };
} }

View File

@ -387,7 +387,7 @@ export async function analyzeScreenshot(
try { try {
console.log(`[vision] Analyzing screenshot: ${pageType} (${Math.round(base64.length / 1024)}KB)`); console.log(`[vision] Analyzing screenshot: ${pageType} (${Math.round(base64.length / 1024)}KB)`);
const res = await fetchWithRetry( const res = await fetchWithRetry(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`, `https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key=${geminiKey}`,
{ {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -399,8 +399,10 @@ export async function analyzeScreenshot(
], ],
}], }],
generationConfig: { generationConfig: {
// gemini-2.0-flash 는 Google 이 내려 404 다 (2026-10-07 확인). 3.5-flash 는 답 전에 내부 추론 토큰(약 550)을 쓰고
// 그것도 출력 한도에 들어가므로 JSON 이 잘리지 않게 한도를 넉넉히 둔다.
temperature: 0.1, temperature: 0.1,
maxOutputTokens: 2048, maxOutputTokens: 4096,
}, },
}), }),
}, },

View File

@ -4,6 +4,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import {defineConfig, loadEnv} from 'vite'; import {defineConfig, loadEnv} from 'vite';
import { supabaseSync } from './plugins/vite-plugin-supabase-sync'; import { supabaseSync } from './plugins/vite-plugin-supabase-sync';
import { assertNoSecretValues, assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard';
export default defineConfig(({mode}) => { export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', ''); const env = loadEnv(mode, '.', '');
@ -41,6 +42,15 @@ export default defineConfig(({mode}) => {
'<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />', '<link rel="apple-touch-icon" href="/discovery-apple-touch-icon.png" />',
] ]
: []; : [];
// 방문 측정: Google 태그 관리자(GTM) 컨테이너. GA4(G-013Z3BFY35)와 키 이벤트는 컨테이너 안에서 설정한다.
// 공개 배포(webforai.kr)에만 넣는다. 컨테이너 ID 는 공개 값이다. 개인정보처리방침 제5·8조와 함께 관리한다.
const GTM_ID = 'GTM-WQNTZVZH';
const gtmHead = isDiscovery
? [`<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${GTM_ID}');</script>`]
: [];
const gtmBody = isDiscovery
? `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${GTM_ID}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`
: '';
const headHtml = [ const headHtml = [
`<title>${head.title}</title>`, `<title>${head.title}</title>`,
`<meta name="description" content="${head.description}" />`, `<meta name="description" content="${head.description}" />`,
@ -50,6 +60,7 @@ export default defineConfig(({mode}) => {
`<meta property="og:locale" content="ko_KR" />`, `<meta property="og:locale" content="ko_KR" />`,
...share, ...share,
...verify, ...verify,
...gtmHead,
].join('\n '); ].join('\n ');
return { return {
@ -62,8 +73,15 @@ export default defineConfig(({mode}) => {
name: 'infinith-root-favicon', name: 'infinith-root-favicon',
apply: 'build' as const, apply: 'build' as const,
writeBundle(options: {dir?: string}) { writeBundle(options: {dir?: string}) {
if (!isDiscovery) return;
const out = options.dir ?? 'dist'; const out = options.dir ?? 'dist';
// 모든 배포 공통: 서버 전용 키 값이 번들에 있으면 빌드를 멈춘다.
assertNoSecretValues(out, env);
if (!isDiscovery) return;
// public/ 은 infinith-demo 와 공용이라 실제 병원 리포트 원본(reports/)과 병원 채널 스크린샷(assets/clients/)이 들어 있다.
// 공개 배포(webforai.kr)에서는 링크가 없어도 주소만 알면 열리므로 빌드 결과에서 지운다 (2026-10-07).
for (const dir of ['reports', path.join('assets', 'clients')]) {
fs.rmSync(path.resolve(out, dir), { recursive: true, force: true });
}
for (const [from, to] of [ for (const [from, to] of [
['discovery-favicon.ico', 'favicon.ico'], ['discovery-favicon.ico', 'favicon.ico'],
['discovery-favicon-32.png', 'favicon-32.png'], ['discovery-favicon-32.png', 'favicon-32.png'],
@ -73,12 +91,17 @@ export default defineConfig(({mode}) => {
const src = path.resolve(out, from); const src = path.resolve(out, from);
if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to)); if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to));
} }
// 정리가 끝난 결과물을 검사한다. 실제 병원명·비밀 키가 남아 있으면 빌드를 실패시켜 배포를 막는다.
// writeBundle 은 플러그인끼리 병렬로 돌 수 있어 별도 플러그인이 아니라 여기서 정리 뒤에 부른다.
assertPublicBundleClean(out);
}, },
}, },
{ {
name: 'infinith-head', name: 'infinith-head',
transformIndexHtml(html: string) { transformIndexHtml(html: string) {
return html.replace(/<title>[\s\S]*?<\/title>/, headHtml); return html
.replace(/<title>[\s\S]*?<\/title>/, headHtml)
.replace('<body>', gtmBody ? `<body>\n ${gtmBody}` : '<body>');
}, },
}, },
tailwindcss(), tailwindcss(),
@ -87,8 +110,17 @@ export default defineConfig(({mode}) => {
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '', serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '',
}), }),
], ],
define: { // 서버 전용 키(Gemini·Creatomate 등)는 define·VITE_ 접두사로 번들에 넣지 않는다. 호출은 api/*.js 서버 함수에서 한다 (2026-10-07).
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), build: {
rollupOptions: {
treeshake: {
// src/data(목업·진단 데이터)와 src/services 는 import 만으로 할 일이 없는 모듈이다. 쓰는 곳이 없으면 번들에서 뺀다.
// 파생 목업의 최상단 객체 펼치기를 부수효과로 보고 실제 병원 목업이 공개 번들에 남던 문제를 막는다.
// src/lib/supabase 도 같다. 최상단 createClient() 때문에 공개 배포에 Supabase 접속 정보가 남았다 (2026-10-07).
moduleSideEffects: (id: string) =>
!/[\\/]src[\\/](data|services)[\\/]/.test(id) && !/[\\/]src[\\/]lib[\\/]supabase\.ts$/.test(id),
},
},
}, },
resolve: { resolve: {
alias: { alias: {