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:
parent
5d3ce40f77
commit
a807d5dd9c
@ -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"))
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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,
|
||||||
@ -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>
|
||||||
|
|||||||
117
supporters/src/pages/en/stay.astro
Normal file
117
supporters/src/pages/en/stay.astro
Normal 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>
|
||||||
@ -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 },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -2,6 +2,9 @@
|
|||||||
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
|
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
|
||||||
"name": "",
|
"name": "",
|
||||||
"shortName": "",
|
"shortName": "",
|
||||||
|
"nameEn": "",
|
||||||
|
"shortNameEn": "",
|
||||||
|
"shortNameEnSource": "",
|
||||||
"kind": "",
|
"kind": "",
|
||||||
"areaLabel": "",
|
"areaLabel": "",
|
||||||
"founded": "",
|
"founded": "",
|
||||||
|
|||||||
26
templates/supporters-astro/src/data/medicalTourism.json
Normal file
26
templates/supporters-astro/src/data/medicalTourism.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
117
templates/supporters-astro/src/pages/en/stay.astro
Normal file
117
templates/supporters-astro/src/pages/en/stay.astro
Normal 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>
|
||||||
@ -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 },
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user