From dca494fc212a0fa64da37922be12b01fe6e85c60 Mon Sep 17 00:00:00 2001 From: Haewon Kam Date: Fri, 11 Sep 2026 18:28:13 +0900 Subject: [PATCH] =?UTF-8?q?feat(supporters):=20=EC=A3=BC=EB=B3=80=20?= =?UTF-8?q?=EC=95=88=EB=82=B4(/stay=C2=B7/en/stay)=20=EB=B7=B0=20=EC=83=98?= =?UTF-8?q?=ED=94=8C=EC=97=90=20=EC=B6=94=EA=B0=80=20=C2=B7=20=EB=82=B4?= =?UTF-8?q?=EB=B9=84=C2=B7=EC=96=B8=EC=96=B4=20=EC=A7=9D=C2=B7=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EB=A7=B5=C2=B7=EC=8A=A4=ED=83=80=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=8B=9D,=20=EA=B5=AC=EC=A1=B0=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=84=A0=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- supporters/scripts/gate/layout-baseline.json | 3 + supporters/src/data/recoveryNotes.json | 8 + supporters/src/layouts/Base.astro | 2 + supporters/src/lib/i18n.ts | 5 +- supporters/src/pages/en/stay.astro | 514 ++++++++++++++++++ supporters/src/pages/sitemap.xml.ts | 2 + supporters/src/pages/stay.astro | 515 +++++++++++++++++++ supporters/src/styles/global.css | 177 +++++++ supporters/vercel.json | 10 - 9 files changed, 1224 insertions(+), 12 deletions(-) create mode 100644 supporters/src/data/recoveryNotes.json create mode 100644 supporters/src/pages/en/stay.astro create mode 100644 supporters/src/pages/stay.astro diff --git a/supporters/scripts/gate/layout-baseline.json b/supporters/scripts/gate/layout-baseline.json index cd72c76..59ad71b 100644 --- a/supporters/scripts/gate/layout-baseline.json +++ b/supporters/scripts/gate/layout-baseline.json @@ -16,6 +16,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "main", @@ -287,6 +288,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "div.backbar-wrap", @@ -515,6 +517,7 @@ "a", "a", "a", + "a", "a.lang-switch", "a.cta", "div.backbar-wrap", diff --git a/supporters/src/data/recoveryNotes.json b/supporters/src/data/recoveryNotes.json new file mode 100644 index 0000000..1ed4877 --- /dev/null +++ b/supporters/src/data/recoveryNotes.json @@ -0,0 +1,8 @@ +{ + "_comment": "시술 후 관리 안내(빈 값). 워커의 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 채운다. items 가 비어 있으면 /stay·/en/stay 는 이 블록을 그리지 않는다.", + "clinic": "", + "generatedAt": "", + "sourceNote": "병원 홈페이지 시술 페이지 원문", + "items": [], + "counts": {} +} diff --git a/supporters/src/layouts/Base.astro b/supporters/src/layouts/Base.astro index 0add854..8d20129 100644 --- a/supporters/src/layouts/Base.astro +++ b/supporters/src/layouts/Base.astro @@ -63,6 +63,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array {u.nav_posts} {u.nav_videos} {u.nav_news} + {u.nav_stay} {u.nav_visit} {u.nav_plan} {u.nav_about} @@ -70,6 +71,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array ) : ( <> {u.nav_home} + {u.nav_stay} {u.nav_plan} {u.nav_visit} {u.nav_clinic} diff --git a/supporters/src/lib/i18n.ts b/supporters/src/lib/i18n.ts index 030d600..58f55e5 100644 --- a/supporters/src/lib/i18n.ts +++ b/supporters/src/lib/i18n.ts @@ -6,6 +6,7 @@ export type Lang = 'ko' | 'en'; const PAIRS: [string, string][] = [ ['/', '/en'], ['/plan', '/en/plan'], + ['/stay', '/en/stay'], ]; export function switchHref(pathname: string, to: Lang): string { const p = pathname.replace(/\/index\.html$/, '/').replace(/\.html$/, '').replace(/\/$/, '') || '/'; @@ -21,7 +22,7 @@ export const UI: Record> = { ko: { sample: '샘플 사이트입니다. 정식 공개 전입니다.', brand: '서포터즈', - nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개', + nav_posts: '상담 전 질문', nav_videos: '의료진 영상', nav_news: '뉴스룸', nav_stay: '주변 안내', nav_visit: '방문 안내', nav_plan: '회복 일정', nav_about: '매체 소개', cta: '상담 예약', back: '이전 페이지', backAria: '이전 페이지로 돌아가기', totop: '맨 위로', switchLabel: 'EN', switchAria: 'Switch to English', @@ -33,7 +34,7 @@ export const UI: Record> = { en: { sample: 'Sample site. Not yet public.', brand: 'Supporters', - nav_home: 'Home', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기', + nav_home: 'Home', nav_stay: 'Nearby', nav_plan: 'Recovery Planner', nav_visit: 'Getting Here', nav_clinic: 'Clinic Facts', nav_korean: '한국어 전체 보기', cta: 'Book a consultation', back: 'Back', backAria: 'Go back to the previous page', totop: 'Back to top', switchLabel: 'KO', switchAria: '한국어로 보기', diff --git a/supporters/src/pages/en/stay.astro b/supporters/src/pages/en/stay.astro new file mode 100644 index 0000000..da0f913 --- /dev/null +++ b/supporters/src/pages/en/stay.astro @@ -0,0 +1,514 @@ +--- +// 외국인 환자용 주변 안내(/en/stay). /stay 의 영문 짝이다. haewon 결정(2026-09-11): 주변 안내(/en/stay)와 회복 일정 플래너(/en/plan)는 별개 페이지다. +// 1. Around the clinic : 도보·차 소요 시간으로 거른 주변 장소 +// 2. Festivals : 계절로 묶은 행사 +// 3. Suggested days : 시간표와 지도가 붙은 동선 +// + After-treatment care(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장(번역) +// +// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가 +// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다. +// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소·도시는 factSheet, 기준점은 +// medicalTourism.meta.origin 에서만 읽는다. 이 파일에 특정 병원이나 지역 이름을 적지 않는다. +// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "pending" 안내를 그리고 빌드는 그대로 된다. +// +// 만들지 않는 것 +// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다. +// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다. +// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다. +// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다. +import Base from '../../layouts/Base.astro'; +import { fact, site as S, SITE_NAME, clinicSchema, has } from '../../lib'; +import data from '../../data/medicalTourism.json'; +import recovery from '../../data/recoveryNotes.json'; + +const f = fact as Record; +const Sx = S as Record; +const clinic = f.shortNameEn || f.nameEn || f.shortName || 'the clinic'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const T = data as any; +// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 확인 대기로 그린다. +const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string }; +const ready = Boolean(origin.lat && origin.lng); +const total: number = T.filters?.total ?? 0; +const festivals: any[] = T.festivals ?? []; +const updated: string = (T.meta?.fetchedAt || '').slice(0, 10); +// 거리의 기준 주소. 영문 주소가 있으면 그것, 없으면 수집 때 쓴 주소, 그다음 팩트 시트 국문 주소. +const originAddress = f.address?.fullEn || origin.address || f.address?.full || ''; +// 도시 이름은 영문 주소의 마지막 항목("..., Gangnam-gu, Seoul" 이면 Seoul)에서 읽는다. 없으면 Korea. +const cityEn = (f.address?.fullEn || '').split(',').pop()?.trim() || 'Korea'; +const festKm: number = T.meta?.festivalRadiusKm || 0; + +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} ${cityEn}`)}`; +// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다. +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 = origin.lat ?? 0, oLng = origin.lng ?? 0; +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); + +/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다. + 빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다. + 타일은 OSM 정책상 출처를 반드시 표기한다. */ +const TILE = 256, MAPW = 680, MAPH = 340; +const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z; +const latToPx = (lat: number, z: number) => { + const r = (lat * Math.PI) / 180; + return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z; +}; + +function miniMap(stops: any[]) { + 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)]; + // 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다. + let z = 17; + for (; z > 10; z--) { + const w = lngToPx(ln1, z) - lngToPx(ln0, z); + const h = latToPx(la0, z) - latToPx(la1, z); + if (w <= MAPW - 90 && h <= MAPH - 90) break; + } + const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2; + const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2; + const left = cx - MAPW / 2, top = cy - MAPH / 2; + const tiles: Array<{ src: string; x: number; y: number }> = []; + for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) { + for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) { + const n = 2 ** z; + if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue; + tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top }); + } + } + const pins = stops.map((s, i) => ({ + x: +(lngToPx(s.lng, z) - left).toFixed(1), + y: +(latToPx(s.lat, z) - top).toFixed(1), + n: i + 1, label: s.label, + })); + return { tiles, pins, z }; +} + +// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다. +// en 이 비어 있는 문장(번역 실패·키 없음)은 한글 원문을 그대로 보이고 (Korean) 표시를 붙인다. items 가 비어 있으면 블록을 그리지 않는다. +type RecoveryItem = { procedure: string; procedureEn?: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean }; +const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] }; +const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length); +const recoveryDate = RN.generatedAt || ''; + +const allPlaces: Array = 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: `Recovery schedule at ${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), +]; +--- + +
+
+
Nearby
+

Around The Clinic

+

+ Around {clinic}. {ready + ? `Everything below is measured from the clinic at ${originAddress || 'its address'}.` + : 'Distances and walking times will appear once the clinic\'s location is confirmed.'} + Names, photographs and descriptions come from the Korea Tourism Organization. + For the clinic's own hours and phone, see Clinic informationKO. To plan arrival and departure around your surgery date, use the Recovery Planner. +

+ {!ready &&

This guide is pending. Food, hotels, rest, sights and festivals near the clinic will be listed once its address is confirmed.

} +
+ + + + + + + + + {recoveryItems.length > 0 && ( +
+ Show after-treatment care · {recoveryItems.length} treatmentsHide after-treatment care +
+
+

After-treatment care, as published by the clinic

+

From {clinic}'s own treatment pages · {recoveryItems.length} treatments

+
+ {recoveryDate &&
Checked {recoveryDate}
} +
+

These lines are quoted from {clinic}'s own treatment pages (Korean), translated for reference. They differ by treatment and by person. Confirm at your consultation.

+
    + {recoveryItems.map((it, i) => ( +
  1. +
    + + + {i + 1} + + {it.area ? `${it.area} · ` : ''}{it.ko.length} {it.ko.length === 1 ? 'line' : 'lines'} · tap to open +

    {it.procedureEn || it.procedure}

    + {it.procedureEn && {it.procedure}} +
    +
    +
    +
      + {it.ko.map((s, k) => it.en?.[k] + ?
    • {it.en[k]}
    • + :
    • {s} Korean
    • )} +
    +

    Clinic page (Korean) ↗{it.fetchedAt ? ` · checked ${it.fetchedAt}` : ''}

    +
    +
  2. + ))} +
+
+ )} + + +
+
+

Around the clinic

+ {updated ? `Updated ${updated}` : 'Pending'} +
+

{total > 0 ? `${total} places, sorted by how long they take to reach on foot.` : 'Listings will follow once the clinic\'s location is confirmed.'}

+ + {total > 0 &&
+ + + + + +
} + + {GROUPS.map((g) => { + const items = allPlaces.filter((p) => p.cat === g.key); + if (!items.length) return null; + return ( +
+

{g.label} {g.note}

+
+ {items.map((p) => ( +
+
+ {p.image ? {`${p.title} : {shortName(p.title)}} + {p.travel && {p.travel.label} {p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}} +
+
+

{p.title}

+

{firstSentence(p.overview) || 'No description provided by the tourism data.'}

+

{p.address}

+ {p.homepage + ? Official site ↗ + : Open in search ↗} +
+
+ ))} +
+
+ ); + })} +

Source · Korea Tourism Organization TourAPI. Walking time is estimated at 80 m per minute and is not a routed distance.

+
+ + +
+

Festivals and events

+

{festivals.length > 0 + ? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is ` + : 'Events will follow once the clinic\'s location is confirmed. Right now it is '}{nowSeason}.

+ {festivals.length > 0 &&
+ + {SEASONS.map((s) => { + const n = festivals.filter((e: any) => e.season === s.key).length; + return n ? : null; + })} +
} +
+ {festivals.map((e: any) => ( +
+
+ {e.image ? {`${e.title} : {shortName(e.title)}} + {MONTHS[e.month] ?? ''} +
+
+

{e.title}

+

{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? e.travel.label : `${e.distanceKm} km`}

+

{firstSentence(e.overview) || 'No description provided by the tourism data.'}

+

{e.address}

+ Open in search ↗ +
+
+ ))} +
+

Source · Korea Tourism Organization TourAPI. Dates can change at the organiser's discretion.

+
+ + +
+

Shaping your days

+

+ 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. +

+ {Sx.recoveryStagesEn + ?

What {clinic} advises. {Sx.recoveryStagesEn}

+ :

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.

} + + {ROUTES.map((r) => { + const pins = miniMap(r.route.stops); + return ( +
+
+
+

{r.title}

+

{r.who}

+
+
{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60)}h {r.route.total % 60}m
+
+

{r.blurb}

+ + ); + })} +
+ +
+

What this page does not tell you

+
    +
  • No ratings, review counts or opening hours. The tourism data does not carry them.
  • +
  • No claim that any place suits your recovery. That is for {clinic} to say.
  • +
  • {total > 0 + ? `English tourism listings near the clinic are limited. Only ${T.filters.walk5} places sit within a five minute walk, and ${T.filters.drive} of ${total} need a car.` + : 'Listings near the clinic are pending until its location is confirmed.'}
  • +
  • We are not a travel agency, take no booking and no commission.
  • +
+

+ {has(f.urlEn) && Book a consultation in English} +

+

{f.sideEffectNoticeEn || f.sideEffectNotice}

+
+
+ + + diff --git a/supporters/src/pages/sitemap.xml.ts b/supporters/src/pages/sitemap.xml.ts index 5a6ffd9..574b8bf 100644 --- a/supporters/src/pages/sitemap.xml.ts +++ b/supporters/src/pages/sitemap.xml.ts @@ -12,6 +12,8 @@ export const GET: APIRoute = async ({ site }) => { { loc: `${base}/clinic`, lastmod: today }, { loc: `${base}/visit`, lastmod: today }, { loc: `${base}/en`, lastmod: today }, + { loc: `${base}/stay`, lastmod: today }, + { loc: `${base}/en/stay`, lastmod: today }, { loc: `${base}/plan`, lastmod: today }, { loc: `${base}/en/plan`, lastmod: today }, { loc: `${base}/newsroom`, lastmod: today }, diff --git a/supporters/src/pages/stay.astro b/supporters/src/pages/stay.astro new file mode 100644 index 0000000..edb22f8 --- /dev/null +++ b/supporters/src/pages/stay.astro @@ -0,0 +1,515 @@ +--- +// 주변 안내(한국어). /en/stay 의 한국어 짝이다. haewon 결정(2026-09-11): 주변 안내(/stay)와 회복 일정 플래너(/plan)는 별개 페이지다. 이 페이지는 병원 주변 장소·축제·동선이고, 시술 후 관리 안내는 접이식으로 둔다. 데이터는 국문 서비스(KorService2)라 +// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건). +// 구조는 /en/stay 와 같다. 세 부분이다. +// 1. 병원 주변 : 도보·차 소요 시간으로 거른 주변 장소 +// 2. Festivals : 계절로 묶은 행사 +// 3. Suggested days : 시간표와 지도가 붙은 동선 +// + 시술 후 관리 안내(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장 +// +// 데이터는 한국관광공사 TourAPI 국문(KorService2)에서 scripts/fetch_medical_tourism.py 가 +// 미리 수집한 src/data/medicalTourismKo.json 이다. 설명문은 detailCommon2 의 overview 다. +// 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소는 factSheet, 기준점은 +// medicalTourismKo.meta.origin 에서만 읽는다. 이 파일에 특정 병원을 적지 않는다. +// 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "확인 대기"를 그리고 빌드는 그대로 된다. +// +// 만들지 않는 것 +// - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다. +// - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다. +// - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다. +// - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다. +import Base from '../layouts/Base.astro'; +import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib'; +import data from '../data/medicalTourismKo.json'; +import recovery from '../data/recoveryNotes.json'; + +const f = fact as Record; +const Sx = S as Record; +const clinic = f.shortName || '병원'; +const site = Astro.site!.toString().replace(/\/$/, ''); +const T = data as any; +// 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 "확인 대기"로 그린다. +const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string }; +const ready = Boolean(origin.lat && origin.lng); +const total: number = T.filters?.total ?? 0; +const festivals: any[] = T.festivals ?? []; +const updated: string = (T.meta?.fetchedAt || '').slice(0, 10); +// 거리의 기준 주소. 수집 때 쓴 주소가 있으면 그것, 없으면 팩트 시트 주소. +const originAddress = origin.address || f.address?.full || ''; +const festKm: number = T.meta?.festivalRadiusKm || 0; + +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: '식사', note: '음식점과 카페' }, + { key: 'stay', label: '숙박', note: '묵을 곳' }, + { key: 'wellness', label: '휴식', note: '스파와 찜질방' }, + { key: 'attraction', label: '관광', note: '거리, 공원, 명소' }, + { key: 'culture', label: '문화', note: '미술관, 공연장' }, + { key: 'shopping', label: '쇼핑', note: '백화점과 시장' }, +]; + +const SEASONS = [ + { key: 'spring', label: '봄' }, { key: 'summer', label: '여름' }, + { key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' }, +]; +const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; +const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()]; +const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record)[nowSeasonKey]; + +// 검색으로 보낸다. 없는 URL 을 만들지 않는다. +// 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다. +const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; +// 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다. +const dt = (d: string | null) => (d && d.length === 8 ? `${+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(); +}; +// travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다. +const travelLabel = (t: { mode: string; minutes: number } | null) => + !t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`; +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 = origin.lat ?? 0, oLng = origin.lng ?? 0; +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: '진료 다녀오는 날', who: '병원에 자주 오는 동안', + blurb: '전부 걸어서 갈 수 있는 곳입니다. 택시가 필요 없습니다.', + route: buildRoute(15 * 60, [ + clinicStop(clinic, 40, '진료가 끝나는 곳입니다.'), + ...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: '가까운 곳 나들이', who: '조금 더 걸을 수 있을 때', + blurb: '가깝지만 한 블록 밖입니다.', + route: buildRoute(13 * 60, [ + clinicStop(clinic, 20, '병원에서 출발합니다.'), + ...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: '멀리 나갈 수 있을 때', who: '체류 후반에', + blurb: '시내 곳곳입니다. 택시나 지하철을 탑니다.', + route: buildRoute(10 * 60, [ + clinicStop(clinic, 10, '병원에서 나섭니다.'), + ...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); + +/* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다. + 빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다. + 타일은 OSM 정책상 출처를 반드시 표기한다. */ +const TILE = 256, MAPW = 680, MAPH = 340; +const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z; +const latToPx = (lat: number, z: number) => { + const r = (lat * Math.PI) / 180; + return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z; +}; + +function miniMap(stops: any[]) { + 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)]; + // 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다. + let z = 17; + for (; z > 10; z--) { + const w = lngToPx(ln1, z) - lngToPx(ln0, z); + const h = latToPx(la0, z) - latToPx(la1, z); + if (w <= MAPW - 90 && h <= MAPH - 90) break; + } + const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2; + const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2; + const left = cx - MAPW / 2, top = cy - MAPH / 2; + const tiles: Array<{ src: string; x: number; y: number }> = []; + for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) { + for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) { + const n = 2 ** z; + if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue; + tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top }); + } + } + const pins = stops.map((s, i) => ({ + x: +(lngToPx(s.lng, z) - left).toFixed(1), + y: +(latToPx(s.lat, z) - top).toFixed(1), + n: i + 1, label: s.label, + })); + return { tiles, pins, z }; +} + +// 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다. +// 병원 확정값(site.recoveryStagesKo)과 역할이 다르다. 확정값은 "병원 안내" 문단, 이것은 원문 인용이라 출처 링크와 확인일을 붙인다. +// items 가 비어 있으면(템플릿 기본값) 블록을 아예 그리지 않는다. +type RecoveryItem = { procedure: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean }; +const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] }; +const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length); +const recoveryDate = RN.generatedAt || ''; + +const allPlaces: Array = GROUPS.flatMap((g) => + (((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key }))); + +const ld = [ + { '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, + clinicSchema(site), +]; +--- + +
+
+
Nearby
+

Around The Clinic

+

+ {clinic} 주변입니다. {ready + ? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.` + : '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'} + 상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 병원 정보에, 수술일 기준 입국·출국 계산은 회복 일정에 있습니다. +

+ {!ready &&

주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.

} +
+ + + + + + + + + {recoveryItems.length > 0 && ( +
+ 시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개시술 후 관리 안내 접기 +
+
+

시술 후 관리 안내

+

{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개

+
+ {recoveryDate &&
{recoveryDate} 확인
} +
+

아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.

+
    + {recoveryItems.map((it, i) => ( +
  1. +
    + + + {i + 1} + + {it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기 +

    {it.procedure}

    +
    +
    +
    +
      + {it.ko.map((s) =>
    • {s}
    • )} +
    +

    병원 페이지 보기 ↗{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}

    +
    +
  2. + ))} +
+
+ )} + + +
+
+

병원 주변

+ {updated ? `${updated} 갱신` : '확인 대기'} +
+

{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}

+ + {total > 0 &&
+ + + + + +
} + + {GROUPS.map((g) => { + const items = allPlaces.filter((p) => p.cat === g.key); + if (!items.length) return null; + return ( +
+

{g.label} {g.note}

+
+ {items.map((p) => ( +
+
+ {p.image ? {`${p.title}, : {shortName(p.title)}} + {p.travel && {travelLabel(p.travel)} {p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}} +
+
+

{p.title}

+

{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}

+

{p.address}

+ {p.homepage + ? 공식 홈페이지 ↗ + : 검색으로 열기 ↗} +
+
+ ))} +
+
+ ); + })} +

출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.

+
+ + +
+

계절별 축제

+

{festivals.length > 0 + ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` + : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}{nowSeason}입니다.

+ {festivals.length > 0 &&
+ + {SEASONS.map((s) => { + const n = festivals.filter((e: any) => e.season === s.key).length; + return n ? : null; + })} +
} +
+ {festivals.map((e: any) => ( +
+
+ {e.image ? {`${e.title}, : {shortName(e.title)}} + {MONTHS[e.month] ?? ''} +
+
+

{e.title}

+

{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}

+

{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}

+

{e.address}

+ 검색으로 열기 ↗ +
+
+ ))} +
+

출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.

+
+ + +
+

하루 동선

+

+ 위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다. +

+ {Sx.recoveryStagesKo + ?

{clinic} 안내. {Sx.recoveryStagesKo}

+ :

어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.

} + + {ROUTES.map((r) => { + const pins = miniMap(r.route.stops); + return ( +
+
+
+

{r.title}

+

{r.who}

+
+
{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분
+
+

{r.blurb}

+ + ); + })} +
+ +
+

이 페이지가 답하지 못하는 것

+
    +
  • 평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.
  • +
  • 어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.
  • +
  • {total > 0 + ? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.` + : '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}
  • +
  • 여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.
  • +
+

+ {has(reservationUrl) && {clinic} 상담 예약} +

+

{f.sideEffectNoticeEn || f.sideEffectNotice}

+
+
+ + + diff --git a/supporters/src/styles/global.css b/supporters/src/styles/global.css index 14a1f51..10052f0 100644 --- a/supporters/src/styles/global.css +++ b/supporters/src/styles/global.css @@ -279,3 +279,180 @@ td { color: var(--slate-700); } .site-header .nav a { margin-left: 0; flex: none; font-size: 0.9rem; } } +/* ── /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; } +/* 시술 후 관리 안내(병원 원문 인용). 동선 카드(.route)·정차(.stop) 룩을 그대로 쓰고 접이식 동작만 더한다. 새 색·크기 없음. */ +.stay details.recovery > summary.btn { list-style: none; cursor: pointer; } +.stay details.recovery > summary.btn::-webkit-details-marker { display: none; } +.stay details.recovery > summary.btn .hide { display: none; } +.stay details.recovery[open] > summary.btn .show { display: none; } +.stay details.recovery[open] > summary.btn .hide { display: inline; } +.stay details.recovery[open] > summary.btn { margin-bottom: 1.1rem; } +.stay details.recovery > .timeline { margin-top: 0.2rem; } +.stay .recovery .timeline > li { border-top: 1px solid var(--line, #E2E8F0); padding: 0.7rem 0; } +.stay .recovery .timeline > li:first-child { border-top: 0; padding-top: 0; } +.stay .recovery summary { cursor: pointer; list-style: none; } +.stay .recovery summary::-webkit-details-marker { display: none; } +.stay .recovery summary .stop { display: flex; } +.stay .recovery summary h4 { margin: 0.1rem 0 0; } +.stay .recovery details[open] summary h4 { text-decoration: underline; text-underline-offset: 0.2em; text-decoration-color: var(--slate-200, #E2E8F0); } +.stay .recovery details > ul { margin: 0.7rem 0 0.5rem 2.2rem; padding-left: 1.1rem; } +.stay .recovery details > ul li { font-size: 0.9rem; color: var(--slate-700, #334155); margin-bottom: 0.35rem; } +.stay .recovery details > p { margin: 0 0 0 2.2rem; font-size: 0.84rem; color: var(--slate-500, #64748B); } +@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; } + +/* 동선 지도. OSM 타일을 좌표로 깔고 그 위에 핀을 얹는다. 출처 표기는 OSM 정책상 필수다. */ +.stay .route .map { + position: relative; width: 100%; max-width: 680px; height: 340px; + border-radius: 12px; overflow: hidden; background: #E8EDF3; + border: 1px solid var(--line, #E2E8F0); +} +.stay .route .map-tiles { position: absolute; inset: 0; } +.stay .route .map-tiles .tile { position: absolute; width: 256px; height: 256px; background-size: 256px 256px; background-repeat: no-repeat; } +.stay .route .map-pins { position: absolute; inset: 0; width: 100%; height: 100%; } +.stay .route .map-credit { + position: absolute; right: 0; bottom: 0; + background: rgba(255, 255, 255, 0.82); color: var(--slate-600, #475569); + font-size: 0.66rem; padding: 0.1rem 0.35rem; border-top-left-radius: 6px; +} + diff --git a/supporters/vercel.json b/supporters/vercel.json index 08f59b9..d6258b7 100644 --- a/supporters/vercel.json +++ b/supporters/vercel.json @@ -28,20 +28,10 @@ "destination": "/plan", "permanent": true }, - { - "source": "/stay", - "destination": "/plan", - "permanent": true - }, { "source": "/en/recovery", "destination": "/en/plan", "permanent": true - }, - { - "source": "/en/stay", - "destination": "/en/plan", - "permanent": true } ] }