diff --git a/api/_studio.js b/api/_studio.js new file mode 100644 index 0000000..67d5bb3 --- /dev/null +++ b/api/_studio.js @@ -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; + } +} diff --git a/api/creatomate-render.js b/api/creatomate-render.js new file mode 100644 index 0000000..9327005 --- /dev/null +++ b/api/creatomate-render.js @@ -0,0 +1,111 @@ +/** + * 콘텐츠 스튜디오 영상 렌더 (Vercel 서버 함수). + * POST { channel, format, pillarId } → { ok, id, status, url? } 렌더 시작 + * GET ?id= → { 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' }); + } +} diff --git a/api/gemini-image.js b/api/gemini-image.js new file mode 100644 index 0000000..130f6b1 --- /dev/null +++ b/api/gemini-image.js @@ -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 }); +} diff --git a/docs/WEBFORAI_FEATURE_FLAGS.md b/docs/WEBFORAI_FEATURE_FLAGS.md new file mode 100644 index 0000000..e42d25f --- /dev/null +++ b/docs/WEBFORAI_FEATURE_FLAGS.md @@ -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`)에 넣는다. 공개 폼에 다시 붙이려면 사람 승인 단계를 먼저 둔다. diff --git a/docs/analytics/gtm_webforai_import.json b/docs/analytics/gtm_webforai_import.json new file mode 100644 index 0000000..72b285d --- /dev/null +++ b/docs/analytics/gtm_webforai_import.json @@ -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" + } + ] + } +} \ No newline at end of file diff --git a/docs/legal/webforai_privacy_terms_review_2026-10-07.html b/docs/legal/webforai_privacy_terms_review_2026-10-07.html new file mode 100644 index 0000000..884cbc0 --- /dev/null +++ b/docs/legal/webforai_privacy_terms_review_2026-10-07.html @@ -0,0 +1,154 @@ + + + + + +webforai.kr 약관 법무 검토 + + + +
+

webforai.kr 개인정보처리방침·이용약관 법무 검토

+

검토일 2026-10-07 · 대상: webforai.kr/privacy, webforai.kr/terms (2026-10-07 게시본) · 작성: Claude (웹 검색 근거 기반 1차 검토)

+ +
+ 이 문서는 공개 법령 해설과 감독기관 자료를 근거로 한 1차 검토입니다. 법률 자문을 대신하지 않습니다. + "반드시 고칠 것"은 법령 문언상 필수 기재사항이 빠진 경우만 넣었고, 해석이 갈리는 부분은 "확인 필요"로 분리했습니다. + 최종 게시 전에는 개인정보·의료 분야 변호사 검토를 권합니다. +
+ +

요약

+ + + + + + +
등급건수내용
반드시 고칠 것3국외 이전 필수 기재사항 누락 · 개인정보 보호책임자 표기 요건 미충족 · 개인 Gmail 계정에서 고객 정보 처리
권장4처리 목적별 법적 근거 기재 · AI 기본법 사전 고지 · 이행보조자 면책 문구 · 거래기록 보관 근거 법령
확인 필요2분석 쿠키(GA4)의 처리 근거 · 수집 시점 안내 문구
적합-필수 기재 13개 항목 중 대부분, 의료 관련 고지, 재판관할, 약관 변경 고지 기간, 처리방침 명칭 구분 표시
+ +

1. 반드시 고칠 것

+ +
+

필수 ① 국외 이전 고지에 이전받는 자의 연락처와 거부 효과가 빠져 있음 (개인정보처리방침 제5조)

+

근거: 개인정보 보호법 제28조의8 제2항. 처리방침 공개를 근거로 국외 이전을 하려면 이전 항목, 국가·시기·방법, 이전받는 자의 성명(법인은 명칭과 연락처), 이용 목적과 보유·이용 기간, 거부 방법·절차와 거부의 효과를 모두 적어야 합니다.

+

현재: 항목·국가·시기·방법·목적·기간은 있으나 Google·Vercel의 연락처가 없고, 거부 방법은 "이메일 또는 전화로 문의"뿐이며 거부 효과(서비스 이용 제한 여부)가 없습니다. 이전 근거 조항(제28조의8 제1항 제3호 가목)도 적혀 있지 않습니다.

+
수정안: +
    +
  • Google LLC: 문의 구글코리아 유한회사, 02-722-7778, data-access-requests@google.com (Google 한국 거주자용 개인정보 추가 정보 페이지 기준)
  • +
  • Vercel Inc.: privacy@vercel.com, 440 N Barranca Avenue #4133, Covina, CA 91723, United States (Vercel 개인정보 고지 기준)
  • +
  • 거부 방법·효과: "서식 대신 이메일(o2oteam@o2o.kr)이나 전화로 문의하면 서식 저장(Google)으로의 이전을 거부할 수 있습니다. 웹사이트 접속 자체는 호스팅(Vercel)을 거치므로, 접속 기록 이전을 거부하려면 웹사이트 이용을 중단해야 합니다. 분석 쿠키는 제8조 방법으로 거부할 수 있고, 거부해도 이용 제한은 없습니다."
  • +
  • 근거 명시: "정보주체와의 계약 체결·이행을 위하여 필요한 처리위탁·보관으로서 이 방침에 공개한 경우(법 제28조의8 제1항 제3호 가목)"
  • +
+
+
+ +
+

필수 ② 개인정보 보호책임자 표기가 요건에 맞지 않음 (제10조)

+

근거: 개인정보 보호법 시행령 제31조. 처리방침에는 보호책임자의 성명 또는 개인정보 보호업무·고충 처리를 맡는 부서의 명칭과 전화번호 등 연락처를 적어야 합니다. 또한 법 제31조에 따라 보호책임자를 실제로 지정해야 합니다.

+

현재: "(주)AI오투오 개인정보 보호 담당"으로 적혀 있어 성명도 실제 부서명도 아닙니다. 열람 청구를 받는 부서도 따로 적혀 있지 않습니다.

+
수정안: 회사가 지정한 보호책임자의 성명·직책, 또는 실제 담당 부서명(예: "경영지원팀")과 전화·이메일을 적고, "열람청구 접수·처리 부서: 위와 같음"을 덧붙입니다. 어느 사람·부서로 할지는 회사가 정해야 합니다.
+
+ +
+

필수 ③ 고객 정보가 개인 Gmail 계정(aio2odev@gmail.com)의 시트에 쌓임

+

근거: 개인정보 보호법 제26조(업무위탁)는 위탁자와 수탁자 사이에 처리 목적·안전조치·재위탁 제한 등을 문서로 정하도록 합니다. 처리방침 제5조는 Google을 "수탁자"로 적었지만, 개인용 Gmail 계정은 기업용 데이터 처리 계약이 붙는 구조가 아니어서 이 표기를 뒷받침하기 어렵습니다. 문의 서식은 이름·연락처를 받으므로 회사 계정에서 관리되어야 합니다.

+
수정안: 문의 수신 시트와 Apps Script를 회사 계정으로 옮깁니다(예: 회사 Google Workspace, 또는 회사가 쓰는 Microsoft 365의 Excel·Power Automate). 옮긴 뒤 처리방침의 수탁자 표기를 실제 계약 주체에 맞춥니다. 어느 계정으로 옮길지는 결정이 필요합니다.
+
+ +

2. 권장

+ +
+

권장 ④ 처리 목적별 법적 근거를 함께 적기 (제1·2조)

+

근거: 개인정보보호위원회 「개인정보 처리방침 작성지침」(2025.4 개정)은 처리 목적별로 동의, 계약 이행, 법령 등 근거를 구분해 적도록 안내합니다. 개인정보 보호법 제15조 제1항 제4호(2023 개정)는 "계약을 체결하는 과정에서 정보주체의 요청에 따른 조치"에 필요한 경우 동의 없이 수집할 수 있게 합니다.

+
수정안: "문의·진단 요청 회신: 정보주체의 요청에 따른 조치 이행(법 제15조 제1항 제4호)" · "서비스 계약 이행: 계약 이행(같은 호)" · "거래기록 보관: 관계 법령(같은 항 제2호)".
+
+ +
+

권장 ⑤ AI 기본법에 따른 생성형 AI 사전 고지 (이용약관)

+

근거: 「인공지능 발전과 신뢰 기반 조성 등에 관한 기본법」(2026.1.22 시행) 제31조. 생성형 AI를 이용한 제품·서비스를 제공할 때 그 사실을 이용자에게 미리 알리고(이용약관 등), 결과물이 생성형 AI로 만들어졌음을 표시해야 합니다. 정부는 시행 초기 1년 이상 과태료를 부과하지 않는 계도기간을 운영 중이며, 위반 시 과태료는 1회 500만 원부터입니다.

+

현재: 이용약관에 AI 사용 고지가 없습니다. 진단 리포트 작성, 공식 답변 허브 콘텐츠 작성, 이미지·영상 생성(데모 스튜디오)에 생성형 AI를 씁니다.

+
수정안: 이용약관에 조항 추가. "서비스의 일부(진단 리포트 문안, 공식 답변 허브 콘텐츠 초안, 이미지·영상)는 생성형 인공지능을 이용해 만들어지며, 회사는 해당 결과물에 그 사실을 표시합니다. 의료 관련 내용은 의료기관 확인을 거쳐 게시합니다." 산출물(공식 답변 허브 글 등)에도 표시 방식을 정해야 합니다.
+
+ +
+

권장 ⑥ 외부 서비스 장애 면책 문구를 좁히기 (이용약관 제11조)

+

근거: 약관의 규제에 관한 법률 제7조. 사업자·이행보조자의 고의·중과실 책임을 배제하거나, 상당한 이유 없이 사업자의 손해배상 범위를 제한하는 조항은 무효입니다.

+

현재: "외부 서비스(호스팅·AI 답변엔진·검색엔진 등)의 장애나 정책 변경 등 회사가 통제할 수 없는 사유"로 면책합니다. 호스팅처럼 회사가 고른 업체는 이행보조자로 볼 여지가 있습니다.

+
수정안: "…회사의 고의 또는 과실 없이 발생한 외부 서비스의 장애나 정책 변경으로 생긴 손해"로 한정합니다. AI 답변엔진·검색엔진의 노출 변화처럼 성질상 통제할 수 없는 부분은 제5조(진단 결과의 성격)에 이미 있어 유지합니다.
+
+ +
+

권장 ⑦ 거래기록 보관 근거 법령 바로잡기 (개인정보처리방침 제3조)

+

근거: 「전자상거래 등에서의 소비자보호에 관한 법률」의 보관 의무는 전자상거래·통신판매 거래를 대상으로 합니다. 이 서비스는 병원(사업자)과의 개별 계약이 중심이라 근거로 맞지 않을 수 있습니다.

+
수정안: 계약·대금 기록은 「국세기본법」(장부·증빙서류 5년) 등 실제로 적용되는 법령으로 적고, 전자상거래법 문구는 온라인 결제 등 해당 거래가 생길 때 추가합니다. 웹사이트 방문기록 3개월(통신비밀보호법)은 유지해도 무방합니다.
+
+ +

3. 확인 필요 (해석이 갈림)

+ +
+

확인 ⑧ 분석 쿠키(GA4)의 처리 근거

+

국내에는 분석 쿠키에 사전 동의 배너를 요구하는 명문 규정이 없고, 처리방침에 수집 항목·목적·거부 방법을 공개하는 방식이 관행입니다(현재 제8조가 이 방식). 다만 쿠키 식별자와 결합된 방문 기록을 개인정보로 볼지, 근거를 "정당한 이익(법 제15조 제1항 제6호)"으로 둘지는 해석이 갈립니다. 해외 이용자(특히 EU) 비중이 커지면 동의 배너가 필요할 수 있습니다.

+
+ +
+

확인 ⑨ 문의 서식의 수집 시점 안내

+

제15조 제1항 제4호로 동의 없이 받을 수 있지만, 서식 하단의 "입력 정보는 문의 회신 용도로만 사용합니다" 옆에 처리방침 링크와 "해외 서버(Google·Vercel)에 저장" 한 줄을 붙이면 투명성이 높아집니다(작성지침의 알기 쉬운 고지 원칙).

+
+ +

4. 적합으로 본 항목

+ + + + + + + + + + +
항목근거판단
처리 목적·항목·보유기간·파기·제3자 제공·정보주체 권리(10일 내 조치)·안전성 확보조치·자동수집장치·권익침해 구제·변경 고지개인정보 보호법 제30조, 시행령 제31조기재됨
처리방침 명칭을 다른 링크와 구분해 표시(푸터 굵게)시행령 제31조 공개 방법적합
만 14세 미만·민감정보 미수집, 문의에 환자 정보 금지 안내법 제22조의2, 제23조적합
의료광고 사전심의 책임은 의료기관에 있음을 고지의료법 제56조 제1항(광고 주체), 제57조(사전심의)적합. 회사는 의료광고의 주체가 될 수 없으므로 산출물 게시 책임 구분이 맞음
AI 답변 노출을 보장하지 않는다는 고지의료법 제56조 제2항(효과 보장 금지 취지), 표시광고법적합
재판관할: 민사소송법에 따른 관할 법원약관규제법 제14조(고객에게 부당하게 불리한 관할 합의 금지)적합
약관 변경 7일 전, 불리한 변경 30일 전 고지약관규제법 제3조 취지, 업계 표준약관적합
권익침해 구제 기관 연락처 (1833-6972, 118, 1301, 182)작성지침적합
+ +

5. 다음 단계 제안

+ + +

근거 자료

+ +
+ + diff --git a/docs/security/2026-10-07_rls_anon_read_DRAFT.sql b/docs/security/2026-10-07_rls_anon_read_DRAFT.sql new file mode 100644 index 0000000..bd36b8a --- /dev/null +++ b/docs/security/2026-10-07_rls_anon_read_DRAFT.sql @@ -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; diff --git a/plugins/public-bundle-denylist.json b/plugins/public-bundle-denylist.json new file mode 100644 index 0000000..9105409 --- /dev/null +++ b/plugins/public-bundle-denylist.json @@ -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/" } + ] +} diff --git a/plugins/vite-plugin-public-bundle-guard.ts b/plugins/vite-plugin-public-bundle-guard.ts new file mode 100644 index 0000000..5f8d9fe --- /dev/null +++ b/plugins/vite-plugin-public-bundle-guard.ts @@ -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[] { + 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(); + 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): 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'), + ); + } +} diff --git a/src/components/ClinicNav.tsx b/src/components/ClinicNav.tsx index 4965e34..bbfef69 100644 --- a/src/components/ClinicNav.tsx +++ b/src/components/ClinicNav.tsx @@ -23,6 +23,8 @@ const STEPS: Step[] = [ /** 작업 공간 경로면 병원 id 를 돌려준다. 아니면 null. */ export function clinicIdFromPath(pathname: string): string | null { + // /discovery/sample 은 병원 작업 공간이 아니라 가상 데이터 예시 리포트라 일반 헤더를 쓴다. + if (/^\/discovery\/sample\/?$/.test(pathname)) return null; for (const s of STEPS) { if (s.match.test(pathname)) return pathname.split('/')[2] ?? null; } diff --git a/src/components/ContactModal.tsx b/src/components/ContactModal.tsx index 2da9efe..a356449 100644 --- a/src/components/ContactModal.tsx +++ b/src/components/ContactModal.tsx @@ -12,6 +12,7 @@ import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'motion/react'; import { ArrowRight } from 'lucide-react'; import { submitLead } from '../lib/submitLead'; +import { trackEvent } from '../lib/analytics'; import { CONTACT_EMAIL } from '../lib/contact'; interface ContactModalProps { @@ -86,6 +87,8 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp source, website, }); + // 접수에 성공한 경우만 리드로 센다. 어느 버튼에서 열었는지(source)만 보낸다. + if (result.ok) trackEvent('generate_lead', { lead_source: source }); setState(result.ok ? 'done' : 'error'); }; @@ -200,7 +203,7 @@ export default function ContactModal({ open, onClose, source }: ContactModalProp type="text" value={company} onChange={(e) => setCompany(e.target.value)} - placeholder="예: 뷰성형외과" + placeholder="예: OO성형외과" className={`${INPUT_CLASS} mb-4`} /> diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 1a2ed85..3e3dd03 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -35,8 +35,8 @@ export default function Footer() { Infinite Marketing for Premium Medical Business & Marketing Agency
- Privacy Policy - Terms of Service + 개인정보처리방침 + 이용약관 - - {DISCOVERY_CONTACT_EMAIL} -
diff --git a/src/components/discovery/DiscoveryHero.tsx b/src/components/discovery/DiscoveryHero.tsx index dfb604a..7bcb775 100644 --- a/src/components/discovery/DiscoveryHero.tsx +++ b/src/components/discovery/DiscoveryHero.tsx @@ -3,8 +3,9 @@ * Hero.tsx의 배경·배지·헤드라인 구조를 따르고, 하단에 ABMR 루프 + AI 답변 목업 2열 그리드. * 시안: docs/reports/landing/ai_discovery_landing.html (픽셀 기준) */ -import { Link } from 'react-router'; import { motion } from 'motion/react'; +import { Link } from 'react-router'; +import { SHOW_SAMPLE_REPORT } from '../../lib/site'; import { ArrowRight } from 'lucide-react'; import AbmrLoop from './AbmrLoop'; import type { AxisSummary } from './AbmrLoop'; @@ -13,16 +14,14 @@ import AiAnswerMock from './AiAnswerMock'; interface DiscoveryHeroProps { axes: AxisSummary[]; clinicName: string; - sampleReportPath: string; criteriaCount: number; - /** CTA 클릭 → URL 입력 신청 모달 열기 */ + /** CTA 클릭 → 진단 요청 폼(문의 폼과 같은 ContactModal) 열기 */ onStart: () => void; } export default function DiscoveryHero({ axes, clinicName, - sampleReportPath, criteriaCount, onStart, }: DiscoveryHeroProps) { @@ -86,15 +85,18 @@ export default function DiscoveryHero({ 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" > - URL 입력으로 시작하기 + AEO, GEO 진단 요청하기 - - 샘플 리포트 보기 - + {/* 가상 데이터 예시 리포트로만 연결한다. 실제 병원 리포트로 연결하지 않는다 (src/lib/site.ts) */} + {SHOW_SAMPLE_REPORT && ( + + 샘플 리포트 보기 + + )} {/* ABMR loop + AI answer mock */} diff --git a/src/components/discovery/SampleReport.tsx b/src/components/discovery/SampleReport.tsx index 4eebe1f..3be2090 100644 --- a/src/components/discovery/SampleReport.tsx +++ b/src/components/discovery/SampleReport.tsx @@ -1,5 +1,5 @@ /** - * 샘플 리포트 섹션 — 뷰성형외과 실측 스냅샷. + * 샘플 리포트 섹션. 가상 데이터(discovery_sample)로 만든 예시이며 실제 병원 진단 결과가 아니다. * 점수·등급·항목 %·항목 수는 전부 discoveryScore(v1 실측)·discoveryScoreV2(AEO/GEO) 계산값에서 렌더 (하드코딩 금지). * 발견 칩 3장의 카피는 result.keyFindings를 랜딩용으로 압축한 시안 확정 문안이다. */ @@ -16,28 +16,28 @@ const GRADE_BADGE_STYLE: Record = { D: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]', }; -/** keyFindings 압축본 (시안 확정 카피). 수치는 2026-09-07 재검증(v1.1) 스냅샷과 일치한다. */ +/** 발견 칩 예시 카피. 특정 병원을 알아볼 수 없도록 고유 명칭을 빼고 수치는 가상 값으로 둔다. */ const FINDING_CHIPS = [ { label: '치명', chipClass: 'bg-[#FFF0F0] border-[#F5D5DC] text-[#7C3A4B]', dotColor: '#D4889A', title: 'AI가 누구인지 확정 못 함', - detail: 'MedicalClinic 스키마 없음. 시술 목록 JSON-LD는 문법 오류로 통째로 버려집니다.', + detail: 'MedicalClinic 스키마가 없고, 시술 정보 JSON-LD는 문법 오류로 읽히지 않습니다.', }, { label: '보완', chipClass: 'bg-[#FFF6ED] border-[#F5E0C5] text-[#7C5C3A]', dotColor: '#D4A872', title: '인용할 문장이 없음', - detail: '정의문 0개, HTML 표 0개, FAQ는 프리저베 한 페이지뿐. 답변엔진이 잘라 갈 단락이 없습니다.', + detail: '정의문 2개, HTML 표 0개, FAQ는 시술 페이지 한 곳뿐. 답변엔진이 잘라 갈 단락이 적습니다.', }, { label: '강점', chipClass: 'bg-[#F3F0FF] border-[#D5CDF5] text-[#4A3A7C]', dotColor: '#9B8AD4', 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 }} >
- SAMPLE REPORT · 실제 사례 + SAMPLE REPORT · 예시

읽을 수는 있지만, 인용할 근거가 없습니다.

- 실제 1차 진단 결과입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, + 가상의 A성형외과로 만든 예시 리포트입니다. 답변 준비도(AEO)와 출처 준비도(GEO)를 각각 100점으로 보고, 어디가 강하고 어디가 비어 있는지 항목 단위로 드러냅니다.

diff --git a/src/components/discovery/UseCasesSection.tsx b/src/components/discovery/UseCasesSection.tsx new file mode 100644 index 0000000..6fe6fd2 --- /dev/null +++ b/src/components/discovery/UseCasesSection.tsx @@ -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 ( +
+
+
USE CASES
+

+ 진단 이후 무엇이 달라졌는지 +

+
+ {cases.map((c, index) => ( + +
{c.specialty}
+
{c.clinicLabel}
+

{c.challenge}

+
    + {c.actions.map((a) => ( +
  • {a}
  • + ))} +
+

{c.outcome}

+

{c.measuredAt}

+
+ ))} +
+
+
+ ); +} diff --git a/src/data/aeoGeoRubric.ts b/src/data/aeoGeoRubric.ts index 28f5b2e..c9fc102 100644 --- a/src/data/aeoGeoRubric.ts +++ b/src/data/aeoGeoRubric.ts @@ -279,7 +279,7 @@ export const AEO_GEO_RUBRIC: Rubric = { id: 'C3', category: 'content', name: '제목 계층 의미성', - rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "VIEW SELFIE" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.', + rationale: 'H1은 페이지당 1개, 주제를 서술해야 한다. "MAIN VISUAL" 같은 라벨형 H1은 AI에게 아무 정보도 주지 않는다.', weight: 3, evidenceGrade: 'measured', control: 'direct', diff --git a/src/data/discovery_sample.ts b/src/data/discovery_sample.ts new file mode 100644 index 0000000..024097a --- /dev/null +++ b/src/data/discovery_sample.ts @@ -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: [], +}; diff --git a/src/data/pricingTiers.ts b/src/data/pricingTiers.ts new file mode 100644 index 0000000..ae368cf --- /dev/null +++ b/src/data/pricingTiers.ts @@ -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: '대형 · 멀티 분원 병원', + }, +]; diff --git a/src/data/useCases.ts b/src/data/useCases.ts new file mode 100644 index 0000000..c30c2ff --- /dev/null +++ b/src/data/useCases.ts @@ -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[] = []; diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..e45d571 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,11 @@ +/** + * 방문 측정 이벤트. GTM 이 있는 배포(webforai.kr)에서만 dataLayer 로 넘기고, 없으면 아무것도 하지 않는다. + * 이벤트에는 개인정보(이름·연락처·문의 내용)를 넣지 않는다. 어느 버튼에서 왔는지 같은 화면 정보만 보낸다. + */ +type DataLayerWindow = Window & { dataLayer?: Record[] }; + +export function trackEvent(event: string, params: Record = {}): void { + const w = window as DataLayerWindow; + if (!Array.isArray(w.dataLayer)) return; + w.dataLayer.push({ event, ...params }); +} diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..cbbc836 --- /dev/null +++ b/src/lib/site.ts @@ -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단계). diff --git a/src/main.tsx b/src/main.tsx index 3d92fc9..d190a97 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; 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 LandingPage from './pages/LandingPage.tsx'; import PricingPage from './pages/PricingPage.tsx'; @@ -21,39 +21,59 @@ import ActionPlanPage from './pages/ActionPlanPage.tsx'; import SupportersBuildPage from './pages/SupportersBuildPage.tsx'; import ImageReviewPage from './pages/ImageReviewPage.tsx'; import SiteBuildPage from './pages/SiteBuildPage.tsx'; +import PrivacyPage from './pages/PrivacyPage.tsx'; +import TermsPage from './pages/TermsPage.tsx'; +import SampleReportPage from './pages/SampleReportPage.tsx'; +import { DISCOVERY_SITE, SHOW_PRICING, SHOW_SAMPLE_REPORT } from './lib/site'; import './index.css'; -// 배포별 루트 페이지 분기. Vercel 프로젝트의 환경변수로 정한다. -const DISCOVERY_SITE = import.meta.env.VITE_SITE === 'discovery'; createRoot(document.getElementById('root')!).render( }> - {/* 루트에서 무엇을 그릴지는 배포마다 다르다. VITE_SITE=discovery 로 빌드한 배포(webforai.kr)는 - AI Discovery 랜딩을 루트에 놓고, 값이 없으면 지금까지처럼 INFINITH 랜딩을 놓는다. - 한 코드베이스로 두 사이트를 내되 기존 배포의 동작은 바꾸지 않는다. */} - : } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* 배포마다 여는 화면이 다르다. VITE_SITE=discovery 로 빌드한 공개 배포(webforai.kr)는 AI Discovery 랜딩을 + 루트에 두고 공개 페이지(랜딩·로그인·약관)만 연다. 요금·예시 리포트는 src/lib/site.ts 스위치가 켜졌을 때만 연다. + 실제 병원 진단 결과·내부 작업 화면 경로는 홈으로 돌린다. + 값이 없으면 지금까지처럼 INFINITH 랜딩과 전체 화면을 연다. */} + {DISCOVERY_SITE ? ( + <> + } /> + {SHOW_PRICING && } />} + {SHOW_SAMPLE_REPORT && } />} + } /> + } /> + } /> + } /> + + ) : ( + <> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + )} diff --git a/src/pages/ApiDashboardPage.tsx b/src/pages/ApiDashboardPage.tsx index 5921a5e..2b69811 100644 --- a/src/pages/ApiDashboardPage.tsx +++ b/src/pages/ApiDashboardPage.tsx @@ -147,7 +147,7 @@ const API_REGISTRY: ApiConfig[] = [ name: 'Google Gemini', description: 'AI 이미지 생성 + Vision 분석 (스크린샷 OCR)', envKeys: ['GEMINI_API_KEY'], - layer: 'both', + layer: 'edge-function', docsUrl: 'https://ai.google.dev/docs', IconComponent: DatabaseFilled, category: 'ai', @@ -161,8 +161,8 @@ const API_REGISTRY: ApiConfig[] = [ id: 'creatomate', name: 'Creatomate', description: '마케팅 영상 자동 생성 (템플릿 기반)', - envKeys: ['VITE_CREATOMATE_API_KEY'], - layer: 'frontend', + envKeys: ['CREATOMATE_API_KEY'], + layer: 'edge-function', docsUrl: 'https://creatomate.com/docs/api/introduction', IconComponent: VideoFilled, category: 'ai', @@ -176,12 +176,19 @@ const API_REGISTRY: ApiConfig[] = [ /* ────────────────── Environment Variable Checker ─────────────── */ +// 브라우저에서 확인할 수 있는 값은 공개를 전제로 만든 변수뿐이다. import.meta.env 를 이름으로 동적 조회하면 +// Vite 가 VITE_ 변수 전체를 번들에 넣으므로(2026-10-07 VITE_CREATOMATE_API_KEY 노출 원인), 여기 적은 것만 읽는다. +const PUBLIC_ENV: Record = { + VITE_SUPABASE_URL: import.meta.env.VITE_SUPABASE_URL, + VITE_SUPABASE_ANON_KEY: import.meta.env.VITE_SUPABASE_ANON_KEY, +}; + function checkEnvVars(): ApiStatusInfo[] { return API_REGISTRY.map((api) => { const keyPresent: Record = {}; for (const key of api.envKeys) { if (key.startsWith('VITE_')) { - const val = (import.meta.env as Record)[key]; + const val = PUBLIC_ENV[key]; keyPresent[key] = !!val && val.length > 0; } else { keyPresent[key] = true; @@ -269,12 +276,12 @@ function EnvKeyRow({ envKey, present, isServerSide }: { key?: string; envKey: st const value = isServerSide ? '(Edge Function 환경변수 — 프론트엔드에서 확인 불가)' : showKey - ? (import.meta.env as Record)[envKey] || '' + ? PUBLIC_ENV[envKey] || '' : '••••••••••••••••'; const handleCopy = () => { if (!isServerSide) { - navigator.clipboard.writeText((import.meta.env as Record)[envKey] || ''); + navigator.clipboard.writeText(PUBLIC_ENV[envKey] || ''); setCopied(true); setTimeout(() => setCopied(false), 1500); } diff --git a/src/pages/DiscoveryLandingPage.tsx b/src/pages/DiscoveryLandingPage.tsx index 736a1c4..fcfb13b 100644 --- a/src/pages/DiscoveryLandingPage.tsx +++ b/src/pages/DiscoveryLandingPage.tsx @@ -8,7 +8,7 @@ */ import { useMemo, useState } from 'react'; 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 { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2'; import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2'; @@ -20,11 +20,16 @@ import SampleReport from '../components/discovery/SampleReport'; import Roadmap from '../components/discovery/Roadmap'; import HonestyStrip from '../components/discovery/HonestyStrip'; 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() { - const [leadOpen, setLeadOpen] = useState(false); - const result = DISCOVERY_VIEWCLINIC; + // 진단 요청은 문의하기와 같은 폼(구글시트 기록 + 메일 알림)으로 받는다. source 로 어느 버튼에서 왔는지 남긴다. + const [leadSource, setLeadSource] = useState(null); + // 공개 랜딩은 가상 예시 데이터만 쓴다. 실제 병원 진단 결과를 싣지 않는다. + const result = DISCOVERY_SAMPLE; const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]); const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]); const axes: AxisSummary[] = [overallV2.aeo, overallV2.geo].map((ax) => ({ @@ -47,21 +52,21 @@ export default function DiscoveryLandingPage() { setLeadOpen(true)} + onStart={() => setLeadSource('discovery_hero')} /> + {SHOW_USE_CASES && USE_CASES.length > 0 && } - setLeadOpen(true)} /> - setLeadOpen(false)} /> + setLeadSource('discovery_cta')} /> + setLeadSource(null)} source={leadSource ?? 'discovery'} /> ); } diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx index 57fa24f..39cab2b 100644 --- a/src/pages/DiscoveryReportPage.tsx +++ b/src/pages/DiscoveryReportPage.tsx @@ -13,6 +13,7 @@ import type { CategoryId, CategoryScore, ControlLevel, + DiscoveryResult, EvidenceGrade, EvidenceMethod, RubricCriterion, @@ -71,20 +72,22 @@ const scoreColor = (pct: number) => export default function DiscoveryReportPage() { const { id } = useParams<{ id: string }>(); const result = id ? DISCOVERY_RESULTS[id] : undefined; - const overall = useMemo( - () => (result ? scoreDiscovery(AEO_GEO_RUBRIC, result) : null), - [result], - ); - const overallV2 = useMemo( - () => (result ? scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result) : null), - [result], - ); // 진단은 AEO/GEO 36항목 실측 결과가 있어야 그린다. 사이트 빌드와는 별개 산출물이라 // 빌드만 끝난 병원은 여기에 들어올 수 있다. 막다른 길로 두지 않고 다음 단계로 잇는다. - if (!result || !overall) { + if (!result) { return ; } + return ; +} + +/** + * 리포트 본문. 실제 병원 리포트(/discovery/:id)와 공개 예시 리포트(/discovery/sample)가 함께 쓴다. + * sample 이면 가상 데이터라는 표시를 붙인다. 이 컴포넌트는 실제 병원 목록(DISCOVERY_RESULTS)을 읽지 않는다. + */ +export function DiscoveryReportView({ result, sample = false }: { result: DiscoveryResult; sample?: boolean }) { + const overall = useMemo(() => scoreDiscovery(AEO_GEO_RUBRIC, result), [result]); + const overallV2 = useMemo(() => scoreDiscoveryV2(AEO_GEO_RUBRIC_V2, result), [result]); const resultById = new Map(result.results.map((r) => [r.criterionId, r])); @@ -108,14 +111,21 @@ export default function DiscoveryReportPage() {

{result.clinicName}

{result.industry}

- - {result.url} - + {result.url && ( + + {result.url} + + )} + {sample && ( +

+ 가상 데이터로 만든 예시 리포트입니다. 실제 병원의 진단 결과가 아닙니다. +

+ )}

실측일 {result.auditedAt} · 실측 항목 {overall.categories.reduce((s, c) => s + c.verifiedCount, 0)} / {' '}{AEO_GEO_RUBRIC.criteria.length} · AEO·GEO 각 100점 독립 지표 · 미검증 항목은 분모 제외 diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 3250300..5fabbc9 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -13,7 +13,7 @@ import { useState, type FormEvent } from 'react'; import { Link } from 'react-router'; import { ArrowRight } from 'lucide-react'; -import { buildContactMailto, CONTACT_EMAIL } from '../lib/contact'; +import { CONTACT_EMAIL } from '../lib/contact'; export default function LoginPage() { const [email, setEmail] = useState(''); @@ -23,7 +23,7 @@ export default function LoginPage() { const handleSubmit = (e: FormEvent) => { e.preventDefault(); setNotice( - `로그인 기능은 준비 중입니다. 계약을 희망하시는 경우 ${CONTACT_EMAIL} 로 문의 주세요.`, + `계약을 희망하시는 경우 ${CONTACT_EMAIL} 로 문의 주세요.`, ); }; @@ -49,8 +49,6 @@ export default function LoginPage() { {/* 준비중 배너 */}

- 현재 로그인 기능은 준비 중입니다. -
신규 도입 문의는 하단 "문의하기"를 이용해 주세요.

@@ -113,17 +111,6 @@ export default function LoginPage() { )} - - {/* 하단 문의하기 */} -
-

계약을 원하시나요?

- - 문의하기 → - -
); diff --git a/src/pages/PricingPage.tsx b/src/pages/PricingPage.tsx index 93158fa..963a425 100644 --- a/src/pages/PricingPage.tsx +++ b/src/pages/PricingPage.tsx @@ -16,7 +16,7 @@ * (analytics 연동은 Supabase `analytics_events` 테이블 도입 후 후속 작업) * * 핵심 데이터 소스: - * - src/data/pricingTiers.ts ← (이 파일 내부에 임시 정의, Step 3에서 분리 고려) + * - src/data/pricingTiers.ts ← 요금표 데이터 (2026-10-07 분리. 요금 확정 시 이 파일만 고친다) */ import React, { useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; @@ -32,83 +32,7 @@ import Badge from '../components/Badge'; import FeatureComparisonTable from '../components/pricing/FeatureComparisonTable'; import FAQ from '../components/pricing/FAQ'; import { buildContactMailto } from '../lib/contact'; - -// ─── 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: '대형 · 멀티 분원 병원', - }, -]; +import { PRICING_TIERS as tiers, type Tier } from '../data/pricingTiers'; // ─── 가격 포맷터 ────────────────────────────────────────────────── // 29_0000원 → "29만원" 포맷. 1000원 단위까지는 표기 안 함 (B2B 가격은 만원 단위) diff --git a/src/pages/PrivacyPage.tsx b/src/pages/PrivacyPage.tsx new file mode 100644 index 0000000..fe9d085 --- /dev/null +++ b/src/pages/PrivacyPage.tsx @@ -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 ( + + ); +} diff --git a/src/pages/SampleReportPage.tsx b/src/pages/SampleReportPage.tsx new file mode 100644 index 0000000..c21e101 --- /dev/null +++ b/src/pages/SampleReportPage.tsx @@ -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 ; +} diff --git a/src/pages/TermsPage.tsx b/src/pages/TermsPage.tsx new file mode 100644 index 0000000..8c68a0c --- /dev/null +++ b/src/pages/TermsPage.tsx @@ -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 ( + + ); +} diff --git a/src/services/creatomateVideoGen.ts b/src/services/creatomateVideoGen.ts index 14a537c..a31382b 100644 --- a/src/services/creatomateVideoGen.ts +++ b/src/services/creatomateVideoGen.ts @@ -1,202 +1,63 @@ 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 { videoUrl: string; prompt: string; } -interface CreatomateRender { - id: string; - status: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed'; - url?: string; - error_message?: string; +interface RenderState { + ok: boolean; + id?: string; + status?: 'planned' | 'waiting' | 'transcribing' | 'rendering' | 'succeeded' | 'failed'; + url?: string | null; + error?: string | null; } -const ASPECT_DIMENSIONS: Record = { - '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 ENDPOINT = '/api/creatomate-render'; -const PILLAR_TITLES: Record = { - 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 { +async function pollRender(renderId: string, maxAttempts = 30): Promise { for (let i = 0; i < maxAttempts; i++) { - const res = await fetch(`${API_BASE}/renders/${renderId}`, { - headers: { Authorization: `Bearer ${CREATOMATE_API_KEY}` }, - }); - - if (!res.ok) { - throw new Error(`렌더 상태 확인 실패 (${res.status})`); - } - - 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)); + const res = await fetch(`${ENDPOINT}?id=${encodeURIComponent(renderId)}`); + if (!res.ok) throw new Error(`렌더 상태 확인 실패 (${res.status})`); + const render: RenderState = await res.json(); + if (render.status === 'succeeded' && render.url) return render; + if (render.status === 'failed') throw new Error(render.error || '영상 렌더링에 실패했습니다'); + await new Promise((resolve) => setTimeout(resolve, 2000)); } - throw new Error('영상 렌더링 시간이 초과되었습니다 — 다시 시도해주세요'); } export async function generateVideo(state: StudioState): Promise { - if (!CREATOMATE_API_KEY) { - throw new Error('Creatomate API 키가 설정되지 않았습니다'); - } - - const source = buildSource(state); const prompt = `${state.channel}/${state.format} — ${state.pillarId || 'general'}`; - let renderResponse; + let res: Response; try { - renderResponse = await fetch(`${API_BASE}/renders`, { + res = await fetch(ENDPOINT, { method: 'POST', - headers: { - 'Authorization': `Bearer ${CREATOMATE_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ source }), + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }), }); } catch (err) { - throw new Error('Creatomate 서버에 연결할 수 없습니다'); + console.error('[generateVideo] 서버 연결 실패', err); + throw new Error('영상 생성 서버에 연결할 수 없습니다'); } - if (!renderResponse.ok) { - const status = renderResponse.status; - if (status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요'); - if (status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요'); - if (status === 400) { - const body = await renderResponse.json().catch(() => ({})); - throw new Error(`영상 설정 오류: ${body.message || '잘못된 요청'}`); - } - throw new Error(`영상 생성 요청 실패 (${status})`); + const render: RenderState | null = await res.json().catch(() => null); + if (!res.ok || !render?.ok) { + if (res.status === 500) throw new Error('Creatomate API 키가 설정되지 않았습니다'); + if (res.status === 401) throw new Error('Creatomate API 인증 실패 — 관리자에게 문의하세요'); + if (res.status === 429) throw new Error('렌더 한도 초과 — 잠시 후 다시 시도해주세요'); + if (res.status === 400) throw new Error('영상 설정 오류: 잘못된 요청'); + throw new Error(`영상 생성 요청 실패 (${res.status})`); } + if (!render.id) throw new Error('렌더 ID를 받지 못했습니다'); - const renders: CreatomateRender[] = await renderResponse.json(); - 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 + if (render.status === 'succeeded' && render.url) return { videoUrl: render.url, prompt }; const completed = await pollRender(render.id); return { videoUrl: completed.url!, prompt }; } diff --git a/src/services/geminiImageGen.ts b/src/services/geminiImageGen.ts index 747a2b9..f407c46 100644 --- a/src/services/geminiImageGen.ts +++ b/src/services/geminiImageGen.ts @@ -1,44 +1,9 @@ -import { GoogleGenAI } from '@google/genai'; import type { StudioState } from '../types/studio'; -import { CHANNEL_OPTIONS } from '../types/studio'; -const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY ?? '' }); - -function buildPrompt(state: StudioState): string { - const channel = CHANNEL_OPTIONS.find(c => c.channel === state.channel); - const format = channel?.formats.find(f => f.key === state.format); - - const pillarDescriptions: Record = { - 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 = { - 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(' '); -} +/** + * 콘텐츠 스튜디오 이미지 생성. Gemini 는 서버 함수(api/gemini-image.js)에서만 호출한다. + * 브라우저는 키를 모르고 선택값(채널·포맷·주제)만 보낸다. 프롬프트도 서버에서 만든다 (2026-10-07). + */ export interface GenerateResult { imageDataUrl: string; @@ -46,36 +11,24 @@ export interface GenerateResult { } export async function generateImage(state: StudioState): Promise { - const prompt = buildPrompt(state); - - let response; + let res: Response; try { - response = await client.models.generateContent({ - model: 'gemini-2.5-flash-image', - contents: prompt, - config: { - responseModalities: ['Text', 'Image'], - }, + res = await fetch('/api/gemini-image', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channel: state.channel, format: state.format, pillarId: state.pillarId }), }); - } catch (err: any) { - const code = err?.status ?? err?.code ?? err?.message?.match(/"code":(\d+)/)?.[1]; - if (String(code) === '429' || err?.message?.includes('RESOURCE_EXHAUSTED')) { - throw new Error('API 요청 한도 초과 — 잠시 후 다시 시도해주세요'); - } - if (String(code) === '400' || err?.message?.includes('INVALID_ARGUMENT')) { - throw new Error('API 설정 오류 — 관리자에게 문의하세요'); - } - throw new Error('이미지 생성 중 오류가 발생했습니다'); + } catch (err) { + console.error('[generateImage] 서버 연결 실패', err); + throw new Error('이미지 생성 서버에 연결할 수 없습니다'); } - // 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 }; + 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('이미지 생성 중 오류가 발생했습니다'); + } + return { imageDataUrl: data.imageDataUrl, prompt: data.prompt }; } diff --git a/supabase/functions/_shared/visionAnalysis.ts b/supabase/functions/_shared/visionAnalysis.ts index 5e9dc3e..c9faa25 100644 --- a/supabase/functions/_shared/visionAnalysis.ts +++ b/supabase/functions/_shared/visionAnalysis.ts @@ -387,7 +387,7 @@ export async function analyzeScreenshot( try { console.log(`[vision] Analyzing screenshot: ${pageType} (${Math.round(base64.length / 1024)}KB)`); 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", headers: { "Content-Type": "application/json" }, @@ -399,8 +399,10 @@ export async function analyzeScreenshot( ], }], generationConfig: { + // gemini-2.0-flash 는 Google 이 내려 404 다 (2026-10-07 확인). 3.5-flash 는 답 전에 내부 추론 토큰(약 550)을 쓰고 + // 그것도 출력 한도에 들어가므로 JSON 이 잘리지 않게 한도를 넉넉히 둔다. temperature: 0.1, - maxOutputTokens: 2048, + maxOutputTokens: 4096, }, }), }, diff --git a/vite.config.ts b/vite.config.ts index 8bf55d1..1d1374d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import {defineConfig, loadEnv} from 'vite'; import { supabaseSync } from './plugins/vite-plugin-supabase-sync'; +import { assertNoSecretValues, assertPublicBundleClean } from './plugins/vite-plugin-public-bundle-guard'; export default defineConfig(({mode}) => { const env = loadEnv(mode, '.', ''); @@ -41,6 +42,15 @@ export default defineConfig(({mode}) => { '', ] : []; + // 방문 측정: Google 태그 관리자(GTM) 컨테이너. GA4(G-013Z3BFY35)와 키 이벤트는 컨테이너 안에서 설정한다. + // 공개 배포(webforai.kr)에만 넣는다. 컨테이너 ID 는 공개 값이다. 개인정보처리방침 제5·8조와 함께 관리한다. + const GTM_ID = 'GTM-WQNTZVZH'; + const gtmHead = isDiscovery + ? [``] + : []; + const gtmBody = isDiscovery + ? `` + : ''; const headHtml = [ `${head.title}`, ``, @@ -50,6 +60,7 @@ export default defineConfig(({mode}) => { ``, ...share, ...verify, + ...gtmHead, ].join('\n '); return { @@ -62,8 +73,15 @@ export default defineConfig(({mode}) => { name: 'infinith-root-favicon', apply: 'build' as const, writeBundle(options: {dir?: string}) { - if (!isDiscovery) return; 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 [ ['discovery-favicon.ico', 'favicon.ico'], ['discovery-favicon-32.png', 'favicon-32.png'], @@ -73,12 +91,17 @@ export default defineConfig(({mode}) => { const src = path.resolve(out, from); if (fs.existsSync(src)) fs.copyFileSync(src, path.resolve(out, to)); } + // 정리가 끝난 결과물을 검사한다. 실제 병원명·비밀 키가 남아 있으면 빌드를 실패시켜 배포를 막는다. + // writeBundle 은 플러그인끼리 병렬로 돌 수 있어 별도 플러그인이 아니라 여기서 정리 뒤에 부른다. + assertPublicBundleClean(out); }, }, { name: 'infinith-head', transformIndexHtml(html: string) { - return html.replace(/[\s\S]*?<\/title>/, headHtml); + return html + .replace(/<title>[\s\S]*?<\/title>/, headHtml) + .replace('<body>', gtmBody ? `<body>\n ${gtmBody}` : '<body>'); }, }, tailwindcss(), @@ -87,8 +110,17 @@ export default defineConfig(({mode}) => { serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY ?? env.VITE_SUPABASE_ANON_KEY ?? '', }), ], - define: { - 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + // 서버 전용 키(Gemini·Creatomate 등)는 define·VITE_ 접두사로 번들에 넣지 않는다. 호출은 api/*.js 서버 함수에서 한다 (2026-10-07). + 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: { alias: {