From a807d5dd9c4a13208de8baff82197243d34bb540 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Wed, 9 Sep 2026 11:42:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor(supporters):=20=EC=9D=98=EB=A3=8C?= =?UTF-8?q?=EA=B4=80=EA=B4=91=20=EC=97=B0=EA=B2=B0=EC=9D=84=20/discovery?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20=EC=84=9C=ED=8F=AC=ED=84=B0=EC=A6=88=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=ED=8A=B8=20/en/stay=20=EB=A1=9C=20=EC=98=AE?= =?UTF-8?q?=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 붙일 자리를 잘못 잡았다. /discovery 는 INFINITH 제품의 진단 화면이고 병원마다 쓰는 범용 도구다. 외국인 환자가 보는 페이지는 병원 서포터즈 사이트다. 그쪽으로 옮겼다. 옮긴 것 - src/components/discovery/MedicalTourismPanel.tsx, src/types/medicalTourism.ts, src/data/medicalTourism.json 삭제. DiscoveryReportPage 원복. - supporters/src/pages/en/stay.astro 신설. 독립 URL 과 자체 WebPage JSON-LD 를 가져 AI 인용 단위가 된다. sitemap 에 등록하고 /visit 에서 hreflang 링크로 잇는다. - 수집 스크립트 출력을 supporters/src/data/medicalTourism.json 으로 바꿨다. 영문 페이지 지원 - Base.astro 에 lang prop 추가(기본 ko). html lang · og:locale · 머리말 · 꼬리말이 따라 바뀐다. 레이아웃을 하나로 유지해 한국어 페이지 동작은 그대로다. - 의료광고 고지(지원 관계·부작용)는 규정 대상이라 임의로 번역하지 않는다. 병원이 sponsorNoticeEn / sideEffectNoticeEn 을 확정하기 전에는 한국어 원문을 그대로 싣고 영문 문구가 확인 대기임을 밝힌다. - factSheet 에 nameEn / shortNameEn / shortNameEnSource 추가. 뷰성형외과 값의 근거는 병원 자신의 영문 도메인 viewplasticsurgery.com 이며 한국관광공사 등재명도 같다. 페이지가 만들지 않는 것 - 평점·리뷰·영업시간: TourAPI 미제공이라 싣지 않는다. - 식당의 회복 적합성: 의학 판단이라 병원이 확정한 영문 문구(site.json dietGuideEn)만 싣는다. 확인 전에는 어떤 식당도 적합으로 표시하지 않는다고 페이지에 적는다. - 회복 단계 일수: 병원 확정값(recoveryStagesEn). 단계는 이동 거리로만 정의한다. - 하단에 이 페이지가 답하지 못하는 것 네 가지를 그대로 적었다. 템플릿 동기화 - templates/supporters-astro 에 같은 변경을 넣되 데이터는 빈 스캐폴드로 둔다. 뷰성형외과 factSheet 와 관광 데이터가 새 병원 빌드에 새어 들어가면 안 된다. 빈 데이터로 빌드해 6개 범주가 "No English listings" 로 나오는 것까지 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/fetch_medical_tourism.py | 7 +- .../discovery/MedicalTourismPanel.tsx | 449 ------------------ src/pages/DiscoveryReportPage.tsx | 4 - src/types/medicalTourism.ts | 88 ---- supporters/src/data/factSheet.json | 3 + .../src}/data/medicalTourism.json | 2 +- supporters/src/layouts/Base.astro | 39 +- supporters/src/pages/en/stay.astro | 117 +++++ supporters/src/pages/sitemap.xml.ts | 1 + supporters/src/pages/visit.astro | 3 + supporters/src/styles/global.css | 13 + .../supporters-astro/src/data/factSheet.json | 3 + .../src/data/medicalTourism.json | 26 + .../supporters-astro/src/layouts/Base.astro | 44 +- .../supporters-astro/src/pages/en/stay.astro | 117 +++++ .../supporters-astro/src/pages/sitemap.xml.ts | 1 + .../supporters-astro/src/pages/visit.astro | 3 + .../supporters-astro/src/styles/global.css | 13 + 18 files changed, 359 insertions(+), 574 deletions(-) delete mode 100644 src/components/discovery/MedicalTourismPanel.tsx delete mode 100644 src/types/medicalTourism.ts rename {src => supporters/src}/data/medicalTourism.json (99%) create mode 100644 supporters/src/pages/en/stay.astro create mode 100644 templates/supporters-astro/src/data/medicalTourism.json create mode 100644 templates/supporters-astro/src/pages/en/stay.astro diff --git a/scripts/fetch_medical_tourism.py b/scripts/fetch_medical_tourism.py index f086b5b..4b0f567 100644 --- a/scripts/fetch_medical_tourism.py +++ b/scripts/fetch_medical_tourism.py @@ -14,8 +14,11 @@ Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서 판정하지 않는다. +출력은 서포터즈 사이트(Astro)의 데이터다. 화면은 supporters/src/pages/en/stay.astro 다. +INFINITH 제품 화면(/discovery)이 아니라 병원 서포터즈 사이트에 실린다. 외국인 환자가 보는 곳이다. + python3 scripts/fetch_medical_tourism.py - python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json + python3 scripts/fetch_medical_tourism.py --out supporters/src/data/medicalTourism.json """ import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request @@ -131,7 +134,7 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다") ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수") - ap.add_argument("--out", default="src/data/medicalTourism.json") + ap.add_argument("--out", default="supporters/src/data/medicalTourism.json") a = ap.parse_args() env = load_env(os.path.join(ROOT, ".env")) diff --git a/src/components/discovery/MedicalTourismPanel.tsx b/src/components/discovery/MedicalTourismPanel.tsx deleted file mode 100644 index 1513523..0000000 --- a/src/components/discovery/MedicalTourismPanel.tsx +++ /dev/null @@ -1,449 +0,0 @@ -/** - * 의료관광 연결 패널 (/discovery/:id) - * - * 외국인 환자가 수술 전후에 쓸 주변 정보를 한 곳에 모은다. - * 숙박 · 음식점 · 웰니스(스파) · 관광 · 쇼핑 · 축제, 그리고 이동 반경 기준 회복 동선. - * - * 데이터는 `scripts/fetch_medical_tourism.py` 가 한국관광공사 TourAPI 다국어(영문)에서 - * 미리 수집한 `src/data/medicalTourism.json` 이다. 사전 수집인 이유는 영문 커버리지가 - * 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지 않게 하기 위해서다. - * - * 만들지 않는 것 - * - 평점·리뷰·영업시간: TourAPI 미제공이므로 "미연동"으로 표시하고 비워 둔다. - * - 수술 전후 식이 적합성: 병원이 supporter_inputs('diet_guide')로 입력한 값만 쓴다. - * 입력 전에는 "병원 확인 대기"로 둔다. 개별 식당의 적합 여부를 추론하지 않는다. - * - 회복 단계가 며칠째인지: 의학 판단이라 병원 입력('recovery')을 따른다. - * 화면의 단계는 이동 반경으로만 정의한다. - */ -import { useEffect, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; -import { motion } from 'motion/react'; -import { SectionWrapper } from '../report/ui/SectionWrapper'; -import { supabase } from '../../lib/supabase'; -import data from '../../data/medicalTourism.json'; -import type { - MedicalTourismData, - PlaceCategory, - RecoveryStage, - TourPlace, -} from '../../types/medicalTourism'; -import { - BedFilled, - ForkFilled, - SpaFilled, - MapPinFilled, - TicketFilled, - BagFilled, - TheaterFilled, -} from '../icons/FilledIcons'; - -const TOURISM = data as unknown as MedicalTourismData; - -const CATEGORY_META: Record< - PlaceCategory, - { label: string; en: string; Icon: typeof BedFilled } -> = { - stay: { label: '숙박', en: 'Stay', Icon: BedFilled }, - restaurant: { label: '음식점', en: 'Dining', Icon: ForkFilled }, - wellness: { label: '웰니스', en: 'Wellness', Icon: SpaFilled }, - attraction: { label: '관광', en: 'Attractions', Icon: MapPinFilled }, - culture: { label: '문화', en: 'Culture', Icon: TheaterFilled }, - shopping: { label: '쇼핑', en: 'Shopping', Icon: BagFilled }, -}; - -const CATEGORY_ORDER: PlaceCategory[] = [ - 'stay', - 'restaurant', - 'wellness', - 'attraction', - 'culture', - 'shopping', -]; - -/** - * 회복 동선. 며칠째인지는 병원이 정한다(§ClinicInputsPanel 'recovery'). - * 여기서는 이동 반경만 정의하므로 의학적 판단을 담지 않는다. - */ -const STAGES: RecoveryStage[] = [ - { - id: 'near', - label: '병원 도보권', - maxDistanceM: 1000, - categories: ['stay', 'restaurant'], - note: '내원이 잦은 기간에 머무는 범위입니다.', - }, - { - id: 'short', - label: '근거리 이동', - maxDistanceM: 3000, - categories: ['restaurant', 'wellness'], - note: '짧은 외출이 가능해지는 범위입니다.', - }, - { - id: 'city', - label: '시내 이동', - maxDistanceM: 10000, - categories: ['attraction', 'culture', 'shopping'], - note: '관광 일정을 넣을 수 있는 범위입니다.', - }, -]; - -function fmtDistance(m: number | null) { - if (m === null) return '거리 미상'; - return m < 1000 ? `${m}m` : `${(m / 1000).toFixed(1)}km`; -} - -function fmtDate(d: string | null) { - if (!d || d.length !== 8) return ''; - return `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}`; -} - -export function MedicalTourismPanel({ clinicId }: { clinicId: string }) { - const [tab, setTab] = useState('route'); - const [clinicInput, setClinicInput] = useState<{ - recovery?: string; - diet?: Record; - }>({}); - - // 병원이 입력한 회복·식이 값을 읽는다. 같은 clinic_id 의 최신 행이 유효값이다. - useEffect(() => { - let alive = true; - (async () => { - const { data: rows } = await supabase - .from('supporter_inputs') - .select('key,value,created_at') - .eq('clinic_id', clinicId) - .in('key', ['recovery', 'diet_guide']) - .order('created_at', { ascending: true }); - if (!alive || !rows) return; - const latest = new Map>(); - for (const r of rows) latest.set(r.key, r.value as Record); - setClinicInput({ - recovery: latest.get('recovery')?.answer, - diet: latest.get('diet_guide'), - }); - })(); - return () => { - alive = false; - }; - }, [clinicId]); - - // 단계는 누적이 아니라 구간이다. 누적으로 하면 가까운 곳이 모든 단계에 중복으로 뜬다. - const stageBuckets = useMemo( - () => - STAGES.map((s, i) => { - const minM = i === 0 ? 0 : STAGES[i - 1].maxDistanceM; - return { - stage: s, - minM, - items: s.categories - .flatMap((c) => - (TOURISM.places[c] ?? []).map((p) => ({ ...p, category: c })), - ) - .filter( - (p) => - p.distanceM !== null && - p.distanceM > minM && - p.distanceM <= s.maxDistanceM, - ) - .sort((a, b) => (a.distanceM ?? 0) - (b.distanceM ?? 0)) - .slice(0, 6), - }; - }), - [], - ); - - const totalPlaces = - CATEGORY_ORDER.reduce((n, c) => n + (TOURISM.places[c]?.length ?? 0), 0) + - TOURISM.festivals.length; - - return ( - - {/* 출처와 기준점 */} -
-
-
-
기준 좌표
-
{TOURISM.meta.origin.label}
-
- {TOURISM.meta.origin.lat}, {TOURISM.meta.origin.lng} · {TOURISM.meta.origin.coordSource} -
-
-
-
데이터 출처
-
{TOURISM.meta.source}
-
- 수집 {TOURISM.meta.fetchedAt.replace('T', ' ')} -
-
-
-
수집 반경
-
- 숙박·식사 5km · 관광·문화·쇼핑 10km -
-
- 축제 {TOURISM.meta.festivalRadiusKm}km 이내 -
-
-
-
- - {/* 탭 */} -
- setTab('route')}> - 회복 동선 - - {CATEGORY_ORDER.map((c) => ( - setTab(c)}> - {CATEGORY_META[c].label} {TOURISM.places[c]?.length ?? 0} - - ))} - setTab('festival')}> - 축제 {TOURISM.festivals.length} - -
- - {tab === 'route' && ( -
- - {stageBuckets.map(({ stage, minM, items }, i) => ( - -
-

{stage.label}

- - 병원 반경 {minM === 0 ? '' : `${fmtDistance(minM)} ~ `} - {fmtDistance(stage.maxDistanceM)} - -
-

{stage.note}

- {items.length === 0 ? ( - 이 반경에 담긴 영문 데이터가 없습니다. - ) : ( -
- {items.map((p) => ( - - ))} -
- )} -
- ))} -
- )} - - {tab !== 'route' && tab !== 'festival' && ( - <> - {tab === 'restaurant' && ( -
- -
- )} - - - )} - - {tab === 'festival' && ( -
- {TOURISM.festivals.map((f) => ( -
-
- {f.image ? ( - - ) : ( - 사진 없음 - )} -
-
-
- - {f.distanceKm}km -
-

{f.title}

-

- {fmtDate(f.startDate)} ~ {fmtDate(f.endDate)} -

-

{f.address}

-
-
- ))} -
- )} - - {/* 한계를 숨기지 않는다 */} -
-
- 측정 가능 범위 -
-
    - {TOURISM.meta.limits.map((l) => ( -
  • - · - {l} -
  • - ))} -
-
-
- ); -} - -/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */ - -function TabButton({ - active, - onClick, - children, -}: { - key?: string; - active: boolean; - onClick: () => void; - children: ReactNode; -}) { - return ( - - ); -} - -function PlaceGrid({ items, category }: { items: TourPlace[]; category: PlaceCategory }) { - if (items.length === 0) return 영문 데이터가 없습니다.; - return ( -
- {items.map((p) => ( - - ))} -
- ); -} - -function PlaceCard({ - place, - category, - compact = false, -}: { - key?: string; - place: TourPlace; - category: PlaceCategory; - compact?: boolean; -}) { - const { Icon, label } = CATEGORY_META[category]; - return ( -
- {!compact && ( - // 이미지가 없는 항목이 섞여 그리드가 어긋나지 않도록 자리를 항상 차지하게 둔다. -
- {place.image ? ( - - ) : ( - 사진 없음 - )} -
- )} -
-
- - {label} - · {fmtDistance(place.distanceM)} -
-

- {place.title} -

-

{place.address}

- {/* TourAPI 미제공 항목. 채우지 않고 미연동으로 둔다. */} -
- {place.rating === null ? '평점·영업시간 미연동' : `평점 ${place.rating}`} -
-
-
- ); -} - -/** 병원이 채워야 하는 값의 자리. 값이 없으면 비운 채로 무엇이 필요한지 밝힌다. */ -function ClinicGate({ - title, - filled, - waiting, -}: { - title: string; - filled?: string; - waiting: string; -}) { - const has = Boolean(filled && filled.trim()); - return ( -
-
- - - {title} - - - {has ? '병원 입력됨' : '병원 확인 대기'} - -
-

- {has ? filled : waiting} -

-
- ); -} - -function EmptyNote({ children }: { children: ReactNode }) { - return ( -
- {children} -
- ); -} diff --git a/src/pages/DiscoveryReportPage.tsx b/src/pages/DiscoveryReportPage.tsx index 6f21bb7..fb68943 100644 --- a/src/pages/DiscoveryReportPage.tsx +++ b/src/pages/DiscoveryReportPage.tsx @@ -7,7 +7,6 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2'; import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2'; import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel'; import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel'; -import { MedicalTourismPanel } from '../components/discovery/MedicalTourismPanel'; import { DISCOVERY_RESULTS } from '../data/discoveryResults'; import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore'; import type { @@ -359,9 +358,6 @@ export default function DiscoveryReportPage() { {/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */} - {/* ── 5c. 의료관광 연결 (dark) ── */} - - {/* ── 6. 채점 기준표 (light) ── */} ; - festivals: TourFestival[]; - meta: { - source: string; - fetchedAt: string; - origin: { lat: number; lng: number; label: string; coordSource: string }; - radiusByType: Record; - festivalRadiusKm: number; - counts: Record>; - /** 화면에 그대로 표시한다. 한계를 숨기지 않는다. */ - limits: string[]; - }; -} - -/** - * 회복 단계. 며칠째인지는 의학 판단이므로 여기서 정하지 않는다. - * 이 단계는 "병원에서 얼마나 멀리 움직이는가"라는 이동 반경 기준이며, - * 각 단계가 수술 후 며칠에 해당하는지는 병원이 supporter_inputs 로 입력한다. - */ -export interface RecoveryStage { - id: string; - label: string; - /** 이 단계에서 권하는 이동 반경(m). 거리로만 정의하며 의학적 판단을 담지 않는다. */ - maxDistanceM: number; - /** 이 단계에 배치할 장소 범주. */ - categories: PlaceCategory[]; - note: string; -} - -/** 병원이 입력하는 값. 없으면 화면은 "병원 확인 대기"로 둔다. */ -export interface ClinicRecoveryInput { - /** supporter_inputs.key = 'recovery' */ - recoveryNote?: string; - /** supporter_inputs.key = 'diet_guide' */ - dietGuide?: { - /** 회복 단계별 권장 음식 분류. 병원이 적은 그대로 쓴다. */ - recommended?: string; - /** 회복 단계별 피해야 할 음식 분류. */ - avoid?: string; - /** 단계가 며칠씩인지. */ - stageDays?: string; - }; -} diff --git a/supporters/src/data/factSheet.json b/supporters/src/data/factSheet.json index 2c1b0df..02dbf6f 100644 --- a/supporters/src/data/factSheet.json +++ b/supporters/src/data/factSheet.json @@ -2,6 +2,9 @@ "_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.", "name": "뷰성형외과의원", "shortName": "뷰성형외과", + "nameEn": "View Plastic Surgery", + "shortNameEn": "View Plastic Surgery", + "shortNameEnSource": "https://www.viewplasticsurgery.com", "kind": "성형외과", "areaLabel": "서울 강남 신논현역", "founded": "2005", diff --git a/src/data/medicalTourism.json b/supporters/src/data/medicalTourism.json similarity index 99% rename from src/data/medicalTourism.json rename to supporters/src/data/medicalTourism.json index 15b4677..c78fd9b 100644 --- a/src/data/medicalTourism.json +++ b/supporters/src/data/medicalTourism.json @@ -1714,7 +1714,7 @@ ], "meta": { "source": "한국관광공사 TourAPI EngService2", - "fetchedAt": "2026-09-09T11:29:26", + "fetchedAt": "2026-09-09T11:42:20", "origin": { "lat": 37.5049823, "lng": 127.0254182, diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index 26f36fd..5f9658f 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -11,8 +11,20 @@ interface Props { ogImage?: string; type?: 'website' | 'article'; noindex?: boolean; + /** 페이지 언어. 외국인 환자용 영문 페이지(/en/*)에서 'en' 을 넘긴다. 기본은 한국어다. */ + lang?: 'ko' | 'en'; } -const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props; +const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko' } = Astro.props; +const isEn = lang === 'en'; +// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조. +const T = isEn + ? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' } + : { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' }; +// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 56조·추천보증 심사지침). +// 병원이 영문 문구를 확정해 site.json 의 sponsorNoticeEn / factSheet 의 sideEffectNoticeEn 에 넣기 전까지는 +// 한국어 원문을 그대로 싣고 확인 대기임을 밝힌다. +const sponsorEn: string = (S as Record).sponsorNoticeEn || ''; +const sideEffectEn: string = (f as Record).sideEffectNoticeEn || ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); @@ -21,7 +33,7 @@ const GA4: string = /^G-[A-Z0-9]{6,}$/.test(String((S as Record).ga const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; --- - + @@ -35,7 +47,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array - + @@ -46,31 +58,27 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array document.addEventListener('click',function(e){var a=e.target.closest&&e.target.closest('a');if(!a)return;var h=a.getAttribute('href')||'';var ev=h.indexOf('tel:')===0?'click_tel':a.dataset.ga==='reservation'||a.classList.contains('cta')?'click_reservation':a.dataset.ga==='clinic-site'?'click_clinic_site':a.dataset.ga==='map'?'click_map':null;if(ev)gtag('event',ev,{link_url:h,page_path:location.pathname});},true);`} />} - {!INDEXABLE &&
샘플 사이트입니다. 정식 공개 전입니다.
} + {!INDEXABLE &&
{T.sample}
} {!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && ( )}
- + } + {GA4 &&