Compare commits
2 Commits
5d3ce40f77
...
e32633e9a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e32633e9a6 | ||
|
|
a807d5dd9c |
@ -14,10 +14,13 @@
|
||||
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
|
||||
판정하지 않는다.
|
||||
|
||||
출력은 서포터즈 사이트(Astro)의 데이터다. 화면은 supporters/src/pages/en/stay.astro 다.
|
||||
INFINITH 제품 화면(/discovery)이 아니라 병원 서포터즈 사이트에 실린다. 외국인 환자가 보는 곳이다.
|
||||
|
||||
python3 scripts/fetch_medical_tourism.py
|
||||
python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json
|
||||
python3 scripts/fetch_medical_tourism.py --out supporters/src/data/medicalTourism.json
|
||||
"""
|
||||
import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request
|
||||
import argparse, io, json, math, os, re, ssl, sys, time, urllib.parse, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@ -30,6 +33,9 @@ CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
|
||||
# 다국어 ContentTypeId. 국문과 코드가 다르다.
|
||||
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
|
||||
SEASON = {3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer",
|
||||
9: "autumn", 10: "autumn", 11: "autumn", 12: "winter", 1: "winter", 2: "winter"}
|
||||
|
||||
TYPES = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
@ -95,6 +101,164 @@ def call_all(env, op, extra, max_pages=6, rows=100):
|
||||
return total, got
|
||||
|
||||
|
||||
def walk_min(m):
|
||||
"""도보 분. 80m/분(4.8km/h) 기준. 참고 사이트의 표기를 역산해 맞춘 값이다
|
||||
(135m→2분, 248m→3분, 297m→4분, 566m→7분)."""
|
||||
return max(1, round(m / 80))
|
||||
|
||||
|
||||
def drive_min(km):
|
||||
"""차 이동 분. 직선거리 km x 1.2. 역시 역산값이다(25km→30분, 33km→40분)."""
|
||||
return max(1, round(km * 1.2))
|
||||
|
||||
|
||||
def travel(m):
|
||||
"""이동 수단과 소요 시간.
|
||||
2km 까지는 걸을 수 있는 거리로 본다(약 25분). 그 위는 차·지하철이다.
|
||||
짧은 거리의 차 시간은 주차와 신호 때문에 의미가 없으므로 최소 5분으로 둔다."""
|
||||
if m is None: return None
|
||||
if m <= 2000: return {"mode": "walk", "minutes": walk_min(m), "label": f"{walk_min(m)} min walk"}
|
||||
mins = max(5, drive_min(m / 1000))
|
||||
return {"mode": "drive", "minutes": mins, "label": f"{mins} min by car"}
|
||||
|
||||
|
||||
def fetch_overview(env, content_id, cache):
|
||||
"""detailCommon2 의 overview(설명문)와 homepage. locationBasedList2 는 주지 않는다.
|
||||
설명문이 없으면 카드가 이름과 주소뿐이라 사람이 고를 수 없다."""
|
||||
if content_id in cache: return cache[content_id]
|
||||
try:
|
||||
_, rows = call(env, "detailCommon2", {"contentId": content_id}, rows=1)
|
||||
except Exception:
|
||||
cache[content_id] = ("", ""); return cache[content_id]
|
||||
if not rows: cache[content_id] = ("", ""); return cache[content_id]
|
||||
r = rows[0]
|
||||
ov = re.sub(r"<[^>]+>", " ", r.get("overview") or "")
|
||||
ov = re.sub(r"\s+", " ", ov).strip()
|
||||
hp = re.sub(r"<[^>]+>", " ", r.get("homepage") or "").strip()
|
||||
hp = (re.search(r"https?://\S+", hp) or [None])[0] if hp else ""
|
||||
cache[content_id] = (ov, hp or "")
|
||||
return cache[content_id]
|
||||
|
||||
|
||||
def fetch_image(env, content_id, cache):
|
||||
"""locationBasedList2 의 firstimage 가 비어 있을 때 detailImage2 로 한 번 더 찾는다.
|
||||
이미지 전용 오퍼레이션이라 목록 API 에 없는 사진이 여기 있다(도산공원 7장, 몽마르뜨공원 5장).
|
||||
실측: 사진 없던 38건 중 15건을 여기서 살렸다. 나머지 23건은 TourAPI 에 사진이 없다."""
|
||||
key = ("img", content_id)
|
||||
if key in cache: return cache[key]
|
||||
try:
|
||||
_, rows = call(env, "detailImage2", {"contentId": content_id, "imageYN": "Y"}, rows=5)
|
||||
except Exception:
|
||||
cache[key] = ""; return ""
|
||||
url = ""
|
||||
for r in rows or []:
|
||||
u = (r.get("originimgurl") or r.get("smallimageurl") or "").strip()
|
||||
if u: url = u.replace("http://", "https://", 1); break
|
||||
cache[key] = url
|
||||
return url
|
||||
|
||||
|
||||
def call_kor(env, op, extra, rows=10):
|
||||
"""국문 서비스(KorService2). 영문 항목에 사진이 없어도 국문 항목에는 있는 경우가 있다.
|
||||
같은 한국관광공사 사진이라 출처가 달라지지 않는다."""
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": "1", **extra}
|
||||
url = f"{env['TOUR_API_BASE']}/KorService2/{op}?serviceKey={env['TOUR_API_KEY']}&" + urllib.parse.urlencode(p)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=25, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"): return []
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000": return []
|
||||
it = j["body"].get("items")
|
||||
return [] if not it or it == "" else it["item"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def korean_name(title):
|
||||
m = re.search(r"\(([^)]+)\)\s*$", title or "")
|
||||
return (m.group(1) if m else (title or "")).strip()
|
||||
|
||||
|
||||
def fetch_image_kor(env, p):
|
||||
"""국문 서비스에서 같은 이름의 항목을 찾아 사진을 가져온다. 실측 23건 중 10건이 여기서 나왔다."""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not re.search(r"[가-힣]", ko): return ""
|
||||
rows = []
|
||||
if p.get("lat"):
|
||||
rows = call_kor(env, "locationBasedList2",
|
||||
{"mapX": str(p["lng"]), "mapY": str(p["lat"]), "radius": "300", "keyword": ko})
|
||||
if not rows: rows = call_kor(env, "searchKeyword2", {"keyword": ko})
|
||||
key = ko.replace(" ", "")
|
||||
for r in rows:
|
||||
if key in (r.get("title") or "").replace(" ", ""):
|
||||
u = (r.get("firstimage") or r.get("firstimage2") or "").strip()
|
||||
if u: return u.replace("http://", "https://", 1)
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_image_og(url):
|
||||
"""업체 공식 홈페이지의 대표 이미지(og:image). 업체가 스스로 올린 사진이라 목록에 쓰기에 무리가 없다.
|
||||
검색 결과 이미지를 긁어 쓰면 식당·블로그·언론사의 저작물이라 환자용 페이지에 올릴 수 없다."""
|
||||
if not url or not url.startswith("http"): return ""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=12, context=ssl_ctx()) as r:
|
||||
head = r.read(120000).decode("utf-8", "replace")
|
||||
base = r.geturl()
|
||||
except Exception:
|
||||
return ""
|
||||
m = re.search(r'<meta[^>]+(?:property|name)=["\']og:image["\'][^>]*content=["\']([^"\']+)', head, re.I) \
|
||||
or re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\']og:image', head, re.I)
|
||||
if not m: return ""
|
||||
u = m.group(1).strip()
|
||||
if u.startswith("//"): u = "https:" + u
|
||||
elif u.startswith("/"):
|
||||
from urllib.parse import urlparse
|
||||
pr = urlparse(base); u = f"{pr.scheme}://{pr.netloc}{u}"
|
||||
return u if u.startswith("https://") else ""
|
||||
|
||||
|
||||
def usable_image(url):
|
||||
"""실제로 이미지가 오는지 열어 본다. og:image 가 이미지가 아니거나(빈 content-type)
|
||||
인스타 CDN 처럼 핫링크를 403 으로 막는 경우가 있어 화면에서 빈 칸이 된다."""
|
||||
if not url: return False
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=10, context=ssl_ctx()) as r:
|
||||
return r.status == 200 and (r.headers.get("content-type") or "").startswith("image")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_image_naver(env, p):
|
||||
"""마지막 폴백. Naver 이미지 검색 API.
|
||||
주의: 결과는 제3자 저작물이다. 내부 시연 목적으로만 쓰고, 공개 배포 전에는
|
||||
imageSource 가 'naver' 인 항목을 걷어내거나 라이선스가 있는 사진으로 교체한다.
|
||||
구글 HTML 크롤링 대신 이걸 쓰는 이유는 공식 API 라 차단되지 않고 결과가 일정해서다."""
|
||||
cid, sec = env.get("NAVER_CLIENT_ID"), env.get("NAVER_CLIENT_SECRET")
|
||||
if not (cid and sec): return ""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not ko: return ""
|
||||
# 지역을 붙이면 정확도가 오르지만 결과가 아예 없는 경우가 있다. 넓혀 가며 시도한다.
|
||||
plain = re.sub(r"\s*\(.*\)\s*", "", p.get("title") or "").strip()
|
||||
for q in (f"{ko} 서울", ko, plain):
|
||||
if not q: continue
|
||||
qs = urllib.parse.urlencode({"query": q, "display": "3", "sort": "sim"})
|
||||
req = urllib.request.Request("https://openapi.naver.com/v1/search/image.json?" + qs,
|
||||
headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ssl_ctx()) as r:
|
||||
items = json.loads(r.read().decode()).get("items", [])
|
||||
except Exception:
|
||||
continue
|
||||
for it in items:
|
||||
u = (it.get("link") or "").strip()
|
||||
if u.startswith("https://"): return u
|
||||
return ""
|
||||
|
||||
|
||||
def bucket(it):
|
||||
"""분류코드로 범주를 정한다. None 이면 버린다."""
|
||||
cat3 = (it.get("cat3") or "").strip()
|
||||
@ -118,10 +282,15 @@ def clean(it, lat, lng):
|
||||
# tong.visitkorea.or.kr 은 https 로도 같은 파일을 준다(실측 확인).
|
||||
"image": (lambda u: u.replace("http://", "https://", 1) if u else None)(
|
||||
it.get("firstimage") or it.get("firstimage2") or None),
|
||||
"imageSource": "tourapi" if (it.get("firstimage") or it.get("firstimage2")) else "",
|
||||
"tel": (it.get("tel") or "").strip() or None,
|
||||
"lat": ilat or None, "lng": ilng or None,
|
||||
"distanceM": round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None),
|
||||
"travel": travel(round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None)),
|
||||
# detailCommon2 로 따로 채운다(locationBasedList2 는 설명문을 주지 않는다).
|
||||
"overview": "", "homepage": "",
|
||||
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
|
||||
"rating": None, "reviewCount": None, "openingHours": None,
|
||||
}
|
||||
@ -131,7 +300,7 @@ def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--out", default="src/data/medicalTourism.json")
|
||||
ap.add_argument("--out", default="supporters/src/data/medicalTourism.json")
|
||||
a = ap.parse_args()
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
@ -173,13 +342,59 @@ def main():
|
||||
if c["lat"] is None: nocoord += 1; continue
|
||||
km = haversine_km(lat, lng, c["lat"], c["lng"])
|
||||
if km > a.festival_km: continue
|
||||
c.update(startDate=it.get("eventstartdate"), endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1))
|
||||
sd = it.get("eventstartdate") or ""
|
||||
c.update(startDate=sd, endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1), month=(int(sd[4:6]) if len(sd) == 8 else None),
|
||||
season=SEASON.get(int(sd[4:6]), "") if len(sd) == 8 else "")
|
||||
near.append(c)
|
||||
near.sort(key=lambda x: x["startDate"] or "")
|
||||
out["festivals"] = near[:a.per_type]
|
||||
print(f" festival API {total:>4}건 · 받음 {len(rows):>3} · 좌표없음 {nocoord} · {a.festival_km:.0f}km 이내 {len(near)}")
|
||||
|
||||
# 설명문은 항목마다 detailCommon2 를 한 번씩 부른다. 카드에 이름과 주소만 있으면 고를 수 없다.
|
||||
cache: dict = {}
|
||||
targets = [p for v in out["places"].values() for p in v] + out["festivals"]
|
||||
print(f"\n 설명문 수집 {len(targets)}건", end="", flush=True)
|
||||
for i, p in enumerate(targets):
|
||||
if not p.get("id"): continue
|
||||
ov, hp = fetch_overview(env, p["id"], cache)
|
||||
p["overview"] = ov[:600]
|
||||
p["homepage"] = hp
|
||||
if not p.get("image"):
|
||||
# 1) 영문 detailImage2 → 2) 국문 서비스 → 3) 업체 공식 홈페이지 og:image
|
||||
# 1) 영문 detailImage2 → 2) 국문 서비스 → 3) 업체 홈페이지 og:image → 4) Naver 이미지 검색
|
||||
src = ""
|
||||
img = fetch_image(env, p["id"], cache)
|
||||
if img: src = "tourapi"
|
||||
if not img:
|
||||
img = fetch_image_kor(env, p); src = "tourapi-kor" if img else src
|
||||
if not img:
|
||||
cand = fetch_image_og(p.get("homepage") or "")
|
||||
if cand and usable_image(cand): img, src = cand, "homepage"
|
||||
if not img:
|
||||
img = fetch_image_naver(env, p); src = "naver" if img else src
|
||||
p["image"] = img or None
|
||||
p["imageSource"] = src
|
||||
if i % 20 == 0: print(".", end="", flush=True)
|
||||
time.sleep(0.12)
|
||||
got = sum(1 for p in targets if p["overview"])
|
||||
pic = sum(1 for p in targets if p.get("image"))
|
||||
print(f" 완료 · 설명문 {got}/{len(targets)} · 사진 {pic}/{len(targets)}")
|
||||
|
||||
# 도보 시간 구간별 건수. 화면의 필터 칩이 이 수를 쓴다.
|
||||
allp = [p for v in out["places"].values() for p in v]
|
||||
# 누적 기준이다. "15분 이내"는 5분 이내도 포함한다. 사람이 고르는 방식에 맞춘다.
|
||||
def walk_upto(mins):
|
||||
return sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"
|
||||
and p["travel"]["minutes"] <= mins)
|
||||
out["filters"] = {
|
||||
"total": len(allp),
|
||||
"walk5": walk_upto(5),
|
||||
"walk15": walk_upto(15),
|
||||
"walkAll": sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"),
|
||||
"drive": sum(1 for p in allp if not p.get("travel") or p["travel"]["mode"] != "walk"),
|
||||
}
|
||||
|
||||
out["meta"] = {
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -7,7 +7,6 @@ import { AEO_GEO_RUBRIC_V2 } from '../data/aeoGeoRubricV2';
|
||||
import { scoreDiscoveryV2 } from '../lib/discoveryScoreV2';
|
||||
import { AeoGeoV2Panel } from '../components/discovery/AeoGeoV2Panel';
|
||||
import { ClinicInputsPanel } from '../components/discovery/ClinicInputsPanel';
|
||||
import { MedicalTourismPanel } from '../components/discovery/MedicalTourismPanel';
|
||||
import { DISCOVERY_RESULTS } from '../data/discoveryResults';
|
||||
import { scoreDiscovery, levelToSeverity } from '../lib/discoveryScore';
|
||||
import type {
|
||||
@ -359,9 +358,6 @@ export default function DiscoveryReportPage() {
|
||||
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
|
||||
<ClinicInputsPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 5c. 의료관광 연결 (dark) ── */}
|
||||
<MedicalTourismPanel clinicId={result.id} />
|
||||
|
||||
{/* ── 6. 채점 기준표 (light) ── */}
|
||||
<SectionWrapper
|
||||
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 스키마는 이 파일에서만 생성한다. 홈페이지 표기와 한 글자까지 맞춘다.",
|
||||
"name": "뷰성형외과의원",
|
||||
"shortName": "뷰성형외과",
|
||||
"nameEn": "View Plastic Surgery",
|
||||
"shortNameEn": "View Plastic Surgery",
|
||||
"shortNameEnSource": "https://www.viewplasticsurgery.com",
|
||||
"kind": "성형외과",
|
||||
"areaLabel": "서울 강남 신논현역",
|
||||
"founded": "2005",
|
||||
@ -12,6 +15,9 @@
|
||||
"locality": "강남구",
|
||||
"region": "서울특별시",
|
||||
"postalNote": "논현동 201-16",
|
||||
"fullEn": "107 Bongeunsa-ro, Gangnam-gu, Seoul",
|
||||
"postalCode": "06120",
|
||||
"fullEnSource": "https://api.visitkorea.or.kr EngService2 contentid 3364779 (한국관광공사 영문 관광정보 등재)",
|
||||
"navigation": "서울시 강남구 봉은사로 1길 4 (논현동 201-14, 주차장 진입 기준)",
|
||||
"source": "https://www.viewclinic.com/viewis/direction/"
|
||||
},
|
||||
|
||||
2852
supporters/src/data/medicalTourism.json
Normal file
2852
supporters/src/data/medicalTourism.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -11,8 +11,36 @@ interface Props {
|
||||
ogImage?: string;
|
||||
type?: 'website' | 'article';
|
||||
noindex?: boolean;
|
||||
/** 페이지 언어. 외국인 환자용 영문 페이지(/en/*)에서 'en' 을 넘긴다. 기본은 한국어다. */
|
||||
lang?: 'ko' | 'en';
|
||||
}
|
||||
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props;
|
||||
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko' } = Astro.props;
|
||||
const isEn = lang === 'en';
|
||||
// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조.
|
||||
const T = isEn
|
||||
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
|
||||
// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다.
|
||||
// 영문 페이지가 늘어나면 이 표에 줄만 추가한다.
|
||||
const PAIRS: Record<string, string> = { '/visit': '/en/stay' };
|
||||
const KO_ENTRY = '/';
|
||||
const EN_ENTRY = '/en/stay';
|
||||
const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/');
|
||||
const koPair = Object.entries(PAIRS).find(([, en]) => en === path)?.[0];
|
||||
const otherHref = isEn ? (koPair ?? KO_ENTRY) : (PAIRS[path] ?? EN_ENTRY);
|
||||
// 짝이 없으면 같은 글의 번역본으로 가는 것이 아니므로 그렇게 말한다.
|
||||
const otherExact = isEn ? Boolean(koPair) : Boolean(PAIRS[path]);
|
||||
// 영문 페이지의 예약 버튼은 병원의 외국인용 영문 사이트로 보낸다(factSheet.urlEn).
|
||||
// 한국어 예약 페이지로 보내면 영어로 온 사람이 한국어 화면을 만난다.
|
||||
const ctaHref = isEn
|
||||
? ((f as Record<string, any>).reservationUrlEn || (f as Record<string, any>).urlEn || reservationUrl || '/en/stay')
|
||||
: (reservationUrl || '/visit');
|
||||
// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 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 cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
|
||||
const canonical = (new URL(cleanPath, Astro.site).toString().replace(/\/$/, '') || site);
|
||||
@ -21,7 +49,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;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@ -35,7 +63,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<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="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
@ -46,31 +74,32 @@ 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);`} />}
|
||||
</head>
|
||||
<body>
|
||||
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>}
|
||||
{!INDEXABLE && <div class="sample-banner">{T.sample}</div>}
|
||||
<header class="site-header">
|
||||
<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">
|
||||
<nav class="nav">
|
||||
<a href="/posts">상담 전 질문</a>
|
||||
<a href="/videos">의료진 영상</a>
|
||||
<a href="/newsroom">뉴스룸</a>
|
||||
<a href="/visit">방문 안내</a>
|
||||
<a href="/about">매체 소개</a>
|
||||
{T.nav.map(([href, label]) => <a href={href}>{label}</a>)}
|
||||
</nav>
|
||||
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a>
|
||||
<div class="langswitch" role="group" aria-label={isEn ? 'Language' : '언어'}>
|
||||
<a href={isEn ? otherHref : path} hreflang="ko" aria-current={!isEn ? 'true' : undefined} class={!isEn ? 'on' : ''}>KO</a>
|
||||
<a href={isEn ? path : otherHref} hreflang="en" aria-current={isEn ? 'true' : undefined} class={isEn ? 'on' : ''}
|
||||
title={isEn || otherExact ? undefined : 'English pages are limited. This goes to the English section.'}>EN</a>
|
||||
</div>
|
||||
<a class="cta" href={ctaHref} rel="noopener">{T.cta}</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && (
|
||||
<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>
|
||||
)}
|
||||
<main>
|
||||
<slot />
|
||||
</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>
|
||||
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(); } });
|
||||
@ -83,8 +112,9 @@ document.addEventListener('click',function(e){var a=e.target.closest&&e.target.c
|
||||
<div class="wrap-wide cols">
|
||||
<div>
|
||||
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
|
||||
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p>
|
||||
<p>{fact.sideEffectNotice}</p>
|
||||
{isEn && !sponsorEn && <p class="notice-pending">Korean original below. The English wording of these notices is pending clinic approval.</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>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
403
supporters/src/pages/en/stay.astro
Normal file
403
supporters/src/pages/en/stay.astro
Normal file
@ -0,0 +1,403 @@
|
||||
---
|
||||
// 외국인 환자용 체류 안내. 세 부분이다.
|
||||
// 1. Around the clinic — 도보·차 소요 시간으로 거른 주변 장소
|
||||
// 2. Festivals — 계절로 묶은 행사
|
||||
// 3. Suggested days — 시간표와 지도가 붙은 동선
|
||||
//
|
||||
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
|
||||
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
|
||||
//
|
||||
// 만들지 않는 것
|
||||
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
|
||||
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
|
||||
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
|
||||
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fact, site as S, SITE_NAME, clinicSchema, 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;
|
||||
|
||||
type Place = {
|
||||
id: string; title: string; address: string; image: string | null; overview: string;
|
||||
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
|
||||
travel: { mode: string; minutes: number; label: string } | null;
|
||||
};
|
||||
|
||||
const GROUPS: Array<{ key: string; label: string; note: string }> = [
|
||||
{ key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' },
|
||||
{ key: 'stay', label: 'Hotels', note: 'Places to sleep' },
|
||||
{ key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' },
|
||||
{ key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' },
|
||||
{ key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' },
|
||||
{ key: 'shopping', label: 'Shopping', note: 'Stores and markets' },
|
||||
];
|
||||
|
||||
const SEASONS = [
|
||||
{ key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' },
|
||||
{ key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' },
|
||||
];
|
||||
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
|
||||
|
||||
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
|
||||
const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(q + ' Seoul')}`;
|
||||
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
|
||||
const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : '');
|
||||
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
|
||||
const shortName = (t: string) => {
|
||||
const m = t.match(/\(([^)]+)\)\s*$/);
|
||||
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
|
||||
};
|
||||
const firstSentence = (s: string, n = 150) => {
|
||||
if (!s) return '';
|
||||
const cut = s.slice(0, n);
|
||||
const stop = cut.lastIndexOf('. ');
|
||||
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
|
||||
};
|
||||
|
||||
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
|
||||
const oLat = T.meta.origin.lat, oLng = T.meta.origin.lng;
|
||||
const pick = (key: string, maxMin: number, n: number): Place[] =>
|
||||
((T.places[key] ?? []) as Place[])
|
||||
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
|
||||
.slice(0, n);
|
||||
const pickAny = (key: string, n: number): Place[] => ((T.places[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
|
||||
|
||||
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
|
||||
let t = startMin;
|
||||
const out: any[] = [];
|
||||
stops.forEach((s, i) => {
|
||||
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
|
||||
t += move;
|
||||
const from = t;
|
||||
t += s.stay;
|
||||
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
|
||||
});
|
||||
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
|
||||
}
|
||||
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
|
||||
blurb: 'Everything within a short walk. You do not need a taxi.',
|
||||
route: buildRoute(15 * 60, [
|
||||
clinicStop(clinic, 40, 'Your appointment ends here.'),
|
||||
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
|
||||
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
|
||||
blurb: 'Still close, but out of the block.',
|
||||
route: buildRoute(13 * 60, [
|
||||
clinicStop(clinic, 20, 'Start from the clinic.'),
|
||||
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'city', title: 'When you can travel', who: 'Later in your stay',
|
||||
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
|
||||
route: buildRoute(10 * 60, [
|
||||
clinicStop(clinic, 10, 'Set off from the clinic.'),
|
||||
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
|
||||
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
].filter((r) => r.route.stops.length > 1);
|
||||
|
||||
/* 좌표를 그대로 그린 미니 지도. 외부 지도 라이브러리를 부르지 않는다. */
|
||||
function miniMap(stops: any[]) {
|
||||
const W = 640, H = 200, PAD = 26;
|
||||
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
|
||||
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
|
||||
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
|
||||
const sx = (ln: number) => (ln1 - ln0 < 1e-6 ? W / 2 : PAD + ((ln - ln0) / (ln1 - ln0)) * (W - PAD * 2));
|
||||
const sy = (la: number) => (la1 - la0 < 1e-6 ? H / 2 : H - PAD - ((la - la0) / (la1 - la0)) * (H - PAD * 2));
|
||||
return stops.map((s, i) => ({ x: +sx(s.lng).toFixed(1), y: +sy(s.lat).toFixed(1), n: i + 1, label: s.label }));
|
||||
}
|
||||
|
||||
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
|
||||
((T.places[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
|
||||
|
||||
const ld = [
|
||||
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `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} — food, rest, sights and festivals`}
|
||||
description={`For international patients at ${clinic} in Gangnam, Seoul: where to eat and rest, what to see, which festivals are on, and how to shape your days. Distances and walking times from the clinic. Source: Korea Tourism Organization.`}
|
||||
jsonLd={ld}
|
||||
>
|
||||
<article class="wrap-wide stay">
|
||||
<header class="article-head">
|
||||
<div class="eyebrow">For international patients</div>
|
||||
<h1 class="serif">Your Stay in Seoul</h1>
|
||||
<p class="lede">
|
||||
<strong>Around {clinic}.</strong> Everything below is measured from the clinic at {f.address?.fullEn || f.address?.full}.
|
||||
Names, photographs and descriptions come from the Korea Tourism Organization.
|
||||
For the clinic's own hours and phone, see <a href="/clinic" hreflang="ko">Clinic information<span class="lang-tag">KO</span></a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
|
||||
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
|
||||
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
|
||||
<aside id="weather" class="wx" hidden>
|
||||
<div class="wx-now">
|
||||
<span class="wx-temp"><span id="wx-t">–</span><span class="wx-unit">°C</span></span>
|
||||
<div>
|
||||
<div class="wx-cond" id="wx-c">Loading</div>
|
||||
<div class="wx-meta" id="wx-m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="wx-note" id="wx-n"></p>
|
||||
</aside>
|
||||
|
||||
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
|
||||
<nav class="jump" aria-label="Jump to a section">
|
||||
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
|
||||
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
|
||||
))}
|
||||
<a href="#festivals">Festivals<b>{T.festivals.length}</b></a>
|
||||
<a href="#days">Days<b>{ROUTES.length}</b></a>
|
||||
</nav>
|
||||
|
||||
<!-- ── 1. 주변 ── -->
|
||||
<section id="around">
|
||||
<div class="sec-head">
|
||||
<h2>Around the clinic</h2>
|
||||
<span class="upd">Updated {T.meta.fetchedAt.slice(0, 10)}</span>
|
||||
</div>
|
||||
<p class="sub">{T.filters.total} places, sorted by how long they take to reach on foot.</p>
|
||||
|
||||
<div class="chips" id="walkchips" role="group" aria-label="Filter by travel time">
|
||||
<button class="chip on" data-max="all">All <b>{T.filters.total}</b></button>
|
||||
<button class="chip" data-max="5">Within 5 min walk <b>{T.filters.walk5}</b></button>
|
||||
<button class="chip" data-max="15">Within 15 min walk <b>{T.filters.walk15}</b></button>
|
||||
<button class="chip" data-max="walk">Walking distance <b>{T.filters.walkAll}</b></button>
|
||||
<button class="chip" data-max="drive">By car <b>{T.filters.drive}</b></button>
|
||||
</div>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const items = allPlaces.filter((p) => p.cat === g.key);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
|
||||
<h3>{g.label} <small>{g.note}</small></h3>
|
||||
<div class="cards">
|
||||
{items.map((p) => (
|
||||
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
|
||||
<div class="thumb">
|
||||
{p.image ? <img src={p.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(p.title)}</span>}
|
||||
{p.travel && <span class="badge">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
|
||||
</div>
|
||||
<div class="body">
|
||||
<h4>{p.title}</h4>
|
||||
<p class="desc">{firstSentence(p.overview) || 'No description provided by the tourism data.'}</p>
|
||||
<p class="addr">{p.address}</p>
|
||||
{p.homepage
|
||||
? <a class="go" href={p.homepage} rel="noopener nofollow">Official site ↗</a>
|
||||
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p class="src">Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. 축제 ── -->
|
||||
<section id="festivals">
|
||||
<div class="sec-head"><h2>Festivals and events</h2></div>
|
||||
<p class="sub">{T.festivals.length} events within {T.meta.festivalRadiusKm} km, grouped by season. Right now it is <strong>{nowSeason}</strong>.</p>
|
||||
<div class="chips" role="group" aria-label="Filter by season">
|
||||
<button class="chip on" data-season="all">All <b>{T.festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = T.festivals.filter((e: any) => e.season === s.key).length;
|
||||
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
|
||||
})}
|
||||
</div>
|
||||
<div class="cards fest">
|
||||
{T.festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h4>{e.title}</h4>
|
||||
<p class="when">{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}</p>
|
||||
<p class="desc">{firstSentence(e.overview) || 'No description provided by the tourism data.'}</p>
|
||||
<p class="addr">{e.address}</p>
|
||||
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<p class="src">Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. 동선 ── -->
|
||||
<section id="days">
|
||||
<div class="sec-head"><h2>Shaping your days</h2></div>
|
||||
<p class="sub">
|
||||
Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule.
|
||||
</p>
|
||||
{Sx.recoveryStagesEn
|
||||
? <p><strong>What {clinic} advises.</strong> {Sx.recoveryStagesEn}</p>
|
||||
: <p class="pending-note">Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.</p>}
|
||||
|
||||
{ROUTES.map((r) => {
|
||||
const pins = miniMap(r.route.stops);
|
||||
return (
|
||||
<div class="route">
|
||||
<div class="route-head">
|
||||
<div>
|
||||
<h3>{r.title}</h3>
|
||||
<p class="who">{r.who}</p>
|
||||
</div>
|
||||
<div class="clock">{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
|
||||
</div>
|
||||
<p class="blurb">{r.blurb}</p>
|
||||
<svg class="map" viewBox="0 0 640 200" role="img" aria-label={`Route map with ${pins.length} stops`}>
|
||||
<rect width="640" height="200" rx="12" fill="#F4F6FB" />
|
||||
<polyline points={pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#C5CBF5" stroke-width="2" stroke-dasharray="5 4" />
|
||||
{pins.map((p) => (
|
||||
<g><circle cx={p.x} cy={p.y} r="11" fill="#0A1128" /><text x={p.x} y={p.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff">{p.n}</text></g>
|
||||
))}
|
||||
</svg>
|
||||
<p class="maphint">Positions are drawn from coordinates, to scale with each other. Not a street map.</p>
|
||||
<ol class="timeline">
|
||||
{r.route.stops.map((s: any, i: number) => (
|
||||
<li>
|
||||
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
|
||||
<div class="stop">
|
||||
<span class="n">{i + 1}</span>
|
||||
<div>
|
||||
<div class="stop-t">{s.from}–{s.to}</div>
|
||||
<h4>{s.label}</h4>
|
||||
<p>{s.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section id="limits">
|
||||
<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.</li>
|
||||
<li>No claim that any place suits your recovery. That is for {clinic} to say.</li>
|
||||
<li>English tourism listings around Gangnam are thin. Only {T.filters.walk5} places sit within a five minute walk, and {T.filters.drive} of {T.filters.total} need a car.</li>
|
||||
<li>We are not a travel agency, take no booking and no commission.</li>
|
||||
</ul>
|
||||
<p class="botnote">
|
||||
{has(f.urlEn) && <a class="btn primary" href={f.urlEn} rel="noopener" hreflang="en">Book a consultation in English</a>}
|
||||
</p>
|
||||
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<script is:inline define:vars={{ WX_LAT: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
|
||||
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
|
||||
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
|
||||
(function () {
|
||||
var chips = document.querySelectorAll('#walkchips .chip');
|
||||
chips.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.max;
|
||||
document.querySelectorAll('#around .card').forEach(function (card) {
|
||||
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
|
||||
var show = want === 'all' ? true
|
||||
: want === 'walk' ? mode === 'walk'
|
||||
: want === 'drive' ? mode !== 'walk'
|
||||
: mode === 'walk' && w <= parseInt(want, 10);
|
||||
card.hidden = !show;
|
||||
});
|
||||
document.querySelectorAll('#around .grp').forEach(function (g) {
|
||||
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
|
||||
});
|
||||
});
|
||||
});
|
||||
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
|
||||
var links = [].slice.call(document.querySelectorAll('.jump a'));
|
||||
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
|
||||
if ('IntersectionObserver' in window) {
|
||||
var seen = {};
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
|
||||
var best = null, top = 0;
|
||||
targets.forEach(function (t) {
|
||||
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
|
||||
});
|
||||
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
|
||||
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
|
||||
targets.forEach(function (t) { if (t) io.observe(t); });
|
||||
}
|
||||
|
||||
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
|
||||
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
|
||||
(function () {
|
||||
var WMO = {
|
||||
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
||||
45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
|
||||
56: 'Freezing drizzle', 57: 'Freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
|
||||
66: 'Freezing rain', 67: 'Freezing rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
|
||||
77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Violent showers',
|
||||
85: 'Snow showers', 86: 'Snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with hail'
|
||||
};
|
||||
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
|
||||
var LAT = WX_LAT, LNG = WX_LNG;
|
||||
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
|
||||
'¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (j) {
|
||||
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
|
||||
var code = c.weather_code;
|
||||
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
|
||||
document.getElementById('wx-c').textContent = WMO[code] || 'Current conditions';
|
||||
document.getElementById('wx-m').textContent =
|
||||
'Feels like ' + Math.round(c.apparent_temperature) + '°C · wind ' +
|
||||
Math.round(c.wind_speed_10m) + ' km/h · observed ' + String(c.time).replace('T', ' ');
|
||||
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
|
||||
? 'Wet outside. The Culture and Shopping listings below are indoors.'
|
||||
: (c.temperature_2m >= 30
|
||||
? 'Hot today. The Culture and Shopping listings below are indoors.'
|
||||
: (c.temperature_2m <= 0
|
||||
? 'Below freezing. The Culture and Shopping listings below are indoors.'
|
||||
: 'Dry at the moment. The walking times below are on foot from the clinic.'));
|
||||
document.getElementById('weather').hidden = false;
|
||||
})
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
{ loc: `${base}/posts`, lastmod: today },
|
||||
{ loc: `${base}/clinic`, lastmod: today },
|
||||
{ loc: `${base}/visit`, lastmod: today },
|
||||
{ loc: `${base}/en/stay`, lastmod: today },
|
||||
{ loc: `${base}/newsroom`, lastmod: today },
|
||||
{ loc: `${base}/videos`, 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 ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
|
||||
</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 class="disclosure">{fact.sideEffectNotice}</p>
|
||||
</article>
|
||||
|
||||
@ -264,3 +264,202 @@ td { color: var(--slate-700); }
|
||||
.totop.show { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.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; } }
|
||||
|
||||
|
||||
/* 값이 아직 없다는 것을 숨기지 않고 드러내는 문단. 병원 확인 대기 항목에 쓴다. */
|
||||
.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; }
|
||||
|
||||
|
||||
/* 영문 화면에서 한국어 페이지로 가는 링크에 붙인다. 눌러보고 알게 하지 않는다. */
|
||||
.lang-tag {
|
||||
display: inline-block;
|
||||
margin-left: 0.28em;
|
||||
padding: 0 0.3em;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: 0.18em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 언어 전환. 머리말 오른쪽에 하나만 둔다. 항목마다 표시하지 않는다. */
|
||||
.langswitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line, #E2E8F0);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-right: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.langswitch a {
|
||||
padding: 0.26rem 0.62rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--slate-500, #64748B);
|
||||
text-decoration: none;
|
||||
line-height: 1.45;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.langswitch a:hover { background: var(--near, #F4F6FB); color: var(--primary-900, #0A1128); }
|
||||
.langswitch a.on {
|
||||
background: var(--primary-900, #0A1128);
|
||||
color: #fff;
|
||||
}
|
||||
.langswitch a.on:hover { background: var(--primary-900, #0A1128); color: #fff; }
|
||||
|
||||
/* ── /en/stay ── */
|
||||
.stay { padding-bottom: 4rem; }
|
||||
.stay .lede { color: var(--slate-600, #475569); margin-top: 0.6rem; }
|
||||
.stay section { margin-top: 3.4rem; }
|
||||
.stay .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.stay .sec-head h2 { margin: 0; }
|
||||
.stay .upd { font-size: 0.8rem; color: var(--slate-500, #64748B); }
|
||||
.stay .sub { color: var(--slate-600, #475569); margin: 0.4rem 0 1rem; }
|
||||
.stay .src { font-size: 0.84rem; color: var(--slate-500, #64748B); margin-top: 1rem; }
|
||||
.stay .chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.2rem 0 1.6rem; }
|
||||
.stay .chip {
|
||||
border: 1px solid var(--line, #E2E8F0); background: #fff; border-radius: 999px;
|
||||
padding: 0.36rem 0.85rem; font: inherit; font-size: 0.86rem; color: var(--slate-600, #475569);
|
||||
cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.stay .chip b { font-weight: 700; opacity: 0.55; margin-left: 0.25em; }
|
||||
.stay .chip:hover { border-color: var(--primary-900, #0A1128); }
|
||||
.stay .chip.on { background: var(--primary-900, #0A1128); border-color: var(--primary-900, #0A1128); color: #fff; }
|
||||
.stay .chip.on b { opacity: 0.7; }
|
||||
.stay .grp { margin-bottom: 2.2rem; }
|
||||
.stay .grp h3 { margin: 0 0 0.9rem; }
|
||||
.stay .grp h3 small { font-weight: 400; font-size: 0.82rem; color: var(--slate-500, #64748B); margin-left: 0.45em; }
|
||||
.stay .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 1rem; }
|
||||
.stay .card {
|
||||
background: #fff; border: 1px solid var(--line, #E2E8F0); border-radius: 14px;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.stay .card .thumb { position: relative; height: 148px; background: #EEF1F6; display: flex; align-items: center; justify-content: center; }
|
||||
.stay .card .thumb img { width: 100%; height: 148px; object-fit: cover; display: block; }
|
||||
.stay .card .nophoto {
|
||||
font-size: 1.02rem; font-weight: 700; color: var(--slate-500, #64748B);
|
||||
padding: 0 1rem; text-align: center; line-height: 1.35; word-break: keep-all;
|
||||
}
|
||||
.stay .card .badge {
|
||||
position: absolute; left: 0.6rem; bottom: 0.6rem;
|
||||
background: rgba(10, 17, 40, 0.82); color: #fff; border-radius: 6px;
|
||||
padding: 0.18rem 0.45rem; font-size: 0.74rem; font-weight: 600;
|
||||
}
|
||||
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
|
||||
.stay .card .badge.mono { letter-spacing: 0.06em; }
|
||||
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
|
||||
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }
|
||||
.stay .card .desc { font-size: 0.86rem; color: var(--slate-600, #475569); margin: 0 0 0.5rem; }
|
||||
.stay .card .addr { font-size: 0.78rem; color: var(--slate-500, #64748B); margin: 0 0 0.6rem; }
|
||||
.stay .card .go { font-size: 0.82rem; margin-top: auto; }
|
||||
.stay .route { border: 1px solid var(--line, #E2E8F0); border-radius: 16px; padding: 1.3rem 1.4rem; margin-bottom: 1.4rem; background: #fff; }
|
||||
.stay .route-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; }
|
||||
.stay .route-head h3 { margin: 0; }
|
||||
.stay .route .who { font-size: 0.84rem; color: var(--slate-500, #64748B); margin: 0.15rem 0 0; }
|
||||
.stay .route .clock { font-size: 0.86rem; font-weight: 600; color: var(--primary-900, #0A1128); white-space: nowrap; }
|
||||
.stay .route .blurb { color: var(--slate-600, #475569); margin: 0.7rem 0 1rem; }
|
||||
.stay .route .map { width: 100%; height: auto; display: block; border-radius: 12px; }
|
||||
.stay .route .maphint { font-size: 0.76rem; color: var(--slate-500, #64748B); margin: 0.4rem 0 1rem; }
|
||||
.stay .timeline { list-style: none; padding: 0; margin: 0; }
|
||||
.stay .timeline .move { font-size: 0.8rem; color: var(--slate-500, #64748B); margin: 0.35rem 0 0.35rem 1.05rem; }
|
||||
.stay .timeline .stop { display: flex; gap: 0.8rem; align-items: flex-start; }
|
||||
.stay .timeline .n {
|
||||
flex: none; width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--primary-900, #0A1128); color: #fff;
|
||||
font-size: 0.8rem; font-weight: 700; display: grid; place-items: center; margin-top: 0.1rem;
|
||||
}
|
||||
.stay .timeline .stop-t { font-size: 0.8rem; color: var(--slate-500, #64748B); }
|
||||
.stay .timeline h4 { margin: 0.1rem 0 0.2rem; font-size: 0.98rem; }
|
||||
.stay .timeline p { margin: 0; font-size: 0.86rem; color: var(--slate-600, #475569); }
|
||||
.stay .botnote { margin-top: 1.6rem; }
|
||||
@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 섹션 점프. 머리말 아래에 붙어 스크롤을 따라온다. 헤더(68px) 아래에 걸리게 top 을 맞춘다. */
|
||||
.stay .jump {
|
||||
position: sticky; top: 68px; z-index: 9;
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem;
|
||||
margin: 1.4rem 0 2.4rem;
|
||||
padding: 0.62rem 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
backdrop-filter: blur(16px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 28px rgba(10, 17, 40, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.stay .jump a {
|
||||
display: inline-flex; align-items: center; gap: 0.42rem;
|
||||
padding: 0.46rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.86rem; font-weight: 600; letter-spacing: -0.005em;
|
||||
color: var(--primary-900, #0A1128);
|
||||
text-decoration: none; white-space: nowrap;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line, #E2E8F0);
|
||||
box-shadow: 0 1px 2px rgba(10, 17, 40, 0.06);
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s, border-color 0.14s, color 0.14s;
|
||||
}
|
||||
.stay .jump a:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
border-color: #C5CBF5;
|
||||
box-shadow: 0 4px 12px rgba(10, 17, 40, 0.12);
|
||||
}
|
||||
.stay .jump a:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(10, 17, 40, 0.08); }
|
||||
.stay .jump a b {
|
||||
font-weight: 700; font-size: 0.72rem; line-height: 1;
|
||||
padding: 0.2rem 0.4rem; border-radius: 999px;
|
||||
background: #EFF0FF; color: #3A3F7C;
|
||||
}
|
||||
/* 지금 보고 있는 섹션 */
|
||||
.stay .jump a.on {
|
||||
background: linear-gradient(to right, #4F1DA1, #021341);
|
||||
border-color: transparent; color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(79, 29, 161, 0.32);
|
||||
}
|
||||
.stay .jump a.on b { background: rgba(255, 255, 255, 0.22); color: #fff; }
|
||||
/* 점프로 이동했을 때 제목이 스티키 바에 가리지 않게 */
|
||||
.stay section, .stay .grp { scroll-margin-top: 152px; }
|
||||
@media (max-width: 640px) {
|
||||
.stay .jump { top: 60px; gap: 0.35rem; padding: 0.5rem; border-radius: 14px; }
|
||||
.stay .jump a { padding: 0.4rem 0.72rem; font-size: 0.8rem; }
|
||||
}
|
||||
|
||||
/* 필터 칩도 눌리는 것처럼 보이게 */
|
||||
.stay .chip {
|
||||
box-shadow: 0 1px 2px rgba(10, 17, 40, 0.05);
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.stay .chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(10, 17, 40, 0.1); }
|
||||
.stay .chip:active { transform: translateY(0); }
|
||||
.stay .chip.on { box-shadow: 0 4px 14px rgba(10, 17, 40, 0.22); }
|
||||
|
||||
/* 오늘 날씨. 값을 못 받으면 hidden 이라 아예 그려지지 않는다. */
|
||||
.stay .wx {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1.2rem; flex-wrap: wrap;
|
||||
margin-top: 1.6rem; padding: 1rem 1.2rem;
|
||||
background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.stay .wx-now { display: flex; align-items: center; gap: 0.9rem; }
|
||||
.stay .wx-temp { font-family: 'Playfair Display', serif; font-size: 2.5rem; font-weight: 700; line-height: 1; color: var(--primary-900, #0A1128); }
|
||||
.stay .wx-unit { font-size: 1.1rem; vertical-align: 0.9rem; margin-left: 0.06em; opacity: 0.6; }
|
||||
.stay .wx-cond { font-weight: 700; color: var(--primary-900, #0A1128); }
|
||||
.stay .wx-meta { font-size: 0.8rem; color: var(--slate-600, #475569); margin-top: 0.15rem; }
|
||||
.stay .wx-note { margin: 0; font-size: 0.88rem; color: var(--slate-700, #334155); max-width: 30rem; }
|
||||
|
||||
@ -2,11 +2,14 @@
|
||||
"_comment": "병원 사실의 단일 원본. 근거 수집기(evidence/<clinic>/)가 채우고, 비어 있는 값은 화면에 '확인 대기'로 표시된다. 홈페이지 표기와 한 글자까지 맞춘다. 지어내지 않는다.",
|
||||
"name": "",
|
||||
"shortName": "",
|
||||
"nameEn": "",
|
||||
"shortNameEn": "",
|
||||
"shortNameEnSource": "",
|
||||
"kind": "",
|
||||
"areaLabel": "",
|
||||
"founded": "",
|
||||
"representative": "",
|
||||
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" },
|
||||
"address": { "full": "", "fullEn": "", "postalCode": "", "fullEnSource": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" },
|
||||
"phone": "",
|
||||
"fax": "",
|
||||
"kakao": "",
|
||||
|
||||
33
templates/supporters-astro/src/data/medicalTourism.json
Normal file
33
templates/supporters-astro/src/data/medicalTourism.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"_comment": "의료관광 연결 데이터. scripts/fetch_medical_tourism.py 가 병원 좌표로 채운다. 비어 있으면 /en/stay 는 목록 없음으로 표시된다.",
|
||||
"places": {
|
||||
"stay": [],
|
||||
"restaurant": [],
|
||||
"wellness": [],
|
||||
"attraction": [],
|
||||
"culture": [],
|
||||
"shopping": []
|
||||
},
|
||||
"festivals": [],
|
||||
"filters": {
|
||||
"total": 0,
|
||||
"walk5": 0,
|
||||
"walk15": 0,
|
||||
"walkAll": 0,
|
||||
"drive": 0
|
||||
},
|
||||
"meta": {
|
||||
"source": "",
|
||||
"fetchedAt": "",
|
||||
"origin": {
|
||||
"lat": 0,
|
||||
"lng": 0,
|
||||
"label": "",
|
||||
"coordSource": ""
|
||||
},
|
||||
"radiusByType": {},
|
||||
"festivalRadiusKm": 0,
|
||||
"counts": {},
|
||||
"limits": []
|
||||
}
|
||||
}
|
||||
@ -11,15 +11,45 @@ interface Props {
|
||||
ogImage?: string;
|
||||
type?: 'website' | 'article';
|
||||
noindex?: boolean;
|
||||
/** 페이지 언어. 외국인 환자용 영문 페이지(/en/*)에서 'en' 을 넘긴다. 기본은 한국어다. */
|
||||
lang?: 'ko' | 'en';
|
||||
}
|
||||
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false } = Astro.props;
|
||||
const { title, description, jsonLd, ogImage = S.buildingImage?.src || '/favicon.svg', type = 'website', noindex = false, lang = 'ko' } = Astro.props;
|
||||
const isEn = lang === 'en';
|
||||
// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조.
|
||||
const T = isEn
|
||||
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
|
||||
// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다.
|
||||
// 영문 페이지가 늘어나면 이 표에 줄만 추가한다.
|
||||
const PAIRS: Record<string, string> = { '/visit': '/en/stay' };
|
||||
const KO_ENTRY = '/';
|
||||
const EN_ENTRY = '/en/stay';
|
||||
const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/');
|
||||
const koPair = Object.entries(PAIRS).find(([, en]) => en === path)?.[0];
|
||||
const otherHref = isEn ? (koPair ?? KO_ENTRY) : (PAIRS[path] ?? EN_ENTRY);
|
||||
// 짝이 없으면 같은 글의 번역본으로 가는 것이 아니므로 그렇게 말한다.
|
||||
const otherExact = isEn ? Boolean(koPair) : Boolean(PAIRS[path]);
|
||||
// 영문 페이지의 예약 버튼은 병원의 외국인용 영문 사이트로 보낸다(factSheet.urlEn).
|
||||
// 한국어 예약 페이지로 보내면 영어로 온 사람이 한국어 화면을 만난다.
|
||||
const ctaHref = isEn
|
||||
? ((f as Record<string, any>).reservationUrlEn || (f as Record<string, any>).urlEn || reservationUrl || '/en/stay')
|
||||
: (reservationUrl || '/visit');
|
||||
// 의료광고 고지는 규정 대상이라 임의로 번역하지 않는다(의료법 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 cleanPath = Astro.url.pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
|
||||
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;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<html lang={lang}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@ -33,39 +63,43 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:url" content={canonical} />
|
||||
<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="preconnect" href="https://fonts.googleapis.com" />
|
||||
<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" />
|
||||
{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>
|
||||
<body>
|
||||
{!INDEXABLE && <div class="sample-banner">샘플 사이트입니다. 정식 공개 전입니다.</div>}
|
||||
{!INDEXABLE && <div class="sample-banner">{T.sample}</div>}
|
||||
<header class="site-header">
|
||||
<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">
|
||||
<nav class="nav">
|
||||
<a href="/posts">상담 전 질문</a>
|
||||
<a href="/videos">의료진 영상</a>
|
||||
<a href="/newsroom">뉴스룸</a>
|
||||
<a href="/visit">방문 안내</a>
|
||||
<a href="/about">매체 소개</a>
|
||||
{T.nav.map(([href, label]) => <a href={href}>{label}</a>)}
|
||||
</nav>
|
||||
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a>
|
||||
<div class="langswitch" role="group" aria-label={isEn ? 'Language' : '언어'}>
|
||||
<a href={isEn ? otherHref : path} hreflang="ko" aria-current={!isEn ? 'true' : undefined} class={!isEn ? 'on' : ''}>KO</a>
|
||||
<a href={isEn ? path : otherHref} hreflang="en" aria-current={isEn ? 'true' : undefined} class={isEn ? 'on' : ''}
|
||||
title={isEn || otherExact ? undefined : 'English pages are limited. This goes to the English section.'}>EN</a>
|
||||
</div>
|
||||
<a class="cta" href={ctaHref} rel="noopener">{T.cta}</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{!['/', '', '/index.html', '/index'].includes(Astro.url.pathname.replace(/\/$/, '') || '/') && (
|
||||
<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>
|
||||
)}
|
||||
<main>
|
||||
<slot />
|
||||
</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>
|
||||
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(); } });
|
||||
@ -78,8 +112,9 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
|
||||
<div class="wrap-wide cols">
|
||||
<div>
|
||||
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
|
||||
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p>
|
||||
<p>{fact.sideEffectNotice}</p>
|
||||
{isEn && !sponsorEn && <p class="notice-pending">Korean original below. The English wording of these notices is pending clinic approval.</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>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
403
templates/supporters-astro/src/pages/en/stay.astro
Normal file
403
templates/supporters-astro/src/pages/en/stay.astro
Normal file
@ -0,0 +1,403 @@
|
||||
---
|
||||
// 외국인 환자용 체류 안내. 세 부분이다.
|
||||
// 1. Around the clinic — 도보·차 소요 시간으로 거른 주변 장소
|
||||
// 2. Festivals — 계절로 묶은 행사
|
||||
// 3. Suggested days — 시간표와 지도가 붙은 동선
|
||||
//
|
||||
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
|
||||
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
|
||||
//
|
||||
// 만들지 않는 것
|
||||
// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다.
|
||||
// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다.
|
||||
// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다.
|
||||
// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다.
|
||||
import Base from '../../layouts/Base.astro';
|
||||
import { fact, site as S, SITE_NAME, clinicSchema, 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;
|
||||
|
||||
type Place = {
|
||||
id: string; title: string; address: string; image: string | null; overview: string;
|
||||
homepage: string; lat: number | null; lng: number | null; distanceM: number | null;
|
||||
travel: { mode: string; minutes: number; label: string } | null;
|
||||
};
|
||||
|
||||
const GROUPS: Array<{ key: string; label: string; note: string }> = [
|
||||
{ key: 'restaurant', label: 'Food', note: 'Restaurants and cafés' },
|
||||
{ key: 'stay', label: 'Hotels', note: 'Places to sleep' },
|
||||
{ key: 'wellness', label: 'Rest', note: 'Spas and jjimjilbang' },
|
||||
{ key: 'attraction', label: 'Sights', note: 'Streets, parks, landmarks' },
|
||||
{ key: 'culture', label: 'Culture', note: 'Museums, theatres, halls' },
|
||||
{ key: 'shopping', label: 'Shopping', note: 'Stores and markets' },
|
||||
];
|
||||
|
||||
const SEASONS = [
|
||||
{ key: 'spring', label: 'Spring' }, { key: 'summer', label: 'Summer' },
|
||||
{ key: 'autumn', label: 'Autumn' }, { key: 'winter', label: 'Winter' },
|
||||
];
|
||||
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const nowSeason = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()];
|
||||
|
||||
// 검색으로 보낸다. 없는 URL 을 만들지 않는다.
|
||||
const searchUrl = (q: string) => `https://www.google.com/search?q=${encodeURIComponent(q + ' Seoul')}`;
|
||||
// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다.
|
||||
const dt = (d: string | null) => (d && d.length === 8 ? `${MONTHS[+d.slice(4, 6)]} ${+d.slice(6, 8)}` : '');
|
||||
// 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다.
|
||||
const shortName = (t: string) => {
|
||||
const m = t.match(/\(([^)]+)\)\s*$/);
|
||||
return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim();
|
||||
};
|
||||
const firstSentence = (s: string, n = 150) => {
|
||||
if (!s) return '';
|
||||
const cut = s.slice(0, n);
|
||||
const stop = cut.lastIndexOf('. ');
|
||||
return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : '');
|
||||
};
|
||||
|
||||
/* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */
|
||||
const oLat = T.meta.origin.lat, oLng = T.meta.origin.lng;
|
||||
const pick = (key: string, maxMin: number, n: number): Place[] =>
|
||||
((T.places[key] ?? []) as Place[])
|
||||
.filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat)
|
||||
.slice(0, n);
|
||||
const pickAny = (key: string, n: number): Place[] => ((T.places[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n);
|
||||
|
||||
const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`;
|
||||
function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) {
|
||||
let t = startMin;
|
||||
const out: any[] = [];
|
||||
stops.forEach((s, i) => {
|
||||
const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5);
|
||||
t += move;
|
||||
const from = t;
|
||||
t += s.stay;
|
||||
out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng });
|
||||
});
|
||||
return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) };
|
||||
}
|
||||
const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note });
|
||||
|
||||
const ROUTES = [
|
||||
{
|
||||
id: 'near', title: 'The day of a check-up', who: 'When you come back to the clinic often',
|
||||
blurb: 'Everything within a short walk. You do not need a taxi.',
|
||||
route: buildRoute(15 * 60, [
|
||||
clinicStop(clinic, 40, 'Your appointment ends here.'),
|
||||
...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })),
|
||||
...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'short', title: 'A short outing', who: 'When you can walk a little further',
|
||||
blurb: 'Still close, but out of the block.',
|
||||
route: buildRoute(13 * 60, [
|
||||
clinicStop(clinic, 20, 'Start from the clinic.'),
|
||||
...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'city', title: 'When you can travel', who: 'Later in your stay',
|
||||
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
|
||||
route: buildRoute(10 * 60, [
|
||||
clinicStop(clinic, 10, 'Set off from the clinic.'),
|
||||
...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
|
||||
...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })),
|
||||
]),
|
||||
},
|
||||
].filter((r) => r.route.stops.length > 1);
|
||||
|
||||
/* 좌표를 그대로 그린 미니 지도. 외부 지도 라이브러리를 부르지 않는다. */
|
||||
function miniMap(stops: any[]) {
|
||||
const W = 640, H = 200, PAD = 26;
|
||||
const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng);
|
||||
const [la0, la1] = [Math.min(...lats), Math.max(...lats)];
|
||||
const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)];
|
||||
const sx = (ln: number) => (ln1 - ln0 < 1e-6 ? W / 2 : PAD + ((ln - ln0) / (ln1 - ln0)) * (W - PAD * 2));
|
||||
const sy = (la: number) => (la1 - la0 < 1e-6 ? H / 2 : H - PAD - ((la - la0) / (la1 - la0)) * (H - PAD * 2));
|
||||
return stops.map((s, i) => ({ x: +sx(s.lng).toFixed(1), y: +sy(s.lat).toFixed(1), n: i + 1, label: s.label }));
|
||||
}
|
||||
|
||||
const allPlaces: Array<Place & { cat: string }> = GROUPS.flatMap((g) =>
|
||||
((T.places[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key })));
|
||||
|
||||
const ld = [
|
||||
{ '@type': 'WebPage', '@id': `${site}/en/stay#page`, name: `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} — food, rest, sights and festivals`}
|
||||
description={`For international patients at ${clinic} in Gangnam, Seoul: where to eat and rest, what to see, which festivals are on, and how to shape your days. Distances and walking times from the clinic. Source: Korea Tourism Organization.`}
|
||||
jsonLd={ld}
|
||||
>
|
||||
<article class="wrap-wide stay">
|
||||
<header class="article-head">
|
||||
<div class="eyebrow">For international patients</div>
|
||||
<h1 class="serif">Your Stay in Seoul</h1>
|
||||
<p class="lede">
|
||||
<strong>Around {clinic}.</strong> Everything below is measured from the clinic at {f.address?.fullEn || f.address?.full}.
|
||||
Names, photographs and descriptions come from the Korea Tourism Organization.
|
||||
For the clinic's own hours and phone, see <a href="/clinic" hreflang="ko">Clinic information<span class="lang-tag">KO</span></a>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
|
||||
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
|
||||
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
|
||||
<aside id="weather" class="wx" hidden>
|
||||
<div class="wx-now">
|
||||
<span class="wx-temp"><span id="wx-t">–</span><span class="wx-unit">°C</span></span>
|
||||
<div>
|
||||
<div class="wx-cond" id="wx-c">Loading</div>
|
||||
<div class="wx-meta" id="wx-m"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="wx-note" id="wx-n"></p>
|
||||
</aside>
|
||||
|
||||
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
|
||||
<nav class="jump" aria-label="Jump to a section">
|
||||
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
|
||||
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
|
||||
))}
|
||||
<a href="#festivals">Festivals<b>{T.festivals.length}</b></a>
|
||||
<a href="#days">Days<b>{ROUTES.length}</b></a>
|
||||
</nav>
|
||||
|
||||
<!-- ── 1. 주변 ── -->
|
||||
<section id="around">
|
||||
<div class="sec-head">
|
||||
<h2>Around the clinic</h2>
|
||||
<span class="upd">Updated {T.meta.fetchedAt.slice(0, 10)}</span>
|
||||
</div>
|
||||
<p class="sub">{T.filters.total} places, sorted by how long they take to reach on foot.</p>
|
||||
|
||||
<div class="chips" id="walkchips" role="group" aria-label="Filter by travel time">
|
||||
<button class="chip on" data-max="all">All <b>{T.filters.total}</b></button>
|
||||
<button class="chip" data-max="5">Within 5 min walk <b>{T.filters.walk5}</b></button>
|
||||
<button class="chip" data-max="15">Within 15 min walk <b>{T.filters.walk15}</b></button>
|
||||
<button class="chip" data-max="walk">Walking distance <b>{T.filters.walkAll}</b></button>
|
||||
<button class="chip" data-max="drive">By car <b>{T.filters.drive}</b></button>
|
||||
</div>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const items = allPlaces.filter((p) => p.cat === g.key);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
|
||||
<h3>{g.label} <small>{g.note}</small></h3>
|
||||
<div class="cards">
|
||||
{items.map((p) => (
|
||||
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
|
||||
<div class="thumb">
|
||||
{p.image ? <img src={p.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(p.title)}</span>}
|
||||
{p.travel && <span class="badge">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
|
||||
</div>
|
||||
<div class="body">
|
||||
<h4>{p.title}</h4>
|
||||
<p class="desc">{firstSentence(p.overview) || 'No description provided by the tourism data.'}</p>
|
||||
<p class="addr">{p.address}</p>
|
||||
{p.homepage
|
||||
? <a class="go" href={p.homepage} rel="noopener nofollow">Official site ↗</a>
|
||||
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p class="src">Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. 축제 ── -->
|
||||
<section id="festivals">
|
||||
<div class="sec-head"><h2>Festivals and events</h2></div>
|
||||
<p class="sub">{T.festivals.length} events within {T.meta.festivalRadiusKm} km, grouped by season. Right now it is <strong>{nowSeason}</strong>.</p>
|
||||
<div class="chips" role="group" aria-label="Filter by season">
|
||||
<button class="chip on" data-season="all">All <b>{T.festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = T.festivals.filter((e: any) => e.season === s.key).length;
|
||||
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
|
||||
})}
|
||||
</div>
|
||||
<div class="cards fest">
|
||||
{T.festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<h4>{e.title}</h4>
|
||||
<p class="when">{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}</p>
|
||||
<p class="desc">{firstSentence(e.overview) || 'No description provided by the tourism data.'}</p>
|
||||
<p class="addr">{e.address}</p>
|
||||
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">Open in search ↗</a>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<p class="src">Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.</p>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. 동선 ── -->
|
||||
<section id="days">
|
||||
<div class="sec-head"><h2>Shaping your days</h2></div>
|
||||
<p class="sub">
|
||||
Three routes built from the places above. The clock times assume an appointment ending at the hour shown, and are an example, not your schedule.
|
||||
</p>
|
||||
{Sx.recoveryStagesEn
|
||||
? <p><strong>What {clinic} advises.</strong> {Sx.recoveryStagesEn}</p>
|
||||
: <p class="pending-note">Which of these you can do, and on which day, is a medical question. {clinic} has not yet published English guidance. Ask at your consultation.</p>}
|
||||
|
||||
{ROUTES.map((r) => {
|
||||
const pins = miniMap(r.route.stops);
|
||||
return (
|
||||
<div class="route">
|
||||
<div class="route-head">
|
||||
<div>
|
||||
<h3>{r.title}</h3>
|
||||
<p class="who">{r.who}</p>
|
||||
</div>
|
||||
<div class="clock">{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m</div>
|
||||
</div>
|
||||
<p class="blurb">{r.blurb}</p>
|
||||
<svg class="map" viewBox="0 0 640 200" role="img" aria-label={`Route map with ${pins.length} stops`}>
|
||||
<rect width="640" height="200" rx="12" fill="#F4F6FB" />
|
||||
<polyline points={pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#C5CBF5" stroke-width="2" stroke-dasharray="5 4" />
|
||||
{pins.map((p) => (
|
||||
<g><circle cx={p.x} cy={p.y} r="11" fill="#0A1128" /><text x={p.x} y={p.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff">{p.n}</text></g>
|
||||
))}
|
||||
</svg>
|
||||
<p class="maphint">Positions are drawn from coordinates, to scale with each other. Not a street map.</p>
|
||||
<ol class="timeline">
|
||||
{r.route.stops.map((s: any, i: number) => (
|
||||
<li>
|
||||
{i > 0 && <div class="move">↓ {s.moveMin} min</div>}
|
||||
<div class="stop">
|
||||
<span class="n">{i + 1}</span>
|
||||
<div>
|
||||
<div class="stop-t">{s.from}–{s.to}</div>
|
||||
<h4>{s.label}</h4>
|
||||
<p>{s.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section id="limits">
|
||||
<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.</li>
|
||||
<li>No claim that any place suits your recovery. That is for {clinic} to say.</li>
|
||||
<li>English tourism listings around Gangnam are thin. Only {T.filters.walk5} places sit within a five minute walk, and {T.filters.drive} of {T.filters.total} need a car.</li>
|
||||
<li>We are not a travel agency, take no booking and no commission.</li>
|
||||
</ul>
|
||||
<p class="botnote">
|
||||
{has(f.urlEn) && <a class="btn primary" href={f.urlEn} rel="noopener" hreflang="en">Book a consultation in English</a>}
|
||||
</p>
|
||||
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<script is:inline define:vars={{ WX_LAT: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
|
||||
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
|
||||
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
|
||||
(function () {
|
||||
var chips = document.querySelectorAll('#walkchips .chip');
|
||||
chips.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.max;
|
||||
document.querySelectorAll('#around .card').forEach(function (card) {
|
||||
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
|
||||
var show = want === 'all' ? true
|
||||
: want === 'walk' ? mode === 'walk'
|
||||
: want === 'drive' ? mode !== 'walk'
|
||||
: mode === 'walk' && w <= parseInt(want, 10);
|
||||
card.hidden = !show;
|
||||
});
|
||||
document.querySelectorAll('#around .grp').forEach(function (g) {
|
||||
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
|
||||
});
|
||||
});
|
||||
});
|
||||
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
|
||||
var links = [].slice.call(document.querySelectorAll('.jump a'));
|
||||
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
|
||||
if ('IntersectionObserver' in window) {
|
||||
var seen = {};
|
||||
var io = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
|
||||
var best = null, top = 0;
|
||||
targets.forEach(function (t) {
|
||||
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
|
||||
});
|
||||
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
|
||||
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
|
||||
targets.forEach(function (t) { if (t) io.observe(t); });
|
||||
}
|
||||
|
||||
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
|
||||
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
|
||||
(function () {
|
||||
var WMO = {
|
||||
0: 'Clear sky', 1: 'Mainly clear', 2: 'Partly cloudy', 3: 'Overcast',
|
||||
45: 'Fog', 48: 'Rime fog', 51: 'Light drizzle', 53: 'Drizzle', 55: 'Dense drizzle',
|
||||
56: 'Freezing drizzle', 57: 'Freezing drizzle', 61: 'Light rain', 63: 'Rain', 65: 'Heavy rain',
|
||||
66: 'Freezing rain', 67: 'Freezing rain', 71: 'Light snow', 73: 'Snow', 75: 'Heavy snow',
|
||||
77: 'Snow grains', 80: 'Light showers', 81: 'Showers', 82: 'Violent showers',
|
||||
85: 'Snow showers', 86: 'Snow showers', 95: 'Thunderstorm', 96: 'Thunderstorm with hail', 99: 'Thunderstorm with hail'
|
||||
};
|
||||
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
|
||||
var LAT = WX_LAT, LNG = WX_LNG;
|
||||
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
|
||||
'¤t=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
|
||||
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
||||
.then(function (j) {
|
||||
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
|
||||
var code = c.weather_code;
|
||||
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
|
||||
document.getElementById('wx-c').textContent = WMO[code] || 'Current conditions';
|
||||
document.getElementById('wx-m').textContent =
|
||||
'Feels like ' + Math.round(c.apparent_temperature) + '°C · wind ' +
|
||||
Math.round(c.wind_speed_10m) + ' km/h · observed ' + String(c.time).replace('T', ' ');
|
||||
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
|
||||
? 'Wet outside. The Culture and Shopping listings below are indoors.'
|
||||
: (c.temperature_2m >= 30
|
||||
? 'Hot today. The Culture and Shopping listings below are indoors.'
|
||||
: (c.temperature_2m <= 0
|
||||
? 'Below freezing. The Culture and Shopping listings below are indoors.'
|
||||
: 'Dry at the moment. The walking times below are on foot from the clinic.'));
|
||||
document.getElementById('weather').hidden = false;
|
||||
})
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
{ loc: `${base}/posts`, lastmod: today },
|
||||
{ loc: `${base}/clinic`, lastmod: today },
|
||||
{ loc: `${base}/visit`, lastmod: today },
|
||||
{ loc: `${base}/en/stay`, lastmod: today },
|
||||
{ loc: `${base}/newsroom`, lastmod: today },
|
||||
{ loc: `${base}/videos`, 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 ?? []).length && <li>검토를 마친 글부터 차례로 연결합니다.</li>}
|
||||
</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 class="disclosure">{fact.sideEffectNotice}</p>
|
||||
</article>
|
||||
|
||||
@ -264,3 +264,202 @@ td { color: var(--slate-700); }
|
||||
.totop.show { opacity: 1; transform: none; pointer-events: auto; }
|
||||
.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; } }
|
||||
|
||||
|
||||
/* 값이 아직 없다는 것을 숨기지 않고 드러내는 문단. 병원 확인 대기 항목에 쓴다. */
|
||||
.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; }
|
||||
|
||||
|
||||
/* 영문 화면에서 한국어 페이지로 가는 링크에 붙인다. 눌러보고 알게 하지 않는다. */
|
||||
.lang-tag {
|
||||
display: inline-block;
|
||||
margin-left: 0.28em;
|
||||
padding: 0 0.3em;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 3px;
|
||||
font-size: 0.62em;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: 0.18em;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 언어 전환. 머리말 오른쪽에 하나만 둔다. 항목마다 표시하지 않는다. */
|
||||
.langswitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line, #E2E8F0);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin-right: 0.7rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.langswitch a {
|
||||
padding: 0.26rem 0.62rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--slate-500, #64748B);
|
||||
text-decoration: none;
|
||||
line-height: 1.45;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.langswitch a:hover { background: var(--near, #F4F6FB); color: var(--primary-900, #0A1128); }
|
||||
.langswitch a.on {
|
||||
background: var(--primary-900, #0A1128);
|
||||
color: #fff;
|
||||
}
|
||||
.langswitch a.on:hover { background: var(--primary-900, #0A1128); color: #fff; }
|
||||
|
||||
/* ── /en/stay ── */
|
||||
.stay { padding-bottom: 4rem; }
|
||||
.stay .lede { color: var(--slate-600, #475569); margin-top: 0.6rem; }
|
||||
.stay section { margin-top: 3.4rem; }
|
||||
.stay .sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.stay .sec-head h2 { margin: 0; }
|
||||
.stay .upd { font-size: 0.8rem; color: var(--slate-500, #64748B); }
|
||||
.stay .sub { color: var(--slate-600, #475569); margin: 0.4rem 0 1rem; }
|
||||
.stay .src { font-size: 0.84rem; color: var(--slate-500, #64748B); margin-top: 1rem; }
|
||||
.stay .chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.2rem 0 1.6rem; }
|
||||
.stay .chip {
|
||||
border: 1px solid var(--line, #E2E8F0); background: #fff; border-radius: 999px;
|
||||
padding: 0.36rem 0.85rem; font: inherit; font-size: 0.86rem; color: var(--slate-600, #475569);
|
||||
cursor: pointer; transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.stay .chip b { font-weight: 700; opacity: 0.55; margin-left: 0.25em; }
|
||||
.stay .chip:hover { border-color: var(--primary-900, #0A1128); }
|
||||
.stay .chip.on { background: var(--primary-900, #0A1128); border-color: var(--primary-900, #0A1128); color: #fff; }
|
||||
.stay .chip.on b { opacity: 0.7; }
|
||||
.stay .grp { margin-bottom: 2.2rem; }
|
||||
.stay .grp h3 { margin: 0 0 0.9rem; }
|
||||
.stay .grp h3 small { font-weight: 400; font-size: 0.82rem; color: var(--slate-500, #64748B); margin-left: 0.45em; }
|
||||
.stay .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 1rem; }
|
||||
.stay .card {
|
||||
background: #fff; border: 1px solid var(--line, #E2E8F0); border-radius: 14px;
|
||||
overflow: hidden; display: flex; flex-direction: column;
|
||||
box-shadow: 3px 4px 12px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
.stay .card .thumb { position: relative; height: 148px; background: #EEF1F6; display: flex; align-items: center; justify-content: center; }
|
||||
.stay .card .thumb img { width: 100%; height: 148px; object-fit: cover; display: block; }
|
||||
.stay .card .nophoto {
|
||||
font-size: 1.02rem; font-weight: 700; color: var(--slate-500, #64748B);
|
||||
padding: 0 1rem; text-align: center; line-height: 1.35; word-break: keep-all;
|
||||
}
|
||||
.stay .card .badge {
|
||||
position: absolute; left: 0.6rem; bottom: 0.6rem;
|
||||
background: rgba(10, 17, 40, 0.82); color: #fff; border-radius: 6px;
|
||||
padding: 0.18rem 0.45rem; font-size: 0.74rem; font-weight: 600;
|
||||
}
|
||||
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
|
||||
.stay .card .badge.mono { letter-spacing: 0.06em; }
|
||||
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
|
||||
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }
|
||||
.stay .card .desc { font-size: 0.86rem; color: var(--slate-600, #475569); margin: 0 0 0.5rem; }
|
||||
.stay .card .addr { font-size: 0.78rem; color: var(--slate-500, #64748B); margin: 0 0 0.6rem; }
|
||||
.stay .card .go { font-size: 0.82rem; margin-top: auto; }
|
||||
.stay .route { border: 1px solid var(--line, #E2E8F0); border-radius: 16px; padding: 1.3rem 1.4rem; margin-bottom: 1.4rem; background: #fff; }
|
||||
.stay .route-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap; }
|
||||
.stay .route-head h3 { margin: 0; }
|
||||
.stay .route .who { font-size: 0.84rem; color: var(--slate-500, #64748B); margin: 0.15rem 0 0; }
|
||||
.stay .route .clock { font-size: 0.86rem; font-weight: 600; color: var(--primary-900, #0A1128); white-space: nowrap; }
|
||||
.stay .route .blurb { color: var(--slate-600, #475569); margin: 0.7rem 0 1rem; }
|
||||
.stay .route .map { width: 100%; height: auto; display: block; border-radius: 12px; }
|
||||
.stay .route .maphint { font-size: 0.76rem; color: var(--slate-500, #64748B); margin: 0.4rem 0 1rem; }
|
||||
.stay .timeline { list-style: none; padding: 0; margin: 0; }
|
||||
.stay .timeline .move { font-size: 0.8rem; color: var(--slate-500, #64748B); margin: 0.35rem 0 0.35rem 1.05rem; }
|
||||
.stay .timeline .stop { display: flex; gap: 0.8rem; align-items: flex-start; }
|
||||
.stay .timeline .n {
|
||||
flex: none; width: 26px; height: 26px; border-radius: 50%;
|
||||
background: var(--primary-900, #0A1128); color: #fff;
|
||||
font-size: 0.8rem; font-weight: 700; display: grid; place-items: center; margin-top: 0.1rem;
|
||||
}
|
||||
.stay .timeline .stop-t { font-size: 0.8rem; color: var(--slate-500, #64748B); }
|
||||
.stay .timeline h4 { margin: 0.1rem 0 0.2rem; font-size: 0.98rem; }
|
||||
.stay .timeline p { margin: 0; font-size: 0.86rem; color: var(--slate-600, #475569); }
|
||||
.stay .botnote { margin-top: 1.6rem; }
|
||||
@media (max-width: 640px) { .cards { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 섹션 점프. 머리말 아래에 붙어 스크롤을 따라온다. 헤더(68px) 아래에 걸리게 top 을 맞춘다. */
|
||||
.stay .jump {
|
||||
position: sticky; top: 68px; z-index: 9;
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem;
|
||||
margin: 1.4rem 0 2.4rem;
|
||||
padding: 0.62rem 0.68rem;
|
||||
background: rgba(255, 255, 255, 0.58);
|
||||
backdrop-filter: blur(16px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(1.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.6);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 8px 28px rgba(10, 17, 40, 0.09), inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.stay .jump a {
|
||||
display: inline-flex; align-items: center; gap: 0.42rem;
|
||||
padding: 0.46rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.86rem; font-weight: 600; letter-spacing: -0.005em;
|
||||
color: var(--primary-900, #0A1128);
|
||||
text-decoration: none; white-space: nowrap;
|
||||
background: #fff;
|
||||
border: 1px solid var(--line, #E2E8F0);
|
||||
box-shadow: 0 1px 2px rgba(10, 17, 40, 0.06);
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s, border-color 0.14s, color 0.14s;
|
||||
}
|
||||
.stay .jump a:hover {
|
||||
text-decoration: none;
|
||||
transform: translateY(-1px);
|
||||
border-color: #C5CBF5;
|
||||
box-shadow: 0 4px 12px rgba(10, 17, 40, 0.12);
|
||||
}
|
||||
.stay .jump a:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(10, 17, 40, 0.08); }
|
||||
.stay .jump a b {
|
||||
font-weight: 700; font-size: 0.72rem; line-height: 1;
|
||||
padding: 0.2rem 0.4rem; border-radius: 999px;
|
||||
background: #EFF0FF; color: #3A3F7C;
|
||||
}
|
||||
/* 지금 보고 있는 섹션 */
|
||||
.stay .jump a.on {
|
||||
background: linear-gradient(to right, #4F1DA1, #021341);
|
||||
border-color: transparent; color: #fff;
|
||||
box-shadow: 0 4px 14px rgba(79, 29, 161, 0.32);
|
||||
}
|
||||
.stay .jump a.on b { background: rgba(255, 255, 255, 0.22); color: #fff; }
|
||||
/* 점프로 이동했을 때 제목이 스티키 바에 가리지 않게 */
|
||||
.stay section, .stay .grp { scroll-margin-top: 152px; }
|
||||
@media (max-width: 640px) {
|
||||
.stay .jump { top: 60px; gap: 0.35rem; padding: 0.5rem; border-radius: 14px; }
|
||||
.stay .jump a { padding: 0.4rem 0.72rem; font-size: 0.8rem; }
|
||||
}
|
||||
|
||||
/* 필터 칩도 눌리는 것처럼 보이게 */
|
||||
.stay .chip {
|
||||
box-shadow: 0 1px 2px rgba(10, 17, 40, 0.05);
|
||||
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.stay .chip:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(10, 17, 40, 0.1); }
|
||||
.stay .chip:active { transform: translateY(0); }
|
||||
.stay .chip.on { box-shadow: 0 4px 14px rgba(10, 17, 40, 0.22); }
|
||||
|
||||
/* 오늘 날씨. 값을 못 받으면 hidden 이라 아예 그려지지 않는다. */
|
||||
.stay .wx {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 1.2rem; flex-wrap: wrap;
|
||||
margin-top: 1.6rem; padding: 1rem 1.2rem;
|
||||
background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff);
|
||||
border-radius: 16px;
|
||||
}
|
||||
.stay .wx-now { display: flex; align-items: center; gap: 0.9rem; }
|
||||
.stay .wx-temp { font-family: 'Playfair Display', serif; font-size: 2.5rem; font-weight: 700; line-height: 1; color: var(--primary-900, #0A1128); }
|
||||
.stay .wx-unit { font-size: 1.1rem; vertical-align: 0.9rem; margin-left: 0.06em; opacity: 0.6; }
|
||||
.stay .wx-cond { font-weight: 700; color: var(--primary-900, #0A1128); }
|
||||
.stay .wx-meta { font-size: 0.8rem; color: var(--slate-600, #475569); margin-top: 0.15rem; }
|
||||
.stay .wx-note { margin: 0; font-size: 0.88rem; color: var(--slate-700, #334155); max-width: 30rem; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user