From daf6350c49bb2b5492df78b7e16962cba17c6b90 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 15:28:51 +0900 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=EC=A0=9C=ED=92=88=20=ED=95=B8?= =?UTF-8?q?=EB=93=9C=EC=98=A4=EB=B2=84=EC=97=90=20=EB=B0=9C=ED=96=89(?= =?UTF-8?q?=EC=83=89=EC=9D=B8)=20=EB=8B=A8=EA=B3=84=EC=99=80=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md index 59c9a08..e895d9a 100644 --- a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md +++ b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md @@ -55,6 +55,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI - 진단 결과가 코드 파일(`discovery_.ts`)이라 병원마다 프런트 배포가 필요하다. 런타임 데이터(Supabase 또는 JSON 스토리지)로 옮겨야 "URL 입력 → 리포트 링크"가 된다. - 측정 지표가 리포트 카드에 자동으로 들어가지 않는다(스크립트 산출물을 사람이 옮긴다). - 워커가 Mac 에 묶여 있다. 규칙표 잠정값·큐레이션 장소·의료진 후보·이미지·채널 12건은 사람 확인 대기. +- 색인 작업의 사람 몫(네이버 등록·GSC 소유자·도메인·법무)이 체크리스트 파일로만 나오고, 담당자에게 알림·요청하는 UI 가 아직 없다(§4-9). - AI Discovery 가격·계약 조건 미확정. 랜딩 FAQ 의 해지·환불 항목 삭제 상태. ## 4. Product 빌드 순서 (권장) @@ -67,6 +68,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI 6. **운영 이관**: Mac launchd 폴러 → Trigger.dev(또는 동등)로. 키·비용 로그(`status.json` costUsd)·실패 알림. 7. **가격·계약**: AI Discovery 티어(진단만 / 진단+서포터즈 / +측정·관리) 확정, 랜딩 FAQ·계약서·해지 조건. FrostAI 비교 문서의 제안을 출발점으로. 8. **다음 병원**: 톡스앤필·차앤박(피부과), 레지스트리 73곳 중 회귀 병원. 한 화면 비교 산출물은 만들지 않는다. +9. **발행(색인) 단계**: "서포터즈 사이트 완료"의 정의는 배포가 아니라 색인 작업과 사람 체크리스트까지 끝난 상태다(haewon 결정 2026-09-11). 워커 `publish` 단계(`supporters/scripts/publish_site.mjs`)가 자동으로 하는 것: 라이브 확인(홈·robots·sitemap·noindex 헤더), IndexNow 로 사이트맵 URL 전부 알림(빙·네이버 공용, 색인 허용 빌드일 때만), Google Search Console 사이트맵 제출과 URL 색인 상태 조회(서비스 계정 `GOOGLE_SERVICE_ACCOUNT_JSON` 있을 때), 검색엔진 소유 확인 메타 태그(site.json `googleSiteVerification`·`naverSiteVerification`), IndexNow 키 파일(`public/.txt`) 자동 생성, `--indexable` 로 noindex 헤더 제거·승인 글만 발행, `--domain` 으로 정식 도메인 연결. 사람에게 남기는 것(`~/supporters-builds//publish-checklist.md`, `status.humanTasks`): 네이버 서치어드바이저 등록·소유 확인·사이트맵/RSS 제출(공개 API 없음), Google Search Console 소유자 추가(첫 1회), 정식 도메인 결정, 색인 허용 전환 전 법무 3건·게시 승인, 병원 자산에서 백링크·sameAs, 네이버 블로그 재작성 트랙. 하지 않는 것: Google Indexing API(채용·라이브 방송 전용), 색인 보장 약속. 제품에서는 이 체크리스트를 확인 패널(`ClinicInputsPanel`) 또는 운영자 알림으로 사람에게 요청하게 만드는 것이 다음 일이다. ## 5. haewon 결정 대기 From dca494fc212a0fa64da37922be12b01fe6e85c60 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 18:28:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(supporters):=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4(/stay=C2=B7/en/stay)=20=EB=B7=B0=20=EC=83=98?= =?UTF-8?q?=ED=94=8C=EC=97=90=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=C2=B7=EC=96=B8=EC=96=B4=20=EC=A7=9D=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A7=B5=C2=B7=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D,=20=EA=B5=AC=EC=A1=B0=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=84=A0=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- supporters/scripts/gate/layout-baseline.json | 3 + supporters/src/data/recoveryNotes.json | 8 + supporters/src/layouts/Base.astro | 2 + supporters/src/lib/i18n.ts | 5 +- supporters/src/pages/en/stay.astro | 514 ++++++++++++++++++ supporters/src/pages/sitemap.xml.ts | 2 + supporters/src/pages/stay.astro | 515 +++++++++++++++++++ supporters/src/styles/global.css | 177 +++++++ supporters/vercel.json | 10 - 9 files changed, 1224 insertions(+), 12 deletions(-) create mode 100644 supporters/src/data/recoveryNotes.json create mode 100644 supporters/src/pages/en/stay.astro create mode 100644 supporters/src/pages/stay.astro diff --git a/supporters/scripts/gate/layout-baseline.json b/supporters/scripts/gate/layout-baseline.json index cd72c76..59ad71b 100644 --- a/supporters/scripts/gate/layout-baseline.json +++ b/supporters/scripts/gate/layout-baseline.json @@ -16,6 +16,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "main", @@ -287,6 +288,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "div.backbar-wrap", @@ -515,6 +517,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "div.backbar-wrap", diff --git a/supporters/src/data/recoveryNotes.json b/supporters/src/data/recoveryNotes.json new file mode 100644 index 0000000..1ed4877 --- /dev/null +++ b/supporters/src/data/recoveryNotes.json @@ -0,0 +1,8 @@ +{ + "_comment": "시술 후 관리 안내(빈 값). 워커의 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 채운다. items 가 비어 있으면 /stay·/en/stay 는 이 블록을 그리지 않는다.", + "clinic": "", + "generatedAt": "", + "sourceNote": "병원 홈페이지 시술 페이지 원문", + "items": [], + "counts": {} +} diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index 0add854..8d20129 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -63,6 +63,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array {u.nav_posts} {u.nav_videos} {u.nav_news} + {u.nav_stay} {u.nav_visit} {u.nav_plan} {u.nav_about} @@ -70,6 +71,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array ) : ( <> {u.nav_home} + {u.nav_stay} {u.nav_plan} {u.nav_visit} {u.nav_clinic} diff --git a/supporters/src/lib/i18n.ts b/supporters/src/lib/i18n.ts index 030d600..58f55e5 100644 --- a/supporters/src/lib/i18n.ts +++ b/supporters/src/lib/i18n.ts @@ -6,6 +6,7 @@ export type Lang = 'ko' | 'en'; const PAIRS: [string, string][] = [ ['/', '/en'], ['/plan', '/en/plan'], + ['/stay', '/en/stay'], ]; export function switchHref(pathname: string, to: Lang): string { const p = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '').replace(/\/$/, '') || '/'; @@ -21,7 +22,7 @@ export const UI: Record> = { ko: { sample: '샘플 사이트입니다. 정식 공개 전입니다.', brand: '서포터즈', - nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개', + nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_stay: '주변 안내', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개', cta: '상담 예약', back: '이전 페이지', backAria: '이전 페이지로 돌아가기', totop: '맨 위로', switchLabel: 'EN', switchAria: 'Switch to English', @@ -33,7 +34,7 @@ export const UI: Record> = { en: { sample: 'Sample site. Not yet public.', brand: 'Supporters', - nav_home: 'Home', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기', + nav_home: 'Home', nav_stay: 'Nearby', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기', cta: 'Book a consultation', back: 'Back', backAria: 'Go back to the previous page', totop: 'Back to top', switchLabel: 'KO', switchAria: '한국어로 보기', diff --git a/supporters/src/pages/en/stay.astro b/supporters/src/pages/en/stay.astro new file mode 100644 index 0000000..da0f913 --- /dev/null +++ b/supporters/src/pages/en/stay.astro @@ -0,0 +1,514 @@ +--- +// 외국인 환자용 주변 안내(/en/stay). /stay 의 영문 짝이다. haewon 결정(2026-09-11): 주변 안내(/en/stay)와 회복 일정 플래너(/en/plan)는 별개 페이지다. +// 1. Around the clinic : 도보·차 소요 시간으로 거른 주변 장소 +// 2. Festivals : 계절로 묶은 행사 +// 3. Suggested days : 시간표와 지도가 붙은 동선 +// + After-treatment care(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장(번역) +// +// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가 +// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다. +// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소·도시는 factSheet, 기준점은 +// medicalTourism.meta.origin 에서만 읽는다. 이 파일에 특정 병원이나 지역 이름을 적지 않는다. +// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "pending" 안내를 그리고 빌드는 그대로 된다. +// +// 만들지 않는 것 +// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다. +// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다. +// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다. +// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다. +import Base from '../../layouts/Base.astro'; +import { fact, site as S, SITE_NAME, clinicSchema, has } from '../../lib'; +import data from '../../data/medicalTourism.json'; +import recovery from '../../data/recoveryNotes.json'; + +const f = fact as Record; +const Sx = S as Record; +const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const T = data as any; +// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 확인 대기로 그린다. +const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string }; +const ready = Boolean(origin.lat && origin.lng); +const total: number = T.filters?.total ?? 0; +const festivals: any[] = T.festivals ?? []; +const updated: string = (T.meta?.fetchedAt || '').slice(0, 10); +// 거리의 기준 주소. 영문 주소가 있으면 그것, 없으면 수집 때 쓴 주소, 그다음 팩트 시트 국문 주소. +const originAddress = f.address?.fullEn || origin.address || f.address?.full || ''; +// 도시 이름은 영문 주소의 마지막 항목("..., Gangnam-gu, Seoul" 이면 Seoul)에서 읽는다. 없으면 Korea. +const cityEn = (f.address?.fullEn || '').split(',').pop()?.trim() || 'Korea'; +const festKm: number = T.meta?.festivalRadiusKm || 0; + +type Place = { + id: string; title: string; address: string; image: string | null; overview: string; + homepage: string; lat: number | null; lng: number | null; distanceM: number | null; + travel: { mode: string; minutes: number; label: string } | null; +}; + +const GROUPS: Array<{ key: string; label: string; note: string }> = [ + { key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' }, + { key: 'stay', label: 'Hotels', note: 'Places to sleep' }, + { key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' }, + { key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' }, + { key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' }, + { key: 'shopping', label: 'Shopping', note: 'Stores and markets' }, +]; + +const SEASONS = [ + { key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' }, + { key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' }, +]; +const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()]; + +// 검색으로 보낸다. 없는 URL 을 만들지 않는다. +const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(`${q} ${cityEn}`)}`; +// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다. +const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : ''); +// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다. +const shortName = (t: string) => { + const m = t.match(/\(([^)]+)\)\s*$/); + return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim(); +}; +const firstSentence = (s: string, n = 150) => { + if (!s) return ''; + const cut = s.slice(0, n); + const stop = cut.lastIndexOf('. '); + return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : ''); +}; + +/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */ +const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0; +const pick = (key: string, maxMin: number, n: number): Place[] => + (((T.places ?? {})[key] ?? []) as Place[]) + .filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat) + .slice(0, n); +const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n); + +const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; +function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) { + let t = startMin; + const out: any[] = []; + stops.forEach((s, i) => { + const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5); + t += move; + const from = t; + t += s.stay; + out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng }); + }); + return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) }; +} +const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note }); + +const ROUTES = [ + { + id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often', + blurb: 'Everything within a short walk. You do not need a taxi.', + route: buildRoute(15 * 60, [ + clinicStop(clinic, 40, 'Your appointment ends here.'), + ...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })), + ...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ]), + }, + { + id: 'short', title: 'A short outing', who: 'When you can walk a little further', + blurb: 'Still close, but out of the block.', + route: buildRoute(13 * 60, [ + clinicStop(clinic, 20, 'Start from the clinic.'), + ...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ]), + }, + { + id: 'city', title: 'When you can travel', who: 'Later in your stay', + blurb: 'Sights across the city. Expect to take a taxi or the subway.', + route: buildRoute(10 * 60, [ + clinicStop(clinic, 10, 'Set off from the clinic.'), + ...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), + ...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), + ]), + }, +].filter((r) => r.route.stops.length > 1); + +/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다. + 빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다. + 타일은 OSM 정책상 출처를 반드시 표기한다. */ +const TILE = 256, MAPW = 680, MAPH = 340; +const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z; +const latToPx = (lat: number, z: number) => { + const r = (lat * Math.PI) / 180; + return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z; +}; + +function miniMap(stops: any[]) { + const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng); + const [la0, la1] = [Math.min(...lats), Math.max(...lats)]; + const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)]; + // 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다. + let z = 17; + for (; z > 10; z--) { + const w = lngToPx(ln1, z) - lngToPx(ln0, z); + const h = latToPx(la0, z) - latToPx(la1, z); + if (w <= MAPW - 90 && h <= MAPH - 90) break; + } + const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2; + const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2; + const left = cx - MAPW / 2, top = cy - MAPH / 2; + const tiles: Array<{ src: string; x: number; y: number }> = []; + for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) { + for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) { + const n = 2 ** z; + if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue; + tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top }); + } + } + const pins = stops.map((s, i) => ({ + x: +(lngToPx(s.lng, z) - left).toFixed(1), + y: +(latToPx(s.lat, z) - top).toFixed(1), + n: i + 1, label: s.label, + })); + return { tiles, pins, z }; +} + +// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다. +// en 이 비어 있는 문장(번역 실패·키 없음)은 한글 원문을 그대로 보이고 (Korean) 표시를 붙인다. items 가 비어 있으면 블록을 그리지 않는다. +type RecoveryItem = { procedure: string; procedureEn?: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean }; +const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] }; +const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length); +const recoveryDate = RN.generatedAt || ''; + +const allPlaces: Array = GROUPS.flatMap((g) => + (((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key }))); + +const ld = [ + { '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Recovery schedule at ${clinic}`, url: `${site}/en/stay`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, + clinicSchema(site), +]; +--- + +
+
+
Nearby
+

Around The Clinic

+

+ Around {clinic}. {ready + ? `Everything below is measured from the clinic at ${originAddress || 'its address'}.` + : 'Distances and walking times will appear once the clinic\'s location is confirmed.'} + Names, photographs and descriptions come from the Korea Tourism Organization. + For the clinic's own hours and phone, see Clinic informationKO. To plan arrival and departure around your surgery date, use the Recovery Planner. +

+ {!ready &&

This guide is pending. Food, hotels, rest, sights and festivals near the clinic will be listed once its address is confirmed.

} +
+ + + + + + + + + {recoveryItems.length > 0 && ( +
+ Show after-treatment care · {recoveryItems.length} treatmentsHide after-treatment care +
+
+

After-treatment care, as published by the clinic

+

From {clinic}'s own treatment pages · {recoveryItems.length} treatments

+
+ {recoveryDate &&
Checked {recoveryDate}
} +
+

These lines are quoted from {clinic}'s own treatment pages (Korean), translated for reference. They differ by treatment and by person. Confirm at your consultation.

+
    + {recoveryItems.map((it, i) => ( +
  1. +
    + + + {i + 1} + + {it.area ? `${it.area} · ` : ''}{it.ko.length} {it.ko.length === 1 ? 'line' : 'lines'} · tap to open +

    {it.procedureEn || it.procedure}

    + {it.procedureEn && {it.procedure}} +
    +
    +
    +
      + {it.ko.map((s, k) => it.en?.[k] + ?
    • {it.en[k]}
    • + :
    • {s} Korean
    • )} +
    +

    Clinic page (Korean) ↗{it.fetchedAt ? ` · checked ${it.fetchedAt}` : ''}

    +
    +
  2. + ))} +
+
+ )} + + +
+
+

Around the clinic

+ {updated ? `Updated ${updated}` : 'Pending'} +
+

{total > 0 ? `${total} places, sorted by how long they take to reach on foot.` : 'Listings will follow once the clinic\'s location is confirmed.'}

+ + {total > 0 &&
+ + + + + +
} + + {GROUPS.map((g) => { + const items = allPlaces.filter((p) => p.cat === g.key); + if (!items.length) return null; + return ( +
+

{g.label} {g.note}

+
+ {items.map((p) => ( +
+
+ {p.image ? {`${p.title} : {shortName(p.title)}} + {p.travel && {p.travel.label} {p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}} +
+
+

{p.title}

+

{firstSentence(p.overview) || 'No description provided by the tourism data.'}

+

{p.address}

+ {p.homepage + ? Official site ↗ + : Open in search ↗} +
+
+ ))} +
+
+ ); + })} +

Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.

+
+ + +
+

Festivals and events

+

{festivals.length > 0 + ? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is ` + : 'Events will follow once the clinic\'s location is confirmed. Right now it is '}{nowSeason}.

+ {festivals.length > 0 &&
+ + {SEASONS.map((s) => { + const n = festivals.filter((e: any) => e.season === s.key).length; + return n ? : null; + })} +
} +
+ {festivals.map((e: any) => ( +
+
+ {e.image ? {`${e.title} : {shortName(e.title)}} + {MONTHS[e.month] ?? ''} +
+
+

{e.title}

+

{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}

+

{firstSentence(e.overview) || 'No description provided by the tourism data.'}

+

{e.address}

+ Open in search ↗ +
+
+ ))} +
+

Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.

+
+ + +
+

Shaping your days

+

+ Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule. +

+ {Sx.recoveryStagesEn + ?

What {clinic} advises. {Sx.recoveryStagesEn}

+ :

Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.

} + + {ROUTES.map((r) => { + const pins = miniMap(r.route.stops); + return ( +
+
+
+

{r.title}

+

{r.who}

+
+
{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m
+
+

{r.blurb}

+ + ); + })} +
+ +
+

What this page does not tell you

+
    +
  • No ratings, review counts or opening hours. The tourism data does not carry them.
  • +
  • No claim that any place suits your recovery. That is for {clinic} to say.
  • +
  • {total > 0 + ? `English tourism listings near the clinic are limited. Only ${T.filters.walk5} places sit within a five minute walk, and ${T.filters.drive} of ${total} need a car.` + : 'Listings near the clinic are pending until its location is confirmed.'}
  • +
  • We are not a travel agency, take no booking and no commission.
  • +
+

+ {has(f.urlEn) && Book a consultation in English} +

+

{f.sideEffectNoticeEn || f.sideEffectNotice}

+
+
+ + + diff --git a/supporters/src/pages/sitemap.xml.ts b/supporters/src/pages/sitemap.xml.ts index 5a6ffd9..574b8bf 100644 --- a/supporters/src/pages/sitemap.xml.ts +++ b/supporters/src/pages/sitemap.xml.ts @@ -12,6 +12,8 @@ export const GET: APIRoute = async ({ site }) => { { loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/visit`, lastmod: today }, { loc: `${base}/en`, lastmod: today }, + { loc: `${base}/stay`, lastmod: today }, + { loc: `${base}/en/stay`, lastmod: today }, { loc: `${base}/plan`, lastmod: today }, { loc: `${base}/en/plan`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today }, diff --git a/supporters/src/pages/stay.astro b/supporters/src/pages/stay.astro new file mode 100644 index 0000000..edb22f8 --- /dev/null +++ b/supporters/src/pages/stay.astro @@ -0,0 +1,515 @@ +--- +// 주변 안내(한국어). /en/stay 의 한국어 짝이다. haewon 결정(2026-09-11): 주변 안내(/stay)와 회복 일정 플래너(/plan)는 별개 페이지다. 이 페이지는 병원 주변 장소·축제·동선이고, 시술 후 관리 안내는 접이식으로 둔다. 데이터는 국문 서비스(KorService2)라 +// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건). +// 구조는 /en/stay 와 같다. 세 부분이다. +// 1. 병원 주변 : 도보·차 소요 시간으로 거른 주변 장소 +// 2. Festivals : 계절로 묶은 행사 +// 3. Suggested days : 시간표와 지도가 붙은 동선 +// + 시술 후 관리 안내(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장 +// +// 데이터는 한국관광공사 TourAPI 국문(KorService2)에서 scripts/fetch_medical_tourism.py 가 +// 미리 수집한 src/data/medicalTourismKo.json 이다. 설명문은 detailCommon2 의 overview 다. +// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소는 factSheet, 기준점은 +// medicalTourismKo.meta.origin 에서만 읽는다. 이 파일에 특정 병원을 적지 않는다. +// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "확인 대기"를 그리고 빌드는 그대로 된다. +// +// 만들지 않는 것 +// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다. +// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다. +// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다. +// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다. +import Base from '../layouts/Base.astro'; +import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib'; +import data from '../data/medicalTourismKo.json'; +import recovery from '../data/recoveryNotes.json'; + +const f = fact as Record; +const Sx = S as Record; +const clinic = f.shortName || '병원'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const T = data as any; +// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 "확인 대기"로 그린다. +const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string }; +const ready = Boolean(origin.lat && origin.lng); +const total: number = T.filters?.total ?? 0; +const festivals: any[] = T.festivals ?? []; +const updated: string = (T.meta?.fetchedAt || '').slice(0, 10); +// 거리의 기준 주소. 수집 때 쓴 주소가 있으면 그것, 없으면 팩트 시트 주소. +const originAddress = origin.address || f.address?.full || ''; +const festKm: number = T.meta?.festivalRadiusKm || 0; + +type Place = { + id: string; title: string; address: string; image: string | null; overview: string; + homepage: string; lat: number | null; lng: number | null; distanceM: number | null; + travel: { mode: string; minutes: number; label: string } | null; +}; + +const GROUPS: Array<{ key: string; label: string; note: string }> = [ + { key: 'restaurant', label: '식사', note: '음식점과 카페' }, + { key: 'stay', label: '숙박', note: '묵을 곳' }, + { key: 'wellness', label: '휴식', note: '스파와 찜질방' }, + { key: 'attraction', label: '관광', note: '거리, 공원, 명소' }, + { key: 'culture', label: '문화', note: '미술관, 공연장' }, + { key: 'shopping', label: '쇼핑', note: '백화점과 시장' }, +]; + +const SEASONS = [ + { key: 'spring', label: '봄' }, { key: 'summer', label: '여름' }, + { key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' }, +]; +const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; +const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()]; +const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record)[nowSeasonKey]; + +// 검색으로 보낸다. 없는 URL 을 만들지 않는다. +// 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다. +const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; +// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다. +const dt = (d: string | null) => (d && d.length === 8 ? `${+d.slice(4, 6)}.${+d.slice(6, 8)}` : ''); +// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다. +const shortName = (t: string) => { + const m = t.match(/\(([^)]+)\)\s*$/); + return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim(); +}; +// travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다. +const travelLabel = (t: { mode: string; minutes: number } | null) => + !t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`; +const firstSentence = (s: string, n = 150) => { + if (!s) return ''; + const cut = s.slice(0, n); + const stop = cut.lastIndexOf('. '); + return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : ''); +}; + +/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */ +const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0; +const pick = (key: string, maxMin: number, n: number): Place[] => + (((T.places ?? {})[key] ?? []) as Place[]) + .filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat) + .slice(0, n); +const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n); + +const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; +function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) { + let t = startMin; + const out: any[] = []; + stops.forEach((s, i) => { + const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5); + t += move; + const from = t; + t += s.stay; + out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng }); + }); + return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) }; +} +const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note }); + +const ROUTES = [ + { + id: 'near', title: '진료 다녀오는 날', who: '병원에 자주 오는 동안', + blurb: '전부 걸어서 갈 수 있는 곳입니다. 택시가 필요 없습니다.', + route: buildRoute(15 * 60, [ + clinicStop(clinic, 40, '진료가 끝나는 곳입니다.'), + ...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })), + ...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ]), + }, + { + id: 'short', title: '가까운 곳 나들이', who: '조금 더 걸을 수 있을 때', + blurb: '가깝지만 한 블록 밖입니다.', + route: buildRoute(13 * 60, [ + clinicStop(clinic, 20, '병원에서 출발합니다.'), + ...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), + ]), + }, + { + id: 'city', title: '멀리 나갈 수 있을 때', who: '체류 후반에', + blurb: '시내 곳곳입니다. 택시나 지하철을 탑니다.', + route: buildRoute(10 * 60, [ + clinicStop(clinic, 10, '병원에서 나섭니다.'), + ...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), + ...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), + ]), + }, +].filter((r) => r.route.stops.length > 1); + +/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다. + 빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다. + 타일은 OSM 정책상 출처를 반드시 표기한다. */ +const TILE = 256, MAPW = 680, MAPH = 340; +const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z; +const latToPx = (lat: number, z: number) => { + const r = (lat * Math.PI) / 180; + return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z; +}; + +function miniMap(stops: any[]) { + const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng); + const [la0, la1] = [Math.min(...lats), Math.max(...lats)]; + const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)]; + // 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다. + let z = 17; + for (; z > 10; z--) { + const w = lngToPx(ln1, z) - lngToPx(ln0, z); + const h = latToPx(la0, z) - latToPx(la1, z); + if (w <= MAPW - 90 && h <= MAPH - 90) break; + } + const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2; + const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2; + const left = cx - MAPW / 2, top = cy - MAPH / 2; + const tiles: Array<{ src: string; x: number; y: number }> = []; + for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) { + for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) { + const n = 2 ** z; + if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue; + tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top }); + } + } + const pins = stops.map((s, i) => ({ + x: +(lngToPx(s.lng, z) - left).toFixed(1), + y: +(latToPx(s.lat, z) - top).toFixed(1), + n: i + 1, label: s.label, + })); + return { tiles, pins, z }; +} + +// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다. +// 병원 확정값(site.recoveryStagesKo)과 역할이 다르다. 확정값은 "병원 안내" 문단, 이것은 원문 인용이라 출처 링크와 확인일을 붙인다. +// items 가 비어 있으면(템플릿 기본값) 블록을 아예 그리지 않는다. +type RecoveryItem = { procedure: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean }; +const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] }; +const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length); +const recoveryDate = RN.generatedAt || ''; + +const allPlaces: Array = GROUPS.flatMap((g) => + (((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key }))); + +const ld = [ + { '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, + clinicSchema(site), +]; +--- + +
+
+
Nearby
+

Around The Clinic

+

+ {clinic} 주변입니다. {ready + ? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.` + : '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'} + 상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 병원 정보에, 수술일 기준 입국·출국 계산은 회복 일정에 있습니다. +

+ {!ready &&

주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.

} +
+ + + + + + + + + {recoveryItems.length > 0 && ( +
+ 시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개시술 후 관리 안내 접기 +
+
+

시술 후 관리 안내

+

{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개

+
+ {recoveryDate &&
{recoveryDate} 확인
} +
+

아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.

+
    + {recoveryItems.map((it, i) => ( +
  1. +
    + + + {i + 1} + + {it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기 +

    {it.procedure}

    +
    +
    +
    +
      + {it.ko.map((s) =>
    • {s}
    • )} +
    +

    병원 페이지 보기 ↗{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}

    +
    +
  2. + ))} +
+
+ )} + + +
+
+

병원 주변

+ {updated ? `${updated} 갱신` : '확인 대기'} +
+

{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}

+ + {total > 0 &&
+ + + + + +
} + + {GROUPS.map((g) => { + const items = allPlaces.filter((p) => p.cat === g.key); + if (!items.length) return null; + return ( +
+

{g.label} {g.note}

+
+ {items.map((p) => ( +
+
+ {p.image ? {`${p.title}, : {shortName(p.title)}} + {p.travel && {travelLabel(p.travel)} {p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}} +
+
+

{p.title}

+

{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}

+

{p.address}

+ {p.homepage + ? 공식 홈페이지 ↗ + : 검색으로 열기 ↗} +
+
+ ))} +
+
+ ); + })} +

출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.

+
+ + +
+

계절별 축제

+

{festivals.length > 0 + ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` + : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}{nowSeason}입니다.

+ {festivals.length > 0 &&
+ + {SEASONS.map((s) => { + const n = festivals.filter((e: any) => e.season === s.key).length; + return n ? : null; + })} +
} +
+ {festivals.map((e: any) => ( +
+
+ {e.image ? {`${e.title}, : {shortName(e.title)}} + {MONTHS[e.month] ?? ''} +
+
+

{e.title}

+

{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}

+

{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}

+

{e.address}

+ 검색으로 열기 ↗ +
+
+ ))} +
+

출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.

+
+ + +
+

하루 동선

+

+ 위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다. +

+ {Sx.recoveryStagesKo + ?

{clinic} 안내. {Sx.recoveryStagesKo}

+ :

어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.

} + + {ROUTES.map((r) => { + const pins = miniMap(r.route.stops); + return ( +
+
+
+

{r.title}

+

{r.who}

+
+
{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분
+
+

{r.blurb}

+ + ); + })} +
+ +
+

이 페이지가 답하지 못하는 것

+
    +
  • 평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.
  • +
  • 어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.
  • +
  • {total > 0 + ? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.` + : '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}
  • +
  • 여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.
  • +
+

+ {has(reservationUrl) && {clinic} 상담 예약} +

+

{f.sideEffectNoticeEn || f.sideEffectNotice}

+
+
+ + + diff --git a/supporters/src/styles/global.css b/supporters/src/styles/global.css index 14a1f51..10052f0 100644 --- a/supporters/src/styles/global.css +++ b/supporters/src/styles/global.css @@ -279,3 +279,180 @@ td { color: var(--slate-700); } .site-header .nav a { margin-left: 0; flex: none; font-size: 0.9rem; } } +/* ── /en/stay ── */ +.stay { padding-bottom: 4rem; } +.stay .lede { color: var(--slate-600, #475569); margin-top: 0.6rem; } +.stay section { margin-top: 3.4rem; } +.stay .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; } +.stay .sec-head h2 { margin: 0; } +.stay .upd { font-size: 0.8rem; color: var(--slate-500, #64748B); } +.stay .sub { color: var(--slate-600, #475569); margin: 0.4rem 0 1rem; } +.stay .src { font-size: 0.84rem; color: var(--slate-500, #64748B); margin-top: 1rem; } +.stay .chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.2rem 0 1.6rem; } +.stay .chip { + border: 1px solid var(--line, #E2E8F0); background: #fff; border-radius: 999px; + padding: 0.36rem 0.85rem; font: inherit; font-size: 0.86rem; color: var(--slate-600, #475569); + cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s; +} +.stay .chip b { font-weight: 700; opacity: 0.55; margin-left: 0.25em; } +.stay .chip:hover { border-color: var(--primary-900, #0A1128); } +.stay .chip.on { background: var(--primary-900, #0A1128); border-color: var(--primary-900, #0A1128); color: #fff; } +.stay .chip.on b { opacity: 0.7; } +.stay .grp { margin-bottom: 2.2rem; } +.stay .grp h3 { margin: 0 0 0.9rem; } +.stay .grp h3 small { font-weight: 400; font-size: 0.82rem; color: var(--slate-500, #64748B); margin-left: 0.45em; } +.stay .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 1rem; } +.stay .card { + background: #fff; border: 1px solid var(--line, #E2E8F0); border-radius: 14px; + overflow: hidden; display: flex; flex-direction: column; + box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.05); +} +.stay .card .thumb { position: relative; height: 148px; background: #EEF1F6; display: flex; align-items: center; justify-content: center; } +.stay .card .thumb img { width: 100%; height: 148px; object-fit: cover; display: block; } +.stay .card .nophoto { + font-size: 1.02rem; font-weight: 700; color: var(--slate-500, #64748B); + padding: 0 1rem; text-align: center; line-height: 1.35; word-break: keep-all; +} +.stay .card .badge { + position: absolute; left: 0.6rem; bottom: 0.6rem; + background: rgba(10, 17, 40, 0.82); color: #fff; border-radius: 6px; + padding: 0.18rem 0.45rem; font-size: 0.74rem; font-weight: 600; +} +.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; } +.stay .card .badge.mono { letter-spacing: 0.06em; } +.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; } +.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; } +.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; } +.stay .card .desc { font-size: 0.86rem; color: var(--slate-600, #475569); margin: 0 0 0.5rem; } +.stay .card .addr { font-size: 0.78rem; color: var(--slate-500, #64748B); margin: 0 0 0.6rem; } +.stay .card .go { font-size: 0.82rem; margin-top: auto; } +.stay .route { border: 1px solid var(--line, #E2E8F0); border-radius: 16px; padding: 1.3rem 1.4rem; margin-bottom: 1.4rem; background: #fff; } +.stay .route-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; } +.stay .route-head h3 { margin: 0; } +.stay .route .who { font-size: 0.84rem; color: var(--slate-500, #64748B); margin: 0.15rem 0 0; } +.stay .route .clock { font-size: 0.86rem; font-weight: 600; color: var(--primary-900, #0A1128); white-space: nowrap; } +.stay .route .blurb { color: var(--slate-600, #475569); margin: 0.7rem 0 1rem; } +.stay .route .map { width: 100%; height: auto; display: block; border-radius: 12px; } +.stay .route .maphint { font-size: 0.76rem; color: var(--slate-500, #64748B); margin: 0.4rem 0 1rem; } +.stay .timeline { list-style: none; padding: 0; margin: 0; } +.stay .timeline .move { font-size: 0.8rem; color: var(--slate-500, #64748B); margin: 0.35rem 0 0.35rem 1.05rem; } +.stay .timeline .stop { display: flex; gap: 0.8rem; align-items: flex-start; } +.stay .timeline .n { + flex: none; width: 26px; height: 26px; border-radius: 50%; + background: var(--primary-900, #0A1128); color: #fff; + font-size: 0.8rem; font-weight: 700; display: grid; place-items: center; margin-top: 0.1rem; +} +.stay .timeline .stop-t { font-size: 0.8rem; color: var(--slate-500, #64748B); } +.stay .timeline h4 { margin: 0.1rem 0 0.2rem; font-size: 0.98rem; } +.stay .timeline p { margin: 0; font-size: 0.86rem; color: var(--slate-600, #475569); } +.stay .botnote { margin-top: 1.6rem; } +/* 시술 후 관리 안내(병원 원문 인용). 동선 카드(.route)·정차(.stop) 룩을 그대로 쓰고 접이식 동작만 더한다. 새 색·크기 없음. */ +.stay details.recovery > summary.btn { list-style: none; cursor: pointer; } +.stay details.recovery > summary.btn::-webkit-details-marker { display: none; } +.stay details.recovery > summary.btn .hide { display: none; } +.stay details.recovery[open] > summary.btn .show { display: none; } +.stay details.recovery[open] > summary.btn .hide { display: inline; } +.stay details.recovery[open] > summary.btn { margin-bottom: 1.1rem; } +.stay details.recovery > .timeline { margin-top: 0.2rem; } +.stay .recovery .timeline > li { border-top: 1px solid var(--line, #E2E8F0); padding: 0.7rem 0; } +.stay .recovery .timeline > li:first-child { border-top: 0; padding-top: 0; } +.stay .recovery summary { cursor: pointer; list-style: none; } +.stay .recovery summary::-webkit-details-marker { display: none; } +.stay .recovery summary .stop { display: flex; } +.stay .recovery summary h4 { margin: 0.1rem 0 0; } +.stay .recovery details[open] summary h4 { text-decoration: underline; text-underline-offset: 0.2em; text-decoration-color: var(--slate-200, #E2E8F0); } +.stay .recovery details > ul { margin: 0.7rem 0 0.5rem 2.2rem; padding-left: 1.1rem; } +.stay .recovery details > ul li { font-size: 0.9rem; color: var(--slate-700, #334155); margin-bottom: 0.35rem; } +.stay .recovery details > p { margin: 0 0 0 2.2rem; font-size: 0.84rem; color: var(--slate-500, #64748B); } +@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } } + +/* 섹션 점프. 머리말 아래에 붙어 스크롤을 따라온다. 헤더(68px) 아래에 걸리게 top 을 맞춘다. */ +.stay .jump { + position: sticky; top: 68px; z-index: 9; + display: flex; flex-wrap: wrap; gap: 0.45rem; + margin: 1.4rem 0 2.4rem; + padding: 0.62rem 0.68rem; + background: rgba(255, 255, 255, 0.58); + backdrop-filter: blur(16px) saturate(1.5); + -webkit-backdrop-filter: blur(16px) saturate(1.5); + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 18px; + box-shadow: 0 8px 28px rgba(10, 17, 40, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.7); +} +.stay .jump a { + display: inline-flex; align-items: center; gap: 0.42rem; + padding: 0.46rem 0.9rem; + border-radius: 999px; + font-size: 0.86rem; font-weight: 600; letter-spacing: -0.005em; + color: var(--primary-900, #0A1128); + text-decoration: none; white-space: nowrap; + background: #fff; + border: 1px solid var(--line, #E2E8F0); + box-shadow: 0 1px 2px rgba(10, 17, 40, 0.06); + transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s, border-color 0.14s, color 0.14s; +} +.stay .jump a:hover { + text-decoration: none; + transform: translateY(-1px); + border-color: #C5CBF5; + box-shadow: 0 4px 12px rgba(10, 17, 40, 0.12); +} +.stay .jump a:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(10, 17, 40, 0.08); } +.stay .jump a b { + font-weight: 700; font-size: 0.72rem; line-height: 1; + padding: 0.2rem 0.4rem; border-radius: 999px; + background: #EFF0FF; color: #3A3F7C; +} +/* 지금 보고 있는 섹션 */ +.stay .jump a.on { + background: linear-gradient(to right, #4F1DA1, #021341); + border-color: transparent; color: #fff; + box-shadow: 0 4px 14px rgba(79, 29, 161, 0.32); +} +.stay .jump a.on b { background: rgba(255, 255, 255, 0.22); color: #fff; } +/* 점프로 이동했을 때 제목이 스티키 바에 가리지 않게 */ +.stay section, .stay .grp { scroll-margin-top: 152px; } +@media (max-width: 640px) { + .stay .jump { top: 60px; gap: 0.35rem; padding: 0.5rem; border-radius: 14px; } + .stay .jump a { padding: 0.4rem 0.72rem; font-size: 0.8rem; } +} + +/* 필터 칩도 눌리는 것처럼 보이게 */ +.stay .chip { + box-shadow: 0 1px 2px rgba(10, 17, 40, 0.05); + transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.15s, color 0.15s, border-color 0.15s; +} +.stay .chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(10, 17, 40, 0.1); } +.stay .chip:active { transform: translateY(0); } +.stay .chip.on { box-shadow: 0 4px 14px rgba(10, 17, 40, 0.22); } + +/* 오늘 날씨. 값을 못 받으면 hidden 이라 아예 그려지지 않는다. */ +.stay .wx { + display: flex; align-items: center; justify-content: space-between; + gap: 1.2rem; flex-wrap: wrap; + margin-top: 1.6rem; padding: 1rem 1.2rem; + background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); + border-radius: 16px; +} +.stay .wx-now { display: flex; align-items: center; gap: 0.9rem; } +.stay .wx-temp { font-family: 'Playfair Display', serif; font-size: 2.5rem; font-weight: 700; line-height: 1; color: var(--primary-900, #0A1128); } +.stay .wx-unit { font-size: 1.1rem; vertical-align: 0.9rem; margin-left: 0.06em; opacity: 0.6; } +.stay .wx-cond { font-weight: 700; color: var(--primary-900, #0A1128); } +.stay .wx-meta { font-size: 0.8rem; color: var(--slate-600, #475569); margin-top: 0.15rem; } +.stay .wx-note { margin: 0; font-size: 0.88rem; color: var(--slate-700, #334155); max-width: 30rem; } + +/* 동선 지도. OSM 타일을 좌표로 깔고 그 위에 핀을 얹는다. 출처 표기는 OSM 정책상 필수다. */ +.stay .route .map { + position: relative; width: 100%; max-width: 680px; height: 340px; + border-radius: 12px; overflow: hidden; background: #E8EDF3; + border: 1px solid var(--line, #E2E8F0); +} +.stay .route .map-tiles { position: absolute; inset: 0; } +.stay .route .map-tiles .tile { position: absolute; width: 256px; height: 256px; background-size: 256px 256px; background-repeat: no-repeat; } +.stay .route .map-pins { position: absolute; inset: 0; width: 100%; height: 100%; } +.stay .route .map-credit { + position: absolute; right: 0; bottom: 0; + background: rgba(255, 255, 255, 0.82); color: var(--slate-600, #475569); + font-size: 0.66rem; padding: 0.1rem 0.35rem; border-top-left-radius: 6px; +} + diff --git a/supporters/vercel.json b/supporters/vercel.json index 08f59b9..d6258b7 100644 --- a/supporters/vercel.json +++ b/supporters/vercel.json @@ -28,20 +28,10 @@ "destination": "/plan", "permanent": true }, - { - "source": "/stay", - "destination": "/plan", - "permanent": true - }, { "source": "/en/recovery", "destination": "/en/plan", "permanent": true - }, - { - "source": "/en/stay", - "destination": "/en/plan", - "permanent": true } ] } From f26ba37948f8e6f47d9a82f054cdb684b1b5ded7 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 18:29:04 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs:=20=EC=A3=BC=EB=B3=80=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=B3=B5=EC=9B=90=EA=B3=BC=20'=EA=B8=B0=EC=A1=B4?= =?UTF-8?q?=20=ED=8E=98=EC=9D=B4=EC=A7=80=EB=A5=BC=20=EB=8D=AE=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4'=20=ED=95=A8?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md index e895d9a..32c046c 100644 --- a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md +++ b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md @@ -27,7 +27,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI | 6 측정 | 질문뱅크 언급률(2엔진), 감성, GA4 AI 유입, AI 크롤러 히트, 공식 vs 서포터즈 재채점 | `scripts/run_question_bank_openai.py`·`merge_qb_results.py`·`sentiment_qb.py`·`ga4_ai_traffic.py`, `data/ai_channels.json`, 리포트 Integrity 절 "측정 가능 범위" | 메인(QB·감성) + Productize(GA4) | 스크립트 단위. 리포트 카드 자동 갱신 없음. GA4 는 GCP 계정 대기 | | 7 계약·가격 | 상담 기반 계약 | 가격 문서 `docs/INFINITH_가격정책_사업부보고서_v1.1.docx`(2026-04, 이전 제품 INSIGHT/INTELLIGENCE 티어), AI Discovery 가격 제안은 `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html` | 메인 | **AI Discovery 전용 티어 미확정** | -2026-09-10~11 추가: 회복 일정 플래너(설계 v0.2 §13, 관광공사 데이터 합침), 오시는 길 지도·길찾기, 전역 언어 전환·영어 홈(뷰), 모바일 헤더. 전부 템플릿·워커에 통합됨. +2026-09-10~11 추가: 회복 일정 플래너 `/plan`(설계 v0.2 §13, 관광공사 데이터 합침), 주변 안내 `/stay`(장소·축제·동선 지도·날씨, 플래너와 별개 페이지로 복원), 오시는 길 지도·길찾기, 전역 언어 전환·영어 홈(뷰), 모바일 헤더, 발행(색인) 단계. 전부 템플릿·워커에 통합됨. ## 2. 코드·브랜치·배포 지도 @@ -84,6 +84,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI ## 6. 함정 (제품 관점) - **한 Vercel 프로젝트에 두 폴더**: 2026-09-11 Productize/supporters 와 메인 supporters 가 같은 프로젝트에 배포해 `/plan` 이 404 됐다. 지금은 Productize 쪽 연결을 껐다. 배포는 병원 폴더와 메인 supporters 에서만. +- **페이지를 덮어쓰지 않는다**: 플래너 통합 때 `/stay`(주변 안내)를 `/plan` 으로 리다이렉트해 세 사이트에서 주변 안내가 사라졌다(09-11 17시 복원). 새 기능이 기존 페이지와 '같은 주제'로 보여도 haewon 확인 전에는 기존 페이지를 지우거나 리다이렉트하지 않는다. - **세 브랜치 분산**: 같은 파일(`supporters/`)이 두 브랜치에서 다르게 진화했다. 머지 전에 "템플릿이 정본"을 정하고 뷰 샘플을 옮긴다. - **진단 결과가 코드**: `discovery_.ts` 커밋 방식은 병원 수가 늘면 배포 병목이다(§4-3). - **게이트는 고객 화면 전체를 본다**: 운영 어휘("실측"·"API 집계"·"임베드")가 데이터 값을 타고 화면에 나가면 빌드가 막힌다. 운영 기록은 데이터에만. From 730f7822c3e50be36d1934c24548da27195d4efd Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 19:51:22 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=A0=9C=ED=92=88=20=ED=95=B8?= =?UTF-8?q?=EB=93=9C=EC=98=A4=EB=B2=84=EC=97=90=20=C2=A78=20=EC=9D=B8?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=2012=EA=B1=B4,=20=EC=9B=94=EC=9A=94?= =?UTF-8?q?=EC=9D=BC(09-14)=20=EC=8B=9C=EC=9E=91=20=EC=A0=88=EC=B0=A8=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- ...SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md index 32c046c..20e67e9 100644 --- a/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md +++ b/docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md @@ -1,6 +1,6 @@ -# 다음 세션 핸드오버 · 2026-09-12 · INFINITH AI Discovery Product 빌드 +# 다음 세션 핸드오버 · 2026-09-14(월) · INFINITH AI Discovery Product 빌드 -작성 2026-09-11 · 작성자 Claude (haewon 세션) · 상태: 초안(haewon 검토 전) +작성 2026-09-11 (금요일 종료 시점) · 작성자 Claude (haewon 세션) · 상태: 초안(haewon 검토 전) · 다음 세션은 2026-09-14 월요일부터 Product 개발을 이어간다. 하위 문서: 서포터즈 사이트 빌드 절차 `~/orca/workspaces/INFINITH/Productize/docs/NEXT_SESSION_2026-09-12_PRODUCT_BUILD.md`, 자동 빌드 상세 `…/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v3.md`, 자동 채점 `docs/AI_DISCOVERY_BUILD2_HANDOVER.md`, 회고 `docs/INFINITH_AI_Discovery_Retrospective_2026-09-08.md`. ## 0. 세션 시작 시 붙여 넣을 프롬프트 @@ -9,7 +9,7 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI 답변 준비도 진단(AEO/GEO v2) → 서포터즈 사이트 자동 빌드(글·영상·뉴스룸·회복 일정 플래너·오시는 길) → 병원 확인·재빌드 → 측정(질문뱅크·감성·GA4·크롤러 로그)" 파이프라인이다. 코드 위치: 프런트·스크립트·문서는 메인 체크아웃 ~/Claude/Agentic Marketing/INFINITH (브랜치 feature/ai-sentiment-rubric, 프로덕션 main → infinith-demo.vercel.app), 워커·템플릿·큐는 워크트리 ~/orca/workspaces/INFINITH/Productize (브랜치 feature/supporters-autobuild), 랜딩 리드 CTA 는 브랜치 feature/discovery-url-cta(미머지). -먼저 docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md 를 읽고, §1 표의 "확인" 칸을 git log·Supabase 로 실제 확인한 뒤, §5 결정 대기의 답을 받고 §4 순서대로 진행한다. +먼저 docs/NEXT_SESSION_2026-09-12_AI_DISCOVERY_PRODUCT.md 를 읽는다(§8 인사이트 포함). 시작 절차: (1) 두 저장소 git status 와 .vercel 연결을 확인하고 다른 세션이 열려 있지 않은지 haewon 에게 묻는다 (2) §1 표의 "확인" 칸을 git log·Supabase 로 실제 확인한다 (3) §5 결정 대기의 답을 받는다 (4) §4 순서대로 진행하되 1번 브랜치 통합을 먼저 끝낸다. 한 세션은 한 저장소·한 배포 대상만 다룬다. 원칙: 근거 없는 사실은 생성하지 않는다("확인 대기"). 사람 게이트(의학 검토·게시 승인·이미지 육안·채널 확인·구조 기준선)는 자동화하지 않는다. 언급률·점수는 "스키마를 넣으면 인용된다"고 약속하지 않는다(정직성 규약). 고객 화면에 운영자 어휘 금지. 존댓말, 엠대시 금지(커밋 메시지 포함), 은유 금지, 흰 배경 위 연보라 텍스트 금지(인디고 #1D0024). INFINITH 리포트 PDF 는 PPTX 덱(infinith-deck 스킬)으로 만든 뒤 변환한다. 클라이언트 격리: INFINITH 문서·코드에 다른 서비스 이름을 쓰지 않는다. 산출 전 grep. @@ -103,3 +103,18 @@ INFINITH AI Discovery Product 빌드 세션. 제품은 "병원 URL 하나 → AI - 측정: `scripts/run_question_bank_openai.py`, `scripts/sentiment_qb.py`, `docs/AI_ANSWER_SENTIMENT_RUBRIC_v0.1.md`, Productize `scripts/ga4_ai_traffic.py`, `data/ai_channels.json` - 가격·경쟁: `docs/INFINITH_vs_FrostAI_비교_및_가격제안_2026-09-08.html`, `docs/INFINITH_가격정책_사업부보고서_v1.1.docx` - PRD: `docs/prd/INFINITH_AI-Discovery_PRD_20260828_v02.md`, `docs/prd/INFINITH_Global_Medical_Hub_PRD_v0.1.html`(Productize 에 v0.2), `docs/prd/INFINITH_Dermatology_Supporters_PRD_v0.1.html` + +## 8. 이번 이틀(09-10~11)의 인사이트 · 다음 개발에 그대로 적용할 것 + +1. **한 세션 = 한 저장소 = 한 배포 대상.** 두 세션이 같은 Vercel 프로젝트와 같은 `supporters/` 폴더를 다른 브랜치에서 건드려 `/plan` 404 와 주변 안내 실종이 났다. 세션 시작 때 `git status`·`.vercel/project.json`·열린 세션 여부를 확인하고, 그 세션이 맡지 않은 자산은 손대지 않는다. 브랜치 통합(§4-1)이 이 문제의 근본 해결이므로 월요일 첫 일로 한다. +2. **"완료"를 산출물이 아니라 고객 결과로 정의하면 자동·사람 경계가 드러난다.** 서포터즈 사이트 완료 = 색인 작업 + 사람 체크리스트로 바꾸자 IndexNow·GSC 는 자동, 네이버 등록·소유자 추가는 사람으로 갈렸고 워커 단계가 그 경계를 코드로 가졌다. 진단(사람 판정 4항목), 병원 확인(재빌드 트리거)도 같은 방식으로 "완료 정의 → 자동 부분 → 사람 요청"을 명시한다. +3. **병원 원문은 인용 전에 걸러야 한다.** 원문 32문장 중 14건이 홍보문·정형 고지·표 조각이었다. 원문을 데이터로 쓰는 모든 경로(recoveryNotes, OCR, 뉴스 제목)에 화이트리스트 필터와 "제외된 문장 목록 눈으로 보기"를 둔다. 필터 없는 "병원 안내 기준" 배지는 의료광고 위반이다. +4. **게이트는 데이터 값도 본다.** 좌표 출처의 "실측"이 화면에 새어 나가 빌드가 막혔다. 데이터 필드마다 고객 노출 여부를 정하고 운영 기록(source·coordSource·note)은 렌더하지 않는다. +5. **규칙표를 먼저 쓰면 코드·테스트가 따라온다.** 설계 v0.2 §13 의 status(clinic/provisional)·출처 필수 규칙이 그대로 procedures.json 스키마와 게이트 테스트가 됐다. 새 기능은 데이터 규칙표부터 문서화한다. +6. **관광공사 데이터는 공급이지 수요가 아니다.** 선호는 화면에서 직접 묻고, 큐레이션이 1순위, 외부 데이터는 보강(2순위)이라는 우선순위를 점수로 코드화했다. 외부 API 를 붙일 때마다 같은 질문(공급인가 수요인가, 누가 1순위인가)을 먼저 한다. +7. **디자인 규칙은 텍스트가 아니라 리듬이다.** "흰 카드만 쌓인 페이지"는 홈·영상 페이지의 섹션 리듬(다크 히어로+글래스 통계 → 틴트 → 다크 → 라이트 → 틴트 → 다크)을 안 따라서 완성도가 낮아 보였다. 새 페이지는 기존 페이지 두 개의 구성을 먼저 분석하고 같은 리듬으로 짠다. 웜 그라디언트는 CTA 카드 한 장에만. +8. **전역 CSS 와 컴포넌트 클래스는 접두어로 분리한다.** `.kpi` 충돌로 그라디언트가 되살아났다. 컴포넌트 클래스는 `plan-`·`vm-` 처럼 접두어를 쓰고, 새 클래스는 전역 CSS 에 grep 한다. +9. **모바일 검증은 하네스로.** 헤드리스 크롬 창 최소 폭 때문에 400px 캡처가 틀렸고, 실제 문제(헤더 겹침)는 아이프레임 하네스에서만 보였다. 8페이지×3사이트 그리드 캡처를 배포 전 기본 절차로 둔다. +10. **언어 전환은 전역 하나.** 페이지별 토글은 사용자 관점에서 틀렸다. 영어 홈이 없는 사이트는 영어 입구(/en/plan)를 정해야 하고, 템플릿에 영어 홈을 넣을지는 §5-3 결정에 달렸다. +11. **기존 페이지는 haewon 확인 전에 지우거나 리다이렉트하지 않는다.** 새 기능이 기존 페이지와 같은 주제로 보여도 병존이 기본이다(주변 안내 `/stay` 와 회복 일정 `/plan`). +12. **제품 병목은 세 가지로 좁혀졌다.** 진단 결과가 코드 파일, 리드→큐 미연결, 브랜치 3개 분산. 이 셋을 풀어야 "URL 입력 → 리포트 링크 → 서포터즈 미리보기"가 사람 손 없이 이어진다.