feat(discovery): 의료관광 연결 섹션 — TourAPI 다국어 주변 정보 + 회복 동선

/discovery/:id 에 외국인 환자용 주변 정보를 붙였다. 숙박·음식점·웰니스·관광·문화·
쇼핑·축제 125건과 이동 반경 기준 회복 동선. 다음 뷰성형외과 미팅용 프로토타입이다.

데이터 (scripts/fetch_medical_tourism.py → src/data/medicalTourism.json)
한국관광공사 TourAPI 영문(EngService2) 사전 수집. 실시간 호출이 아닌 이유는 영문
커버리지가 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지
않게 하기 위해서다. 좌표는 Naver 지역검색 실측(뷰성형외과의원, 봉은사로 107).

실측으로 드러난 것과 대응
- 영문 관광지(76)의 대부분이 관광지가 아니다. 반경 5km 100건 중 92건이 분류코드
  A02020500 "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치
  에이전시다. 제목 키워드로는 "하이안과", "닥파인더코리아" 를 놓쳐 코드로 걸렀다.
  거리순 상위 100건이 거의 다 여기라 페이징을 넣어야 실제 관광지가 나온다.
- 스파·찜질방(A02020300/400)은 회복 여정에 맞아 wellness 로 분리했다.
- 영문 축제는 areacode 필드가 비어 있어 areaCode 필터가 0건을 반환한다. 좌표
  거리로 걸렀다.
- TourAPI 이미지 URL 이 http 라 https 사이트에서 혼합 콘텐츠로 차단된다. 수집
  단계에서 https 로 올린다.
- 반경을 목적별로 나눴다. 회복기에 자주 가는 숙박·식사는 5km, 관광은 10km.

만들지 않은 것
- 평점·리뷰·영업시간: TourAPI 미제공이라 null 로 두고 화면에 "미연동" 으로 적었다.
  Google Places 키가 아직 비어 있다(신청 중).
- 수술 전후 식이 적합성: 의학 판단이라 병원 입력만 쓴다. ClinicInputsPanel 에
  diet_guide 항목을 더해 supporter_inputs 로 받고, 입력 전에는 "병원 확인 대기"
  로 둔다. 개별 식당의 적합 여부를 추론하지 않는다.
- 회복 단계가 며칠째인지: 병원 입력(recovery). 화면의 단계는 이동 반경으로만
  정의해 의학적 함의를 담지 않는다.
- 한계 4가지를 meta.limits 에 담아 섹션 하단 "측정 가능 범위" 에 그대로 띄운다.

FilledIcons 에 Bed·Fork·Spa·MapPin·Ticket·Bag·Theater 7종 추가(라인 아이콘·이모지
금지 규칙에 따라 채움 SVG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-09 11:31:55 +09:00
parent b2caef9d02
commit 5d3ce40f77
7 changed files with 2618 additions and 0 deletions

View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어).
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
거리와 함께 붙여 프론트가 JSON 하나로 낸다.
사전 수집인가
영문 서비스는 강남 일대 커버리지가 얇고(5km 음식점 18, 국문은 338) 관광지의
79% 의료기관이라 필터링이 많이 필요하다. 처리를 수집 시점에 끝내고 프론트는
정제된 결과만 그린다. 미팅 데모 외부 API 상태에 화면이 종속되지도 않는다.
무엇을 만들지 않는가
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 주지 않으므로 비워 두고,
Google Places 승인 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
판정하지 않는다.
python3 scripts/fetch_medical_tourism.py
python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json
"""
import argparse, io, json, math, os, ssl, sys, time, urllib.parse, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# 영문 관광지(76)의 대부분은 관광지가 아니라 의료관광 등록업소다. 반경 5km 100건 중 92건이
# 분류코드 A02020500 = "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치 에이전시가
# 여기 들어 있다. 제목 키워드로는 "하이안과", "닥파인더코리아" 같은 것을 놓치므로 코드로 거른다.
CAT_MEDICAL_TOURISM = "A02020500"
# 스파·찜질방은 수술 후 회복 여정에 맞으므로 관광이 아니라 별도 범주로 뺀다.
CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
# 다국어 ContentTypeId. 국문과 코드가 다르다.
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
TYPES = {
"76": ("attraction", "10000"),
"78": ("culture", "10000"),
"79": ("shopping", "10000"),
"80": ("stay", "5000"),
"82": ("restaurant", "5000"),
}
def ssl_ctx():
try:
import certifi; return ssl.create_default_context(cafile=certifi.where())
except Exception:
return ssl.create_default_context()
def load_env(path):
d = {}
for line in io.open(path, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1); d[k.strip()] = v.strip()
return d
def haversine_km(lat1, lng1, lat2, lng2):
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(a))
def call(env, op, extra, rows=100, retries=3, page=1):
base, svc, key = env["TOUR_API_BASE"], env["TOUR_API_SERVICE"], env["TOUR_API_KEY"]
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
"numOfRows": str(rows), "pageNo": str(page), **extra}
url = f"{base}/{svc}/{op}?serviceKey={key}&" + urllib.parse.urlencode(p)
for i in range(retries):
try:
with urllib.request.urlopen(url, timeout=30, context=ssl_ctx()) as r:
body = r.read().decode("utf-8", "replace")
if body.lstrip().startswith("<"):
raise RuntimeError(f"XML 응답(인증·한도 확인): {body[:160]}")
j = json.loads(body)["response"]
if j["header"].get("resultCode") != "0000":
raise RuntimeError(f"{j['header'].get('resultCode')} {j['header'].get('resultMsg')}")
b = j.get("body", {}); items = b.get("items")
return b.get("totalCount", 0), ([] if not items or items == "" else items["item"])
except Exception as e:
if i == retries - 1: raise
time.sleep(1.5 * (i + 1))
def call_all(env, op, extra, max_pages=6, rows=100):
"""전 페이지를 모은다. 관광지는 거리순 상위 100건이 거의 다 의료관광업소라 한 페이지로는 부족하다."""
got, total = [], None
for page in range(1, max_pages + 1):
total, items = call(env, op, extra, rows=rows, page=page)
got += items
if not items or len(got) >= (total or 0): break
time.sleep(0.2)
return total, got
def bucket(it):
"""분류코드로 범주를 정한다. None 이면 버린다."""
cat3 = (it.get("cat3") or "").strip()
if cat3 == CAT_MEDICAL_TOURISM: return None
if cat3 in CAT_WELLNESS: return "wellness"
return "keep"
def clean(it, lat, lng):
"""TourAPI 원본에서 화면이 쓸 필드만 남긴다. mapx=경도, mapy=위도 (이름과 반대라 자주 틀린다)."""
try:
ilng, ilat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
except ValueError:
ilng = ilat = 0.0
dist = it.get("dist")
return {
"id": it.get("contentid"),
"title": (it.get("title") or "").strip(),
"address": (it.get("addr1") or "").strip(),
# TourAPI 는 이미지 URL 을 http 로 준다. https 사이트에서 혼합 콘텐츠로 차단되므로 올린다.
# 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),
"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),
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
"rating": None, "reviewCount": None, "openingHours": None,
}
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")
a = ap.parse_args()
env = load_env(os.path.join(ROOT, ".env"))
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
out = {"places": {}, "festivals": [], "meta": {}}
filtered_log = {}
wellness = []
for ct, (name, radius) in TYPES.items():
total, rows = call_all(env, "locationBasedList2",
{"mapX": str(lng), "mapY": str(lat), "radius": radius,
"contentTypeId": ct, "arrange": "E"})
kept, dropped = [], []
for it in rows:
b = bucket(it)
if b is None:
dropped.append((it.get("title") or "").strip()); continue
(wellness if b == "wellness" else kept).append(clean(it, lat, lng))
kept.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
out["places"][name] = kept[:a.per_type]
filtered_log[name] = {"apiTotal": total, "fetched": len(rows), "radiusM": int(radius),
"droppedMedicalTourism": len(dropped), "kept": len(kept),
"shown": len(out["places"][name]), "droppedSample": dropped[:5]}
print(f" {name:<11} r={radius:>5}m · API {total:>4} · 받음 {len(rows):>3} · 의료관광업소 제외 {len(dropped):>3} · 담음 {len(out['places'][name])}")
wellness.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
out["places"]["wellness"] = wellness[:a.per_type]
filtered_log["wellness"] = {"note": "관광지(76)에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
today = time.strftime("%Y%m%d")
total, rows = call(env, "searchFestival2", {"eventStartDate": today, "arrange": "A"}, rows=300)
near, nocoord = [], 0
for it in rows:
c = clean(it, lat, lng)
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))
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)}")
out["meta"] = {
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
"origin": {"lat": lat, "lng": lng, "label": "뷰성형외과의원 (봉은사로 107)",
"coordSource": "Naver 지역검색 실측 2026-09-09"},
"radiusByType": {n: int(r) for n, r in TYPES.values()}, "festivalRadiusKm": a.festival_km,
"counts": filtered_log,
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
"limits": [
"영문 서비스는 강남 일대 숙박·음식점 커버리지가 얇다(반경 5km 숙박 4건·음식점 18건, 국문은 43건·338건).",
"관광지 항목의 대부분(반경 5km 100건 중 92건)이 분류코드 A02020500 \"Medical Tourism Sites\" 였고 제외했다.",
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
],
}
p = os.path.join(ROOT, a.out)
os.makedirs(os.path.dirname(p), exist_ok=True)
io.open(p, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
print(f"\n{a.out}")
if __name__ == "__main__":
main()

View File

@ -40,6 +40,7 @@ const FIELDS: Array<{ key: string; title: string; hint: string; inputs: Array<{
{ key: 'sponsorship', title: '3. 지원 관계 문구', hint: '모든 글 첫 부분에 그대로 실립니다. 예: 이 글은 OO의원의 지원(광고비)을 받아 OO 서포터즈가 작성했습니다.', inputs: [{ name: 'notice', label: '고지 문장', type: 'textarea' }] },
{ key: 'quote_items', title: '4. 견적 기본 포함 항목과 결제 방식', hint: '금액은 받지 않습니다. 항목과 방식만 적어 주세요.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'recovery', title: '5. 회복 일정표의 병원 확정값', hint: '출근 기준, 걷기 시작 시점 등 공개 자료에 없는 값.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'diet_guide', title: '5b. 수술 전후 식이 가이드', hint: '의료관광 연결의 음식점 추천에 그대로 실립니다. 개별 식당의 적합 여부는 저희가 판정하지 않고, 여기 적힌 분류만 표시합니다.', inputs: [{ name: 'recommended', label: '권장 음식 분류 (회복 단계별)', type: 'textarea' }, { name: 'avoid', label: '피해야 할 음식 분류', type: 'textarea' }, { name: 'stageDays', label: '각 단계가 수술 후 며칠인지' }] },
{ key: 'revision_policy', title: '6. 재수술 정책', hint: '다른 병원 수술의 상담 가능 여부, 부위별 대기 기간.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'discrepancy', title: '7. 페이지 간 표기 불일치의 정답', hint: '예: 코 실밥 제거일이 코성형 페이지 7일, 애프터케어 3일로 다릅니다.', inputs: [{ name: 'answer', label: '내용', type: 'textarea' }] },
{ key: 'specialty_doctors', title: '8. 진료 분야별 담당 원장', hint: '아래 의료진 목록의 ID 를 적습니다. 글별 검토자 후보가 됩니다.', inputs: [{ name: 'eye', label: '눈' }, { name: 'nose', label: '코' }, { name: 'contour', label: '윤곽' }, { name: 'lifting', label: '리프팅' }, { name: 'breast', label: '가슴' }, { name: 'anesthesia', label: '마취' }] },

View File

@ -0,0 +1,449 @@
/**
* (/discovery/:id)
*
* .
* · · () · · · , .
*
* `scripts/fetch_medical_tourism.py` TourAPI ()
* `src/data/medicalTourism.json` .
* , API .
*
*
* - ··영업시간: TourAPI "미연동" .
* - 적합성: 병원이 supporter_inputs('diet_guide') .
* "병원 확인 대기" . .
* - 며칠째인지: 의학 ('recovery') .
* .
*/
import { useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { supabase } from '../../lib/supabase';
import data from '../../data/medicalTourism.json';
import type {
MedicalTourismData,
PlaceCategory,
RecoveryStage,
TourPlace,
} from '../../types/medicalTourism';
import {
BedFilled,
ForkFilled,
SpaFilled,
MapPinFilled,
TicketFilled,
BagFilled,
TheaterFilled,
} from '../icons/FilledIcons';
const TOURISM = data as unknown as MedicalTourismData;
const CATEGORY_META: Record<
PlaceCategory,
{ label: string; en: string; Icon: typeof BedFilled }
> = {
stay: { label: '숙박', en: 'Stay', Icon: BedFilled },
restaurant: { label: '음식점', en: 'Dining', Icon: ForkFilled },
wellness: { label: '웰니스', en: 'Wellness', Icon: SpaFilled },
attraction: { label: '관광', en: 'Attractions', Icon: MapPinFilled },
culture: { label: '문화', en: 'Culture', Icon: TheaterFilled },
shopping: { label: '쇼핑', en: 'Shopping', Icon: BagFilled },
};
const CATEGORY_ORDER: PlaceCategory[] = [
'stay',
'restaurant',
'wellness',
'attraction',
'culture',
'shopping',
];
/**
* . (§ClinicInputsPanel 'recovery').
* .
*/
const STAGES: RecoveryStage[] = [
{
id: 'near',
label: '병원 도보권',
maxDistanceM: 1000,
categories: ['stay', 'restaurant'],
note: '내원이 잦은 기간에 머무는 범위입니다.',
},
{
id: 'short',
label: '근거리 이동',
maxDistanceM: 3000,
categories: ['restaurant', 'wellness'],
note: '짧은 외출이 가능해지는 범위입니다.',
},
{
id: 'city',
label: '시내 이동',
maxDistanceM: 10000,
categories: ['attraction', 'culture', 'shopping'],
note: '관광 일정을 넣을 수 있는 범위입니다.',
},
];
function fmtDistance(m: number | null) {
if (m === null) return '거리 미상';
return m < 1000 ? `${m}m` : `${(m / 1000).toFixed(1)}km`;
}
function fmtDate(d: string | null) {
if (!d || d.length !== 8) return '';
return `${d.slice(0, 4)}.${d.slice(4, 6)}.${d.slice(6, 8)}`;
}
export function MedicalTourismPanel({ clinicId }: { clinicId: string }) {
const [tab, setTab] = useState<PlaceCategory | 'festival' | 'route'>('route');
const [clinicInput, setClinicInput] = useState<{
recovery?: string;
diet?: Record<string, string>;
}>({});
// 병원이 입력한 회복·식이 값을 읽는다. 같은 clinic_id 의 최신 행이 유효값이다.
useEffect(() => {
let alive = true;
(async () => {
const { data: rows } = await supabase
.from('supporter_inputs')
.select('key,value,created_at')
.eq('clinic_id', clinicId)
.in('key', ['recovery', 'diet_guide'])
.order('created_at', { ascending: true });
if (!alive || !rows) return;
const latest = new Map<string, Record<string, string>>();
for (const r of rows) latest.set(r.key, r.value as Record<string, string>);
setClinicInput({
recovery: latest.get('recovery')?.answer,
diet: latest.get('diet_guide'),
});
})();
return () => {
alive = false;
};
}, [clinicId]);
// 단계는 누적이 아니라 구간이다. 누적으로 하면 가까운 곳이 모든 단계에 중복으로 뜬다.
const stageBuckets = useMemo(
() =>
STAGES.map((s, i) => {
const minM = i === 0 ? 0 : STAGES[i - 1].maxDistanceM;
return {
stage: s,
minM,
items: s.categories
.flatMap((c) =>
(TOURISM.places[c] ?? []).map((p) => ({ ...p, category: c })),
)
.filter(
(p) =>
p.distanceM !== null &&
p.distanceM > minM &&
p.distanceM <= s.maxDistanceM,
)
.sort((a, b) => (a.distanceM ?? 0) - (b.distanceM ?? 0))
.slice(0, 6),
};
}),
[],
);
const totalPlaces =
CATEGORY_ORDER.reduce((n, c) => n + (TOURISM.places[c]?.length ?? 0), 0) +
TOURISM.festivals.length;
return (
<SectionWrapper
id="medical-tourism"
title="Medical Tourism Connect"
subtitle={`외국인 환자의 수술 전후 동선을 병원 반경 기준으로 연결합니다. 한국관광공사 TourAPI 영문 데이터 ${totalPlaces}`}
dark
>
{/* 출처와 기준점 */}
<div className="mb-8 rounded-2xl bg-white/10 backdrop-blur-sm border border-white/10 p-5">
<div className="grid gap-4 md:grid-cols-3 text-sm">
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">{TOURISM.meta.origin.label}</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.origin.lat}, {TOURISM.meta.origin.lng} · {TOURISM.meta.origin.coordSource}
</div>
</div>
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">{TOURISM.meta.source}</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.fetchedAt.replace('T', ' ')}
</div>
</div>
<div>
<div className="text-xs font-medium text-purple-300 mb-1"> </div>
<div className="text-white/90">
· 5km · ·· 10km
</div>
<div className="text-purple-200 text-xs mt-0.5">
{TOURISM.meta.festivalRadiusKm}km
</div>
</div>
</div>
</div>
{/* 탭 */}
<div className="flex flex-wrap gap-2 mb-8" data-no-print>
<TabButton active={tab === 'route'} onClick={() => setTab('route')}>
</TabButton>
{CATEGORY_ORDER.map((c) => (
<TabButton key={c} active={tab === c} onClick={() => setTab(c)}>
{CATEGORY_META[c].label} {TOURISM.places[c]?.length ?? 0}
</TabButton>
))}
<TabButton active={tab === 'festival'} onClick={() => setTab('festival')}>
{TOURISM.festivals.length}
</TabButton>
</div>
{tab === 'route' && (
<div className="space-y-5">
<ClinicGate
title="회복 단계별 일수"
filled={clinicInput.recovery}
waiting="수술별로 각 단계가 며칠인지는 병원이 정합니다. 아래 '병원 확인 항목'의 회복 일정 칸에 입력하면 이 동선에 날짜가 붙습니다."
/>
{stageBuckets.map(({ stage, minM, items }, i) => (
<motion.div
key={stage.id}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.1 }}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] p-6"
>
<div className="flex items-baseline justify-between gap-3 mb-1 flex-wrap">
<h3 className="text-lg font-bold text-[#0A1128]">{stage.label}</h3>
<span className="text-xs font-medium text-slate-500">
{minM === 0 ? '' : `${fmtDistance(minM)} ~ `}
{fmtDistance(stage.maxDistanceM)}
</span>
</div>
<p className="text-sm text-slate-600 mb-4">{stage.note}</p>
{items.length === 0 ? (
<EmptyNote> .</EmptyNote>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<PlaceCard key={`${p.category}-${p.id}`} place={p} category={p.category} compact />
))}
</div>
)}
</motion.div>
))}
</div>
)}
{tab !== 'route' && tab !== 'festival' && (
<>
{tab === 'restaurant' && (
<div className="mb-5">
<ClinicGate
title="수술 전후 식이 가이드"
filled={clinicInput.diet?.recommended}
waiting="어떤 음식이 회복에 맞는지는 의학 판단이라 병원이 정합니다. 병원이 권장·회피 분류를 입력하면 이 목록에 표시됩니다. 개별 식당의 적합 여부를 저희가 판정하지 않습니다."
/>
</div>
)}
<PlaceGrid items={TOURISM.places[tab] ?? []} category={tab} />
</>
)}
{tab === 'festival' && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{TOURISM.festivals.map((f) => (
<div
key={f.id}
className="bg-white rounded-2xl shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)] transition-shadow overflow-hidden"
>
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
{f.image ? (
<img src={f.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
) : (
<span className="text-xs text-slate-400"> </span>
)}
</div>
<div className="p-5">
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
<TicketFilled size={16} />
<span className="text-xs font-semibold">{f.distanceKm}km</span>
</div>
<h4 className="text-base font-bold text-[#0A1128] mb-1.5">{f.title}</h4>
<p className="text-xs text-slate-500 mb-2">
{fmtDate(f.startDate)} ~ {fmtDate(f.endDate)}
</p>
<p className="text-sm text-slate-600">{f.address}</p>
</div>
</div>
))}
</div>
)}
{/* 한계를 숨기지 않는다 */}
<div className="mt-10 rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-xs font-semibold tracking-wider text-purple-300 mb-3">
</div>
<ul className="space-y-1.5">
{TOURISM.meta.limits.map((l) => (
<li key={l} className="text-sm text-white/80 flex gap-2">
<span className="text-purple-300 shrink-0">·</span>
<span>{l}</span>
</li>
))}
</ul>
</div>
</SectionWrapper>
);
}
/* ────────────────────────── 서브 컴포넌트 ────────────────────────── */
function TabButton({
active,
onClick,
children,
}: {
key?: string;
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
onClick={onClick}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
active
? 'bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white'
: 'bg-white/10 text-white/80 hover:bg-white/20'
}`}
>
{children}
</button>
);
}
function PlaceGrid({ items, category }: { items: TourPlace[]; category: PlaceCategory }) {
if (items.length === 0) return <EmptyNote> .</EmptyNote>;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((p) => (
<PlaceCard key={p.id} place={p} category={category} />
))}
</div>
);
}
function PlaceCard({
place,
category,
compact = false,
}: {
key?: string;
place: TourPlace;
category: PlaceCategory;
compact?: boolean;
}) {
const { Icon, label } = CATEGORY_META[category];
return (
<div
className={`bg-white rounded-2xl overflow-hidden transition-shadow h-full flex flex-col ${
compact
? 'border border-slate-100 shadow-sm hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)]'
: 'shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:shadow-[4px_6px_16px_rgba(0,0,0,0.09)]'
}`}
>
{!compact && (
// 이미지가 없는 항목이 섞여 그리드가 어긋나지 않도록 자리를 항상 차지하게 둔다.
<div className="h-36 bg-slate-100 flex items-center justify-center overflow-hidden">
{place.image ? (
<img src={place.image} alt="" className="w-full h-36 object-cover" loading="lazy" />
) : (
<span className="text-xs text-slate-400"> </span>
)}
</div>
)}
<div className={`flex-1 ${compact ? 'p-4' : 'p-5'}`}>
<div className="flex items-center gap-1.5 mb-2 text-[#6C5CE7]">
<Icon size={16} />
<span className="text-xs font-semibold">{label}</span>
<span className="text-xs text-slate-400">· {fmtDistance(place.distanceM)}</span>
</div>
<h4 className={`font-bold text-[#0A1128] mb-1.5 ${compact ? 'text-sm' : 'text-base'}`}>
{place.title}
</h4>
<p className="text-xs text-slate-500 mb-2">{place.address}</p>
{/* TourAPI 미제공 항목. 채우지 않고 미연동으로 둔다. */}
<div className="text-xs text-slate-400">
{place.rating === null ? '평점·영업시간 미연동' : `평점 ${place.rating}`}
</div>
</div>
</div>
);
}
/** 병원이 채워야 하는 값의 자리. 값이 없으면 비운 채로 무엇이 필요한지 밝힌다. */
function ClinicGate({
title,
filled,
waiting,
}: {
title: string;
filled?: string;
waiting: string;
}) {
const has = Boolean(filled && filled.trim());
return (
<div
className="rounded-2xl border p-5"
style={
has
? { background: '#F3F0FF', borderColor: '#D5CDF5' }
: { background: '#FFF6ED', borderColor: '#F5E0C5' }
}
>
<div className="flex items-center gap-2 mb-2">
<span
className="inline-block w-2.5 h-2.5 rounded-full"
style={{ background: has ? '#9B8AD4' : '#D4A872' }}
/>
<span className="text-sm font-bold" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
{title}
</span>
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full"
style={
has
? { background: '#fff', color: '#4A3A7C' }
: { background: '#fff', color: '#7C5C3A' }
}
>
{has ? '병원 입력됨' : '병원 확인 대기'}
</span>
</div>
<p className="text-sm" style={{ color: has ? '#4A3A7C' : '#7C5C3A' }}>
{has ? filled : waiting}
</p>
</div>
);
}
function EmptyNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-xl bg-white/10 border border-white/10 px-4 py-3 text-sm text-white/70">
{children}
</div>
);
}

View File

@ -324,3 +324,76 @@ export function PrismFilled({ size = 20, className = '' }: IconProps) {
</svg>
);
}
/* ── 의료관광 연결 (숙박·식사·웰니스·관광·축제) ── */
export function BedFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="2" y="10" width="20" height="8" rx="2" fill="currentColor" opacity="0.25" />
<rect x="4" y="6" width="16" height="5" rx="2" fill="currentColor" opacity="0.5" />
<circle cx="7.5" cy="9" r="2" fill="currentColor" />
<rect x="2" y="17" width="3" height="3" rx="1" fill="currentColor" />
<rect x="19" y="17" width="3" height="3" rx="1" fill="currentColor" />
</svg>
);
}
export function ForkFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
<path d="M8 4v6a2 2 0 0 0 2 2v8h1.5v-8a2 2 0 0 0 2-2V4H12v5h-1V4H9.5v5h-1V4H8z" fill="currentColor" />
<path d="M17 4c-1.4 1-2 2.8-2 4.5 0 1.4.6 2.4 1.5 2.9V20H18V4h-1z" fill="currentColor" />
</svg>
);
}
export function SpaFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.25" />
<path d="M12 5c2.2 1.8 3.4 4 3.4 6.2 0 1.3-.4 2.4-1.1 3.2C13.4 13.2 12.7 11.4 12 9c-.7 2.4-1.4 4.2-2.3 5.4-.7-.8-1.1-1.9-1.1-3.2C8.6 9 9.8 6.8 12 5z" fill="currentColor" />
<ellipse cx="12" cy="17.5" rx="5.5" ry="1.6" fill="currentColor" opacity="0.6" />
</svg>
);
}
export function MapPinFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M12 2c-3.9 0-7 3.1-7 7 0 5.2 7 13 7 13s7-7.8 7-13c0-3.9-3.1-7-7-7z" fill="currentColor" opacity="0.25" />
<circle cx="12" cy="9" r="3.2" fill="currentColor" />
</svg>
);
}
export function TicketFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M3 7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2.5a2.5 2.5 0 0 0 0 5V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2.5a2.5 2.5 0 0 0 0-5V7z" fill="currentColor" opacity="0.25" />
<rect x="11.2" y="7" width="1.6" height="3" rx="0.8" fill="currentColor" />
<rect x="11.2" y="12" width="1.6" height="5" rx="0.8" fill="currentColor" />
</svg>
);
}
export function BagFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<path d="M4 8h16l-1.2 12H5.2L4 8z" fill="currentColor" opacity="0.25" />
<path d="M8.5 9V7a3.5 3.5 0 1 1 7 0v2h-2V7a1.5 1.5 0 1 0-3 0v2h-2z" fill="currentColor" />
<rect x="4" y="7" width="16" height="2.4" rx="1.2" fill="currentColor" opacity="0.6" />
</svg>
);
}
export function TheaterFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="4" width="18" height="14" rx="3" fill="currentColor" opacity="0.25" />
<path d="M7 9.5h10v2H7zM7 13h6v2H7z" fill="currentColor" />
<rect x="8" y="18" width="8" height="2" rx="1" fill="currentColor" opacity="0.6" />
</svg>
);
}

1797
src/data/medicalTourism.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ 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 {
@ -358,6 +359,9 @@ export default function DiscoveryReportPage() {
{/* ── 5b. 서포터즈 자동 빌드 · 병원 확인 항목 (light, v2 §7-5) ── */}
<ClinicInputsPanel clinicId={result.id} />
{/* ── 5c. 의료관광 연결 (dark) ── */}
<MedicalTourismPanel clinicId={result.id} />
{/* ── 6. 채점 기준표 (light) ── */}
<SectionWrapper
id="rubric"

View File

@ -0,0 +1,88 @@
/**
* .
*
* 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;
};
}