refactor(supporters): 의료관광 연결을 /discovery 에서 서포터즈 사이트 /en/stay 로 옮김

붙일 자리를 잘못 잡았다. /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) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-09 11:42:46 +09:00
parent 5d3ce40f77
commit a807d5dd9c
18 changed files with 359 additions and 574 deletions

View File

@ -14,8 +14,11 @@
Google Places 승인 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서 Google Places 승인 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
판정하지 않는다. 판정하지 않는다.
출력은 서포터즈 사이트(Astro) 데이터다. 화면은 supporters/src/pages/en/stay.astro .
INFINITH 제품 화면(/discovery) 아니라 병원 서포터즈 사이트에 실린다. 외국인 환자가 보는 곳이다.
python3 scripts/fetch_medical_tourism.py 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 import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request
@ -131,7 +134,7 @@ def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다") ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
ap.add_argument("--per-type", type=int, default=24, 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() a = ap.parse_args()
env = load_env(os.path.join(ROOT, ".env")) env = load_env(os.path.join(ROOT, ".env"))

View File

@ -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<PlaceCategory | 'festival' | 'route'>('route');
const [clinicInput, setClinicInput] = useState<{
recovery?: string;
diet?: Record<string, string>;
}>({});
// 병원이 입력한 회복·식이 값을 읽는다. 같은 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<string, Record<string, string>>();
for (const r of rows) latest.set(r.key, r.value as Record<string, string>);
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 (
<SectionWrapper
id="medical-tourism"
title="Medical Tourism Connect"
subtitle={`외국인 환자의 수술 전후 동선을 병원 반경 기준으로 연결합니다. 한국관광공사 TourAPI 영문 데이터 ${totalPlaces}`}
dark
>
{/* 출처와 기준점 */}
<div className="mb-8 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/10 p-5">
<div className="grid gap-4 md:grid-cols-3 text-sm">
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">{TOURISM.meta.origin.label}</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.origin.lat}, {TOURISM.meta.origin.lng} · {TOURISM.meta.origin.coordSource}
</div>
</div>
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">{TOURISM.meta.source}</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.fetchedAt.replace('T', ' ')}
</div>
</div>
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">
· 5km · ·· 10km
</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.festivalRadiusKm}km
</div>
</div>
</div>
</div>
{/* 탭 */}
<div className="flex flex-wrap gap-2 mb-8" data-no-print>
<TabButton active={tab === 'route'} onClick={() => setTab('route')}>
</TabButton>
{CATEGORY_ORDER.map((c) => (
<TabButton key={c} active={tab === c} onClick={() => setTab(c)}>
{CATEGORY_META[c].label} {TOURISM.places[c]?.length ?? 0}
</TabButton>
))}
<TabButton active={tab === 'festival'} onClick={() => setTab('festival')}>
{TOURISM.festivals.length}
</TabButton>
</div>
{tab === 'route' && (
<div className="space-y-5">
<ClinicGate
title="회복 단계별 일수"
filled={clinicInput.recovery}
waiting="수술별로 각 단계가 며칠인지는 병원이 정합니다. 아래 '병원 확인 항목'의 회복 일정 칸에 입력하면 이 동선에 날짜가 붙습니다."
/>
{stageBuckets.map(({ stage, minM, items }, i) => (
<motion.div
key={stage.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<div className="flex items-baseline justify-between gap-3 mb-1 flex-wrap">
<h3 className="text-lg font-bold text-[#0A1128]">{stage.label}</h3>
<span className="text-xs font-medium text-slate-500">
{minM === 0 ? '' : `${fmtDistance(minM)} ~ `}
{fmtDistance(stage.maxDistanceM)}
</span>
</div>
<p className="text-sm text-slate-600 mb-4">{stage.note}</p>
{items.length === 0 ? (
<EmptyNote> .</EmptyNote>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<PlaceCard key={`${p.category}-${p.id}`} place={p} category={p.category} compact />
))}
</div>
)}
</motion.div>
))}
</div>
)}
{tab !== 'route' && tab !== 'festival' && (
<>
{tab === 'restaurant' && (
<div className="mb-5">
<ClinicGate
title="수술 전후 식이 가이드"
filled={clinicInput.diet?.recommended}
waiting="어떤 음식이 회복에 맞는지는 의학 판단이라 병원이 정합니다. 병원이 권장·회피 분류를 입력하면 이 목록에 표시됩니다. 개별 식당의 적합 여부를 저희가 판정하지 않습니다."
/>
</div>
)}
<PlaceGrid items={TOURISM.places[tab] ?? []} category={tab} />
</>
)}
{tab === 'festival' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{TOURISM.festivals.map((f) => (
<div
key={f.id}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
>
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
{f.image ? (
<img src={f.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
) : (
<span className="text-xs text-slate-400"> </span>
)}
</div>
<div className="p-5">
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
<TicketFilled size={16} />
<span className="text-xs font-semibold">{f.distanceKm}km</span>
</div>
<h4 className="text-base font-bold text-[#0A1128] mb-1.5">{f.title}</h4>
<p className="text-xs text-slate-500 mb-2">
{fmtDate(f.startDate)} ~ {fmtDate(f.endDate)}
</p>
<p className="text-sm text-slate-600">{f.address}</p>
</div>
</div>
))}
</div>
)}
{/* 한계를 숨기지 않는다 */}
<div className="mt-10 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs font-semibold tracking-wider text-purple-300 mb-3">
</div>
<ul className="space-y-1.5">
{TOURISM.meta.limits.map((l) => (
<li key={l} className="text-sm text-white/80 flex gap-2">
<span className="text-purple-300 shrink-0">·</span>
<span>{l}</span>
</li>
))}
</ul>
</div>
</SectionWrapper>
);
}
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
function TabButton({
active,
onClick,
children,
}: {
key?: string;
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
active
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: 'bg-white/10 text-white/80 hover:bg-white/20'
}`}
>
{children}
</button>
);
}
function PlaceGrid({ items, category }: { items: TourPlace[]; category: PlaceCategory }) {
if (items.length === 0) return <EmptyNote> .</EmptyNote>;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<PlaceCard key={p.id} place={p} category={category} />
))}
</div>
);
}
function PlaceCard({
place,
category,
compact = false,
}: {
key?: string;
place: TourPlace;
category: PlaceCategory;
compact?: boolean;
}) {
const { Icon, label } = CATEGORY_META[category];
return (
<div
className={`bg-white rounded-2xl overflow-hidden transition-shadow h-full flex flex-col ${
compact
? 'border border-slate-100 shadow-sm hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
: 'shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
{!compact && (
// 이미지가 없는 항목이 섞여 그리드가 어긋나지 않도록 자리를 항상 차지하게 둔다.
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
{place.image ? (
<img src={place.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
) : (
<span className="text-xs text-slate-400"> </span>
)}
</div>
)}
<div className={`flex-1 ${compact ? 'p-4' : 'p-5'}`}>
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
<Icon size={16} />
<span className="text-xs font-semibold">{label}</span>
<span className="text-xs text-slate-400">· {fmtDistance(place.distanceM)}</span>
</div>
<h4 className={`font-bold text-[#0A1128] mb-1.5 ${compact ? 'text-sm' : 'text-base'}`}>
{place.title}
</h4>
<p className="text-xs text-slate-500 mb-2">{place.address}</p>
{/* TourAPI 미제공 항목. 채우지 않고 미연동으로 둔다. */}
<div className="text-xs text-slate-400">
{place.rating === null ? '평점·영업시간 미연동' : `평점 ${place.rating}`}
</div>
</div>
</div>
);
}
/** 병원이 채워야 하는 값의 자리. 값이 없으면 비운 채로 무엇이 필요한지 밝힌다. */
function ClinicGate({
title,
filled,
waiting,
}: {
title: string;
filled?: string;
waiting: string;
}) {
const has = Boolean(filled && filled.trim());
return (
<div
className="rounded-2xl border p-5"
style={
has
? { background: '#F3F0FF', borderColor: '#D5CDF5' }
: { background: '#FFF6ED', borderColor: '#F5E0C5' }
}
>
<div className="flex items-center gap-2 mb-2">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: has ? '#9B8AD4' : '#D4A872' }}
/>
<span className="text-sm font-bold" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
{title}
</span>
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full"
style={
has
? { background: '#fff', color: '#4A3A7C' }
: { background: '#fff', color: '#7C5C3A' }
}
>
{has ? '병원 입력됨' : '병원 확인 대기'}
</span>
</div>
<p className="text-sm" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
{has ? filled : waiting}
</p>
</div>
);
}
function EmptyNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-sm text-white/70">
{children}
</div>
);
}

View File

@ -7,7 +7,6 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2'; import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel'; import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel'; import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
import { MedicalTourismPanel } from '../components/discovery/MedicalTourismPanel';
import { DISCOVERY_RESULTS } from '../data/discoveryResults'; import { DISCOVERY_RESULTS } from '../data/discoveryResults';
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore'; import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
import type { import type {
@ -359,9 +358,6 @@ export default function DiscoveryReportPage() {
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */} {/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
<ClinicInputsPanel clinicId={result.id} /> <ClinicInputsPanel clinicId={result.id} />
{/* ── 5c. 의료관광 연결 (dark) ── */}
<MedicalTourismPanel clinicId={result.id} />
{/* ── 6. 채점 기준표 (light) ── */} {/* ── 6. 채점 기준표 (light) ── */}
<SectionWrapper <SectionWrapper
id="rubric" id="rubric"

View File

@ -1,88 +0,0 @@
/**
* .
*
* TourAPI () `scripts/fetch_medical_tourism.py`
* · `src/data/medicalTourism.json` .
*
* 원칙: 없는 .
* - rating/reviewCount/openingHours TourAPI . Google Places .
* null "미연동" .
* - . .
*/
/** 병원 반경 안에서 찾은 장소 한 곳. */
export interface TourPlace {
id: string;
title: string;
address: string;
image: string | null;
tel: string | null;
lat: number | null;
lng: number | null;
/** 병원으로부터의 거리(m). TourAPI 가 준 값을 그대로 쓰고, 없으면 좌표로 계산한다. */
distanceM: number | null;
/** 아래 셋은 TourAPI 미제공. Google Places 승인 전까지 null 이다. */
rating: number | null;
reviewCount: number | null;
openingHours: string | null;
}
/** 축제·행사. 영문 서비스는 areacode 가 비어 있어 좌표 거리로 걸렀다. */
export interface TourFestival extends TourPlace {
startDate: string | null;
endDate: string | null;
distanceKm: number;
}
export type PlaceCategory =
| 'stay'
| 'restaurant'
| 'wellness'
| 'attraction'
| 'culture'
| 'shopping';
export interface MedicalTourismData {
places: Record<PlaceCategory, TourPlace[]>;
festivals: TourFestival[];
meta: {
source: string;
fetchedAt: string;
origin: { lat: number; lng: number; label: string; coordSource: string };
radiusByType: Record<string, number>;
festivalRadiusKm: number;
counts: Record<string, Record<string, unknown>>;
/** 화면에 그대로 표시한다. 한계를 숨기지 않는다. */
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;
};
}

View File

@ -2,6 +2,9 @@
"_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.", "_comment": "병원 사실의 단일 원본. 모든 페이지의 병원 정보 블록과 MedicalClinic 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.",
"name": "뷰성형외과의원", "name": "뷰성형외과의원",
"shortName": "뷰성형외과", "shortName": "뷰성형외과",
"nameEn": "View Plastic Surgery",
"shortNameEn": "View Plastic Surgery",
"shortNameEnSource": "https://www.viewplasticsurgery.com",
"kind": "성형외과", "kind": "성형외과",
"areaLabel": "서울 강남 신논현역", "areaLabel": "서울 강남 신논현역",
"founded": "2005", "founded": "2005",

View File

@ -1714,7 +1714,7 @@
], ],
"meta": { "meta": {
"source": "한국관광공사 TourAPI EngService2", "source": "한국관광공사 TourAPI EngService2",
"fetchedAt": "2026-09-09T11:29:26", "fetchedAt": "2026-09-09T11:42:20",
"origin": { "origin": {
"lat": 37.5049823, "lat": 37.5049823,
"lng": 127.0254182, "lng": 127.0254182,

View File

@ -11,8 +11,20 @@ interface Props {
ogImage?: string; ogImage?: string;
type?: 'website' | 'article'; type?: 'website' | 'article';
noindex?: boolean; 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<string, any>).sponsorNoticeEn || '';
const sideEffectEn: string = (f as Record<string, any>).sideEffectNoticeEn || '';
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); 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<string, any>).ga
const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null;
--- ---
<!doctype html> <!doctype html>
<html lang="ko"> <html lang={lang}>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -35,7 +47,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL(ogImage, Astro.site).toString()} /> <meta property="og:image" content={new URL(ogImage, Astro.site).toString()} />
<meta property="og:locale" content="ko_KR" /> <meta property="og:locale" content={isEn ? "en_US" : "ko_KR"} />
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
@ -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);`} />} 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);`} />}
</head> </head>
<body> <body>
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>} {!INDEXABLE && <div class="sample-banner">{T.sample}</div>}
<header class="site-header"> <header class="site-header">
<div class="wrap-wide bar"> <div class="wrap-wide bar">
<a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a> <a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">{T.supporters} <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
<div class="right"> <div class="right">
<nav class="nav"> <nav class="nav">
<a href="/posts">상담 전 질문</a> {T.nav.map(([href, label]) => <a href={href}>{label}</a>)}
<a href="/videos">의료진 영상</a>
<a href="/newsroom">뉴스룸</a>
<a href="/visit">방문 안내</a>
<a href="/about">매체 소개</a>
</nav> </nav>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a> <a class="cta" href={reservationUrl || (isEn ? '/en/stay' : '/visit')} rel="noopener">{T.cta}</a>
</div> </div>
</div> </div>
</header> </header>
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && ( {!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && (
<div class="backbar-wrap"><div class="wrap-wide backbar"> <div class="backbar-wrap"><div class="wrap-wide backbar">
<a href="/" class="backbtn" id="backbtn" aria-label="이전 페이지로 돌아가기"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>이전 페이지</a> <a href="/" class="backbtn" id="backbtn" aria-label={T.back}><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>{T.back}</a>
</div></div> </div></div>
)} )}
<main> <main>
<slot /> <slot />
</main> </main>
<button type="button" class="totop" id="totop" aria-label="맨 위로"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button> <button type="button" class="totop" id="totop" aria-label={T.top}><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button>
<script> <script>
const back = document.getElementById('backbtn'); const back = document.getElementById('backbtn');
if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } }); if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } });
@ -83,8 +91,9 @@ document.addEventListener('click',function(e){var a=e.target.closest&&e.target.c
<div class="wrap-wide cols"> <div class="wrap-wide cols">
<div> <div>
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p> <p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p> {isEn && !sponsorEn && <p class="notice-pending">Korean original below. The English wording of these notices is pending clinic approval.</p>}
<p>{fact.sideEffectNotice}</p> <p>{isEn && sponsorEn ? sponsorEn : <>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</>}</p>
<p>{isEn && sideEffectEn ? sideEffectEn : fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p> <p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p>
</div> </div>
<div> <div>

View File

@ -0,0 +1,117 @@
---
// 외국인 환자용 체류 안내. 병원 좌표를 중심으로 숙박·식사·회복·관광·행사를 이동 반경 순으로 놓는다.
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가 미리 수집한
// src/data/medicalTourism.json 이다. 값을 만들지 않는다.
// - 평점·리뷰·영업시간은 TourAPI 가 주지 않아 싣지 않는다.
// - 수술 전후 식이 적합성은 의학 판단이라 병원이 확정한 문구만 싣는다(site.json dietGuideEn).
// - 회복 단계가 며칠째인지도 병원 확정값이다(site.json recoveryStagesEn). 단계는 이동 거리로만 정의한다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, has } from '../../lib';
import data from '../../data/medicalTourism.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
const GROUPS: Array<{ key: string; label: string; blurb: string }> = [
{ key: 'stay', label: 'Where to stay', blurb: 'Hotels and guesthouses near the clinic, for the days when visits are frequent.' },
{ key: 'restaurant', label: 'Where to eat', blurb: 'Restaurants within walking or short-ride distance.' },
{ key: 'wellness', label: 'Rest and recovery', blurb: 'Spas and jjimjilbang listed by the Korea Tourism Organization.' },
{ key: 'attraction', label: 'Places to see', blurb: 'Sights within the city, once you are able to travel further.' },
{ key: 'culture', label: 'Culture', blurb: 'Museums, theatres and halls.' },
{ key: 'shopping', label: 'Shopping', blurb: 'Department stores, markets and streets.' },
];
const dist = (m: number | null) => (m === null ? '' : m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1)} km`);
const dt = (d: string | null) => (d && d.length === 8 ? `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}` : '');
// 병원이 확정하기 전에는 비운다. 임의로 채우지 않는다.
const dietGuide: string = Sx.dietGuideEn || '';
const recoveryStages: string = Sx.recoveryStagesEn || '';
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Your stay near ${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),
];
---
<Base
lang="en"
title={`Your stay near ${clinic} — hotels, food, recovery, and what to see`}
description={`Practical guide for international patients: where to stay, eat and rest near ${clinic} in Gangnam, Seoul, plus sights and festivals, listed by distance from the clinic. Source: Korea Tourism Organization.`}
jsonLd={ld}
>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head">
<div class="eyebrow">For international patients</div>
<h1 class="serif">Your Stay in Seoul</h1>
<p style="color:var(--slate-600);margin-top:0.6rem">
<strong style="color:var(--primary-900)">Where to stay, eat and rest around {clinic}.</strong>
Everything below is listed by distance from the clinic at {f.address?.fullEn || f.address?.full}.
Addresses and names come from the Korea Tourism Organization, not from us.
For the clinic's own address, hours and phone, see <a href="/clinic">Clinic information</a>.
</p>
</header>
<h2>Before you use this page</h2>
<div class="table-wrap"><table><tbody>
<tr><th style="width:32%">Reference point</th><td>{clinic} — {T.meta.origin.lat}, {T.meta.origin.lng}</td></tr>
<tr><th>Source</th><td>Korea Tourism Organization, TourAPI EngService2 — collected {T.meta.fetchedAt.slice(0, 10)}</td></tr>
<tr><th>Search radius</th><td>Stay and food 5 km · sights, culture, shopping 10 km · festivals {T.meta.festivalRadiusKm} km</td></tr>
</tbody></table></div>
<h2>Planning your days</h2>
<p>
How many days each stage lasts depends on your procedure, and only your doctor can tell you that.
The stages below are defined by <strong>distance from the clinic</strong>, nothing else.
</p>
{recoveryStages
? <p>{recoveryStages}</p>
: <p class="pending-note">The day-by-day schedule is pending confirmation by {clinic}. Ask at your consultation.</p>}
<div class="table-wrap"><table><thead><tr><th>Stage</th><th>Distance from clinic</th><th>What fits here</th></tr></thead><tbody>
<tr><td>Frequent visits</td><td>within 1 km</td><td>Hotels and food you can reach on foot</td></tr>
<tr><td>Short outings</td><td>1 3 km</td><td>Restaurants, spas</td></tr>
<tr><td>Around the city</td><td>3 10 km</td><td>Sights, culture, shopping</td></tr>
</tbody></table></div>
{GROUPS.map((g) => {
const items = (T.places[g.key] ?? []) as any[];
return (
<section>
<h2>{g.label}</h2>
<p>{g.blurb}</p>
{g.key === 'restaurant' && (
dietGuide
? <p><strong>What {clinic} advises after surgery.</strong> {dietGuide}</p>
: <p class="pending-note">What you may eat after surgery is a medical question. {clinic} has not yet published English guidance, so we do not mark any restaurant as suitable. Please ask at your consultation.</p>
)}
{items.length === 0
? <p class="pending-note">No English listings within this radius.</p>
: <div class="table-wrap"><table><thead><tr><th style="width:44%">Name</th><th style="width:14%">Distance</th><th>Address</th></tr></thead><tbody>
{items.map((p) => <tr><td>{p.title}</td><td>{dist(p.distanceM)}</td><td>{p.address}</td></tr>)}
</tbody></table></div>}
</section>
);
})}
<h2>Festivals and events while you are here</h2>
<p>Currently running or starting soon, within {T.meta.festivalRadiusKm} km of the clinic.</p>
{T.festivals.length === 0
? <p class="pending-note">No listings.</p>
: <div class="table-wrap"><table><thead><tr><th style="width:40%">Event</th><th style="width:22%">Dates</th><th style="width:12%">Distance</th><th>Where</th></tr></thead><tbody>
{T.festivals.map((e: any) => <tr><td>{e.title}</td><td>{dt(e.startDate)} {dt(e.endDate)}</td><td>{e.distanceKm} km</td><td>{e.address}</td></tr>)}
</tbody></table></div>}
<h2>What this page does not tell you</h2>
<ul>
<li>No ratings, review counts or opening hours. The tourism data does not carry them, and we do not guess.</li>
<li>No claim that any restaurant suits your recovery. That is for {clinic} to say.</li>
<li>English tourism listings around Gangnam are thin. Some categories hold only a handful of entries.</li>
<li>We are not a travel agency and take no booking or commission. Links and names are for reference.</li>
</ul>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">Book a consultation with {clinic}</a>}</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</article>
</Base>

View File

@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
{ loc: `${base}/posts`, lastmod: today }, { loc: `${base}/posts`, lastmod: today },
{ loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today }, { loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/en/stay`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today },
{ loc: `${base}/videos`, lastmod: today }, { loc: `${base}/videos`, lastmod: today },
{ loc: `${base}/about`, lastmod: today }, { loc: `${base}/about`, lastmod: today },

View File

@ -41,6 +41,9 @@ const ld = [
{(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)} {(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)}
{!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>} {!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
</ul> </ul>
<h2>6. 해외에서 오시는 경우</h2>
<p>수술 전후에 머무는 동안의 숙박·식사·회복·관광 정보를 영문으로 정리했습니다. <a href="/en/stay" hreflang="en">Your Stay in Seoul</a>.</p>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p> <p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p>
<p class="disclosure">{fact.sideEffectNotice}</p> <p class="disclosure">{fact.sideEffectNotice}</p>
</article> </article>

View File

@ -264,3 +264,16 @@ td { color: var(--slate-700); }
.totop.show { opacity: 1; transform: none; pointer-events: auto; } .totop.show { opacity: 1; transform: none; pointer-events: auto; }
.totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); } .totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); }
@media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } } @media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } }
/* 값이 아직 없다는 것을 숨기지 않고 드러내는 문단. 병원 확인 대기 항목에 쓴다. */
.pending-note {
background: var(--status-warn-bg, #FFF6ED);
border: 1px solid var(--status-warn-border, #F5E0C5);
border-left-width: 3px;
color: var(--status-warn-text, #7C5C3A);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.92rem;
}
.notice-pending { font-size: 0.82rem; opacity: 0.75; }

View File

@ -2,6 +2,9 @@
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.", "_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
"name": "", "name": "",
"shortName": "", "shortName": "",
"nameEn": "",
"shortNameEn": "",
"shortNameEnSource": "",
"kind": "", "kind": "",
"areaLabel": "", "areaLabel": "",
"founded": "", "founded": "",

View File

@ -0,0 +1,26 @@
{
"_comment": "의료관광 연결 데이터. scripts/fetch_medical_tourism.py 가 병원 좌표로 채운다. 비어 있으면 /en/stay 는 '목록 없음'으로 표시된다. 지어내지 않는다.",
"places": {
"stay": [],
"restaurant": [],
"wellness": [],
"attraction": [],
"culture": [],
"shopping": []
},
"festivals": [],
"meta": {
"source": "",
"fetchedAt": "",
"origin": {
"lat": 0,
"lng": 0,
"label": "",
"coordSource": ""
},
"radiusByType": {},
"festivalRadiusKm": 0,
"counts": {},
"limits": []
}
}

View File

@ -11,15 +11,29 @@ interface Props {
ogImage?: string; ogImage?: string;
type?: 'website' | 'article'; type?: 'website' | 'article';
noindex?: boolean; 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<string, any>).sponsorNoticeEn || '';
const sideEffectEn: string = (f as Record<string, any>).sideEffectNoticeEn || '';
const site = Astro.site?.toString().replace(/\/$/, '') ?? ''; const site = Astro.site?.toString().replace(/\/$/, '') ?? '';
const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, ''); const cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site); const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
// GA4: site.json 의 ga4MeasurementId(G-XXXX) 가 있을 때만 태그를 넣는다. 키 이벤트는 data/ai_channels.json conversion_events 와 같은 이름(click_tel · click_reservation · click_clinic_site · click_map).
const GA4: string = /^G-[A-Z0-9]{6,}$/.test(String((S as Record<string, any>).ga4MeasurementId ?? '')) ? String((S as Record<string, any>).ga4MeasurementId) : '';
const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null; const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array.isArray(jsonLd) ? { '@graph': jsonLd } : jsonLd) }) : null;
--- ---
<!doctype html> <!doctype html>
<html lang="ko"> <html lang={lang}>
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@ -33,39 +47,38 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonical} /> <meta property="og:url" content={canonical} />
<meta property="og:image" content={new URL(ogImage, Astro.site).toString()} /> <meta property="og:image" content={new URL(ogImage, Astro.site).toString()} />
<meta property="og:locale" content="ko_KR" /> <meta property="og:locale" content={isEn ? "en_US" : "ko_KR"} />
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" /> <link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;900&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
{ld && <script type="application/ld+json" set:html={ld} />} {ld && <script type="application/ld+json" set:html={ld} />}
{GA4 && <script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${GA4}`}></script>}
{GA4 && <script is:inline set:html={`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${GA4}',{anonymize_ip:true});
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);`} />}
</head> </head>
<body> <body>
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>} {!INDEXABLE && <div class="sample-banner">{T.sample}</div>}
<header class="site-header"> <header class="site-header">
<div class="wrap-wide bar"> <div class="wrap-wide bar">
<a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">서포터즈 <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a> <a class="brand" href="/">{has(S.logo?.src) ? <img class="brand-logo" src={S.logo.src} alt={S.logo.alt} width={S.logo.width} height={S.logo.height} /> : <span class="brand-logo brand-name">{v(f.shortName, '병원')}</span>}<span class="brand-sep" aria-hidden="true"></span><span class="brand-text">{T.supporters} <small>{S.siteNameEn || 'SUPPORTERS'}</small></span></a>
<div class="right"> <div class="right">
<nav class="nav"> <nav class="nav">
<a href="/posts">상담 전 질문</a> {T.nav.map(([href, label]) => <a href={href}>{label}</a>)}
<a href="/videos">의료진 영상</a>
<a href="/newsroom">뉴스룸</a>
<a href="/visit">방문 안내</a>
<a href="/about">매체 소개</a>
</nav> </nav>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a> <a class="cta" href={reservationUrl || (isEn ? '/en/stay' : '/visit')} rel="noopener">{T.cta}</a>
</div> </div>
</div> </div>
</header> </header>
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && ( {!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && (
<div class="backbar-wrap"><div class="wrap-wide backbar"> <div class="backbar-wrap"><div class="wrap-wide backbar">
<a href="/" class="backbtn" id="backbtn" aria-label="이전 페이지로 돌아가기"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>이전 페이지</a> <a href="/" class="backbtn" id="backbtn" aria-label={T.back}><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M15.5 4.5 8 12l7.5 7.5 1.8-1.8L11.6 12l5.7-5.7z"/></svg>{T.back}</a>
</div></div> </div></div>
)} )}
<main> <main>
<slot /> <slot />
</main> </main>
<button type="button" class="totop" id="totop" aria-label="맨 위로"><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button> <button type="button" class="totop" id="totop" aria-label={T.top}><svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 5 4.5 12.5l1.8 1.8L11 9.6V20h2V9.6l4.7 4.7 1.8-1.8z"/></svg></button>
<script> <script>
const back = document.getElementById('backbtn'); const back = document.getElementById('backbtn');
if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } }); if (back) back.addEventListener('click', (e) => { if (history.length > 1 && document.referrer && new URL(document.referrer).origin === location.origin) { e.preventDefault(); history.back(); } });
@ -78,8 +91,9 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<div class="wrap-wide cols"> <div class="wrap-wide cols">
<div> <div>
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p> <p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p> {isEn && !sponsorEn && <p class="notice-pending">Korean original below. The English wording of these notices is pending clinic approval.</p>}
<p>{fact.sideEffectNotice}</p> <p>{isEn && sponsorEn ? sponsorEn : <>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</>}</p>
<p>{isEn && sideEffectEn ? sideEffectEn : fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p> <p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p>
</div> </div>
<div> <div>

View File

@ -0,0 +1,117 @@
---
// 외국인 환자용 체류 안내. 병원 좌표를 중심으로 숙박·식사·회복·관광·행사를 이동 반경 순으로 놓는다.
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가 미리 수집한
// src/data/medicalTourism.json 이다. 값을 만들지 않는다.
// - 평점·리뷰·영업시간은 TourAPI 가 주지 않아 싣지 않는다.
// - 수술 전후 식이 적합성은 의학 판단이라 병원이 확정한 문구만 싣는다(site.json dietGuideEn).
// - 회복 단계가 며칠째인지도 병원 확정값이다(site.json recoveryStagesEn). 단계는 이동 거리로만 정의한다.
import Base from '../../layouts/Base.astro';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, has } from '../../lib';
import data from '../../data/medicalTourism.json';
const f = fact as Record<string, any>;
const Sx = S as Record<string, any>;
const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic';
const site = Astro.site!.toString().replace(/\/$/, '');
const T = data as any;
const GROUPS: Array<{ key: string; label: string; blurb: string }> = [
{ key: 'stay', label: 'Where to stay', blurb: 'Hotels and guesthouses near the clinic, for the days when visits are frequent.' },
{ key: 'restaurant', label: 'Where to eat', blurb: 'Restaurants within walking or short-ride distance.' },
{ key: 'wellness', label: 'Rest and recovery', blurb: 'Spas and jjimjilbang listed by the Korea Tourism Organization.' },
{ key: 'attraction', label: 'Places to see', blurb: 'Sights within the city, once you are able to travel further.' },
{ key: 'culture', label: 'Culture', blurb: 'Museums, theatres and halls.' },
{ key: 'shopping', label: 'Shopping', blurb: 'Department stores, markets and streets.' },
];
const dist = (m: number | null) => (m === null ? '' : m < 1000 ? `${m} m` : `${(m / 1000).toFixed(1)} km`);
const dt = (d: string | null) => (d && d.length === 8 ? `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}` : '');
// 병원이 확정하기 전에는 비운다. 임의로 채우지 않는다.
const dietGuide: string = Sx.dietGuideEn || '';
const recoveryStages: string = Sx.recoveryStagesEn || '';
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `Your stay near ${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),
];
---
<Base
lang="en"
title={`Your stay near ${clinic} — hotels, food, recovery, and what to see`}
description={`Practical guide for international patients: where to stay, eat and rest near ${clinic} in Gangnam, Seoul, plus sights and festivals, listed by distance from the clinic. Source: Korea Tourism Organization.`}
jsonLd={ld}
>
<article class="wrap" style="padding-bottom:4rem">
<header class="article-head">
<div class="eyebrow">For international patients</div>
<h1 class="serif">Your Stay in Seoul</h1>
<p style="color:var(--slate-600);margin-top:0.6rem">
<strong style="color:var(--primary-900)">Where to stay, eat and rest around {clinic}.</strong>
Everything below is listed by distance from the clinic at {f.address?.fullEn || f.address?.full}.
Addresses and names come from the Korea Tourism Organization, not from us.
For the clinic's own address, hours and phone, see <a href="/clinic">Clinic information</a>.
</p>
</header>
<h2>Before you use this page</h2>
<div class="table-wrap"><table><tbody>
<tr><th style="width:32%">Reference point</th><td>{clinic} — {T.meta.origin.lat}, {T.meta.origin.lng}</td></tr>
<tr><th>Source</th><td>Korea Tourism Organization, TourAPI EngService2 — collected {T.meta.fetchedAt.slice(0, 10)}</td></tr>
<tr><th>Search radius</th><td>Stay and food 5 km · sights, culture, shopping 10 km · festivals {T.meta.festivalRadiusKm} km</td></tr>
</tbody></table></div>
<h2>Planning your days</h2>
<p>
How many days each stage lasts depends on your procedure, and only your doctor can tell you that.
The stages below are defined by <strong>distance from the clinic</strong>, nothing else.
</p>
{recoveryStages
? <p>{recoveryStages}</p>
: <p class="pending-note">The day-by-day schedule is pending confirmation by {clinic}. Ask at your consultation.</p>}
<div class="table-wrap"><table><thead><tr><th>Stage</th><th>Distance from clinic</th><th>What fits here</th></tr></thead><tbody>
<tr><td>Frequent visits</td><td>within 1 km</td><td>Hotels and food you can reach on foot</td></tr>
<tr><td>Short outings</td><td>1 3 km</td><td>Restaurants, spas</td></tr>
<tr><td>Around the city</td><td>3 10 km</td><td>Sights, culture, shopping</td></tr>
</tbody></table></div>
{GROUPS.map((g) => {
const items = (T.places[g.key] ?? []) as any[];
return (
<section>
<h2>{g.label}</h2>
<p>{g.blurb}</p>
{g.key === 'restaurant' && (
dietGuide
? <p><strong>What {clinic} advises after surgery.</strong> {dietGuide}</p>
: <p class="pending-note">What you may eat after surgery is a medical question. {clinic} has not yet published English guidance, so we do not mark any restaurant as suitable. Please ask at your consultation.</p>
)}
{items.length === 0
? <p class="pending-note">No English listings within this radius.</p>
: <div class="table-wrap"><table><thead><tr><th style="width:44%">Name</th><th style="width:14%">Distance</th><th>Address</th></tr></thead><tbody>
{items.map((p) => <tr><td>{p.title}</td><td>{dist(p.distanceM)}</td><td>{p.address}</td></tr>)}
</tbody></table></div>}
</section>
);
})}
<h2>Festivals and events while you are here</h2>
<p>Currently running or starting soon, within {T.meta.festivalRadiusKm} km of the clinic.</p>
{T.festivals.length === 0
? <p class="pending-note">No listings.</p>
: <div class="table-wrap"><table><thead><tr><th style="width:40%">Event</th><th style="width:22%">Dates</th><th style="width:12%">Distance</th><th>Where</th></tr></thead><tbody>
{T.festivals.map((e: any) => <tr><td>{e.title}</td><td>{dt(e.startDate)} {dt(e.endDate)}</td><td>{e.distanceKm} km</td><td>{e.address}</td></tr>)}
</tbody></table></div>}
<h2>What this page does not tell you</h2>
<ul>
<li>No ratings, review counts or opening hours. The tourism data does not carry them, and we do not guess.</li>
<li>No claim that any restaurant suits your recovery. That is for {clinic} to say.</li>
<li>English tourism listings around Gangnam are thin. Some categories hold only a handful of entries.</li>
<li>We are not a travel agency and take no booking or commission. Links and names are for reference.</li>
</ul>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">Book a consultation with {clinic}</a>}</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</article>
</Base>

View File

@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
{ loc: `${base}/posts`, lastmod: today }, { loc: `${base}/posts`, lastmod: today },
{ loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/clinic`, lastmod: today },
{ loc: `${base}/visit`, lastmod: today }, { loc: `${base}/visit`, lastmod: today },
{ loc: `${base}/en/stay`, lastmod: today },
{ loc: `${base}/newsroom`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today },
{ loc: `${base}/videos`, lastmod: today }, { loc: `${base}/videos`, lastmod: today },
{ loc: `${base}/about`, lastmod: today }, { loc: `${base}/about`, lastmod: today },

View File

@ -41,6 +41,9 @@ const ld = [
{(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)} {(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)}
{!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>} {!(S.visitReads ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
</ul> </ul>
<h2>6. 해외에서 오시는 경우</h2>
<p>수술 전후에 머무는 동안의 숙박·식사·회복·관광 정보를 영문으로 정리했습니다. <a href="/en/stay" hreflang="en">Your Stay in Seoul</a>.</p>
<p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p> <p style="margin-top:2rem">{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}</p>
<p class="disclosure">{fact.sideEffectNotice}</p> <p class="disclosure">{fact.sideEffectNotice}</p>
</article> </article>

View File

@ -264,3 +264,16 @@ td { color: var(--slate-700); }
.totop.show { opacity: 1; transform: none; pointer-events: auto; } .totop.show { opacity: 1; transform: none; pointer-events: auto; }
.totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); } .totop:hover { box-shadow: 0 10px 28px rgba(2,19,65,0.40); }
@media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } } @media (max-width: 720px) { .totop { right: 1rem; bottom: 1.1rem; width: 44px; height: 44px; } }
/* 값이 아직 없다는 것을 숨기지 않고 드러내는 문단. 병원 확인 대기 항목에 쓴다. */
.pending-note {
background: var(--status-warn-bg, #FFF6ED);
border: 1px solid var(--status-warn-border, #F5E0C5);
border-left-width: 3px;
color: var(--status-warn-text, #7C5C3A);
border-radius: 10px;
padding: 0.75rem 1rem;
font-size: 0.92rem;
}
.notice-pending { font-size: 0.82rem; opacity: 0.75; }