feat(supporters): 회복 일정 플래너 /plan · /en/plan · 시술별 회복 규칙표, 큐레이션 장소, 예약 딥링크, 헤더 내비 연결

- src/pages/plan.astro, src/pages/en/plan.astro: 한국어·영어 페이지, hreflang 상호 연결
- src/components/Planner.astro: 5단계 흐름(언어·국적 → 시술·수술일 → 선호 → 일정표 → 예약·저장), 브라우저 안 계산, URL 해시 공유, ICS 저장
- src/lib/plan.ts: 입국 권장일·출국 가능 최소일·외출 등급·식사 단계·추천 점수·딥링크·ICS 순수 함수
- src/data/procedures.json: 시술 8종 규칙표. 값마다 clinic/provisional 표시, 병원 확인 전 잠정값은 화면 배지
- src/data/places.json: 신논현역 기준 큐레이션 31곳 (구글 지도·예약 사이트 검색 링크만)
- src/data/planStrings.json: UI 문자열 ko/en
- scripts/gate/plan.test.mjs: 계산·데이터 정합성 검증 11건
- Base.astro: lang·alternates prop, 내비 '회복 일정', 푸터 링크. 구조 기준선 갱신(haewon 승인)
- visit.astro: 플래너 진입 CTA 카드. sitemap에 /plan, /en/plan 추가
- 설계 근거: docs/INFINITH_Viewclinic_Supporters_Site_Design_v0.2.md §13

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-10 16:00:26 +09:00
parent 2eb3d5cd06
commit e40f9ceb94
13 changed files with 1894 additions and 4 deletions

View File

@ -15,6 +15,7 @@
"a",
"a",
"a",
"a",
"a.cta",
"main",
"section.hero-light",
@ -255,6 +256,8 @@
"a",
"a",
"a",
"a",
"a",
"div",
"p",
"img",
@ -283,6 +286,7 @@
"a",
"a",
"a",
"a",
"a.cta",
"div.backbar-wrap",
"div.backbar.wrap-wide",
@ -480,6 +484,8 @@
"a",
"a",
"a",
"a",
"a",
"div",
"p",
"img",
@ -508,6 +514,7 @@
"a",
"a",
"a",
"a",
"a.cta",
"div.backbar-wrap",
"div.backbar.wrap-wide",
@ -731,6 +738,8 @@
"a",
"a",
"a",
"a",
"a",
"div",
"p",
"img",

View File

@ -0,0 +1,98 @@
// 회복기 스케줄러 계산 로직 검증. node --experimental-strip-types scripts/gate/plan.test.mjs
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
import { buildPlan, addDays, decodeHash, encodeHash, bookingUrl, googleFlightsUrl, buildIcs } from '../../src/lib/plan.ts';
const here = (p) => new URL(p, import.meta.url);
const P = JSON.parse(readFileSync(here('../../src/data/procedures.json'), 'utf8'));
const PL = JSON.parse(readFileSync(here('../../src/data/places.json'), 'utf8'));
const byId = (id) => P.procedures.find((x) => x.id === id);
const nat = (code) => P.nationalities.list.find((x) => x.code === code);
let n = 0;
const ok = (name, fn) => { fn(); n++; console.log(` ✓ ${name}`); };
ok('addDays: 월 경계', () => { assert.equal(addDays('2026-10-30', 3), '2026-11-02'); assert.equal(addDays('2026-11-01', -2), '2026-10-30'); });
ok('가슴: 입국일·실밥·출국·체류', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['shopping'] }, PL.places);
assert.equal(plan.arriveBy, '2026-11-01');
assert.equal(plan.stitchDate, '2026-11-10');
assert.equal(plan.earliestDeparture, '2026-11-13');
assert.equal(plan.stayNights, 12);
assert.equal(plan.days[0].isArrival, true);
assert.equal(plan.days.at(-1).isDeparture, true);
});
ok('장거리 국적은 준비일 +1', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: nat('US'), prefs: [] }, PL.places);
assert.equal(plan.arriveBy, '2026-10-31');
assert.equal(plan.preOpDays, 3);
});
ok('윤곽: 유동식 단계에는 죽·스무디만 추천', () => {
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: nat('JP'), prefs: ['food', 'shopping'] }, PL.places);
const day5 = plan.days.find((d) => d.offset === 5);
assert.equal(day5.level, 1);
assert.equal(day5.diet, 'liquid');
for (const pick of day5.picks) {
assert.ok(pick.minLevel <= 1, `${pick.id} 등급 초과`);
if (pick.category === 'food') assert.ok(pick.dietOk.includes('liquid'), `${pick.id} 유동식 불가`);
}
assert.equal(plan.stitchDate, null); // 녹는 실
});
ok('수술 당일·입원일·L0 은 추천 없음', () => {
const plan = buildPlan({ procedure: byId('breast'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
const d0 = plan.days.find((d) => d.offset === 0), d1 = plan.days.find((d) => d.offset === 1);
assert.deepEqual(d0.picks, []); assert.equal(d0.isSurgery, true);
assert.equal(d1.level, 0); assert.deepEqual(d1.picks, []);
});
ok('호텔은 일자 추천에 나오지 않음', () => {
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: ['care'] }, PL.places);
for (const d of plan.days) for (const p of d.picks) assert.notEqual(p.category, 'hotel');
});
ok('연속 이틀 같은 곳 반복 억제', () => {
const plan = buildPlan({ procedure: byId('eye'), surgeryDate: '2026-11-03', nationality: null, prefs: ['sightseeing'] }, PL.places);
const out = plan.days.filter((d) => d.picks.length && d.offset > 0);
let repeats = 0;
for (let i = 1; i < out.length; i++) for (const p of out[i].picks) if (out[i - 1].picks.some((q) => q.id === p.id)) repeats++;
assert.ok(repeats <= out.length, `연속 반복 ${repeats}`);
});
ok('해시 왕복', () => {
const s = { p: 'nose', d: '2026-12-01', l: 'en', n: 'US', pref: ['kpop', 'food'] };
assert.deepEqual(decodeHash('#' + encodeHash(s)), s);
assert.equal(decodeHash('#d=notadate').d, undefined);
});
ok('예약 링크 형식', () => {
const u = new URL(bookingUrl('Hotel Cappuccino Seoul', '2026-11-01', '2026-11-13', 'en'));
assert.equal(u.hostname, 'www.booking.com');
assert.equal(u.searchParams.get('checkin'), '2026-11-01');
assert.ok(googleFlightsUrl('2026-11-01', '2026-11-13', 'en').startsWith('https://www.google.com/travel/flights'));
});
ok('ICS 에 일정 4개', () => {
const plan = buildPlan({ procedure: byId('nose'), surgeryDate: '2026-11-03', nationality: null, prefs: [] }, PL.places);
const ics = buildIcs(plan, { arrive: 'Arrive', surgery: 'Surgery', stitch: 'Stitch', departure: 'Depart' }, 'Test');
assert.equal((ics.match(/BEGIN:VEVENT/g) || []).length, 4);
assert.ok(ics.includes('DTSTART;VALUE=DATE:20261103'));
});
ok('규칙표 정합성: 숫자마다 status, provisional 은 source 없어도 됨, clinic 은 source 필수', () => {
for (const p of P.procedures) {
for (const k of ['preOpDays', 'hospitalNights', 'stitchRemovalDay', 'earliestDepartureDay']) {
assert.ok(['clinic', 'provisional'].includes(p[k].status), `${p.id}.${k} status`);
if (p[k].status === 'clinic') assert.ok(p[k].source, `${p.id}.${k} clinic 인데 source 없음`);
}
for (const s of [...p.outingLevels, ...p.diet]) if (s.status === 'clinic') assert.ok(s.source, `${p.id} 단계 clinic 인데 source 없음`);
for (const c of p.constraints) assert.ok(P.constraints[c], `${p.id} 미정의 제약 ${c}`);
for (const s of p.sources) assert.ok(P.clinicSources.some((x) => x.id === s), `${p.id} 미정의 출처 ${s}`);
}
for (const pl of PL.places) { assert.ok(PL.categories[pl.category], `${pl.id} 카테고리`); assert.ok(pl.name.ko && pl.name.en, `${pl.id} 이름`); }
});
console.log(`plan.test: ${n}건 통과`);

View File

@ -0,0 +1,241 @@
---
// 회복기 스케줄러 본문 (설계 v0.2 §13). /plan(한국어)과 /en/plan(영어)이 같은 컴포넌트를 lang 만 바꿔 쓴다.
// 건강정보(시술·수술일)는 서버로 보내지 않는다. 상태는 URL 해시로만 공유하고, 언어 전환은 해시를 들고 다른 언어 페이지로 이동한다.
import '../styles/plan.css';
import strings from '../data/planStrings.json';
interface Props { lang: 'ko' | 'en' }
const { lang } = Astro.props;
const t = strings[lang];
---
<article class="wrap-wide">
<header class="plan-hero">
<div class="lang" id="lang-top" role="group" aria-label="Language"><a href="/plan" data-lang="ko" class={lang === 'ko' ? 'on' : ''}>한국어</a><a href="/en/plan" data-lang="en" class={lang === 'en' ? 'on' : ''}>English</a></div>
<div class="eyebrow" id="plan-eyebrow">{t.eyebrow}</div>
<h1 class="serif" id="plan-h1">{t.h_hero}</h1>
<p class="lede"><strong id="plan-title">{t.title}</strong> <span id="plan-lede">{t.lede}</span></p>
</header>
<div id="planner" data-lang={lang}>
<noscript><p class="notice">{lang === 'ko' ? '이 일정표는 브라우저에서 계산합니다. 자바스크립트를 켜 주세요.' : 'This planner runs in your browser. Please enable JavaScript.'}</p></noscript>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</article>
<script>
import P from '../data/procedures.json';
import PL from '../data/places.json';
import STR from '../data/planStrings.json';
import factSheet from '../data/factSheet.json';
import { buildPlan, encodeHash, decodeHash, bookingUrl, googleFlightsUrl, mapUrl, buildIcs, summaryText, isIsoDate, addDays } from '../lib/plan';
import type { Lang, Procedure, Place, Nationality, Plan, PlanDay } from '../lib/plan';
type Strings = Record<string, string>;
const procedures = P.procedures as unknown as Procedure[];
const places = PL.places as unknown as Place[];
const nationalities = P.nationalities.list as Nationality[];
const PREFS = ['sightseeing', 'shopping', 'food', 'kpop', 'care'];
const root = document.getElementById('planner')!;
const pageLang = (root.dataset.lang === 'en' ? 'en' : 'ko') as Lang;
const pageUrl = (l: Lang) => (l === 'en' ? '/en/plan' : '/plan');
const toastEl = document.getElementById('toast')!;
const reservationUrl: string = (factSheet as any).reservationUrl || (factSheet as any).url || '/visit';
interface State { lang: Lang; nat: string; proc: string; date: string; prefs: string[] }
const initial = decodeHash(location.hash);
const state: State = {
lang: pageLang,
nat: initial.n ?? '',
proc: initial.p && procedures.some((p) => p.id === initial.p) ? initial.p : '',
date: initial.d ?? '',
prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)),
};
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
const T = (): Strings => (STR as any)[state.lang];
const fill = (s: string, vars: Record<string, string | number>) => Object.entries(vars).reduce((acc, [k, val]) => acc.replaceAll(`{${k}}`, String(val)), s);
const fmtDate = (iso: string) => new Intl.DateTimeFormat(state.lang === 'ko' ? 'ko-KR' : 'en-GB', { month: 'short', day: 'numeric', weekday: 'short', timeZone: 'UTC' }).format(new Date(`${iso}T00:00:00Z`));
const fmtLong = (iso: string) => new Intl.DateTimeFormat(state.lang === 'ko' ? 'ko-KR' : 'en-GB', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }).format(new Date(`${iso}T00:00:00Z`));
const L = (o: any) => esc(o?.[state.lang] ?? '');
const currentProc = () => procedures.find((p) => p.id === state.proc) ?? null;
const currentNat = () => nationalities.find((n) => n.code === state.nat) ?? null;
const head = (eyebrow: string, en: string, sub: string) => `<div class="phead"><div class="eyebrow">${esc(eyebrow)}</div><h2 class="serif">${esc(en)}</h2>${sub && sub !== en ? `<p class="sub">${esc(sub)}</p>` : ''}</div>`;
const fmtKpi = (iso: string) => { const d = new Date(`${iso}T00:00:00Z`); const wd = new Intl.DateTimeFormat(state.lang === 'ko' ? 'ko-KR' : 'en-GB', { weekday: 'short', timeZone: 'UTC' }).format(d); return `<span class="num">${iso.replace(/-/g, '.')}</span><span class="wd">${esc(wd)}</span>`; };
const statusTag = (st: string) => st === 'clinic' ? `<span class="tag clinic">${esc(T().clinicSourced)}</span>` : `<span class="tag prov">${esc(T().provisional)}</span>`;
function syncHash() {
const h = encodeHash({ p: state.proc || undefined, d: state.date || undefined, l: state.lang, n: state.nat || undefined, pref: state.prefs });
history.replaceState(null, '', `#${h}`);
}
function toast(msg: string) { toastEl.textContent = msg; toastEl.classList.add('show'); setTimeout(() => toastEl.classList.remove('show'), 1600); }
// ---------- 입력 단계 ----------
function renderSteps(): string {
const t = T();
const natOpts = [`<option value="">${state.lang === 'ko' ? '선택' : 'Select'}</option>`].concat(nationalities.map((n) => `<option value="${n.code}" ${n.code === state.nat ? 'selected' : ''}>${L(n.label)}</option>`)).join('');
const procBtns = procedures.map((p) => `<button type="button" data-action="proc" data-id="${p.id}" class="${p.id === state.proc ? 'on' : ''}" aria-pressed="${p.id === state.proc}"><small>${L(p.group)}</small><b>${L(p.label)}</b></button>`).join('');
const prefBtns = PREFS.map((k) => `<button type="button" data-action="pref" data-id="${k}" class="${state.prefs.includes(k) ? 'on' : ''}" aria-pressed="${state.prefs.includes(k)}">${esc(t[`pref_${k}`])}</button>`).join('');
const minDate = new Date().toISOString().slice(0, 10);
return `
<div class="plan-steps">
<section class="step">
${head("Step 1", t.h_step1, t.step1)}
<div class="field-row">
<div class="field"><label>${esc(t.lang)}</label><div class="lang"><a href="/plan" data-lang="ko" class="${state.lang === 'ko' ? 'on' : ''}">한국어</a><a href="/en/plan" data-lang="en" class="${state.lang === 'en' ? 'on' : ''}">English</a></div></div>
<div class="field"><label for="nat">${esc(t.nationality)}</label><select id="nat" data-action="nat">${natOpts}</select></div>
</div>
</section>
<section class="step">
${head("Step 2", t.h_step2, t.step2)}
<div class="field"><label>${esc(t.procedure)}</label><div class="proc-grid">${procBtns}</div></div>
<div class="field"><label for="sdate">${esc(t.surgeryDate)}</label><input id="sdate" type="date" data-action="date" value="${esc(state.date)}" min="${minDate}" /><p class="hint">${esc(t.surgeryDateHint)}</p></div>
</section>
<section class="step">
${head("Step 3", t.h_step3, t.step3)}
<div class="pref-row">${prefBtns}</div>
<p class="hint">${esc(t.prefs)}</p>
<div class="plan-actions"><button type="button" class="btn primary" data-action="go">${esc(t.generate)}</button><button type="button" class="btn ghost small" data-action="reset">${esc(t.reset)}</button></div>
</section>
</div>`;
}
// ---------- 결과 ----------
function dayLabel(d: PlanDay): string {
const t = T();
if (d.offset < 0) return fill(t.dayBefore, { n: -d.offset });
if (d.offset === 0) return t.daySurgery;
return fill(t.dayAfter, { n: d.offset });
}
function eventLabel(d: PlanDay): string {
const t = T();
const ev: string[] = [];
if (d.isArrival) ev.push(t.arriveBy);
if (d.isSurgery) ev.push(t.surgery);
if (d.isHospital && !d.isSurgery) ev.push(t.hospital);
if (d.isStitch) ev.push(t.stitch);
if (d.isDeparture) ev.push(t.departure);
return ev.join(' · ');
}
function pickHtml(p: Place): string {
const t = T();
const meta = `<small>${p.distanceKm <= 0.6 ? esc(t.walk) : esc(t.taxi)} ${esc(fill(t.km, { km: p.distanceKm }))}${p.indoor ? ' · ' + esc(t.indoor) : ''}</small>`;
return p.mapQuery ? `<a href="${mapUrl(p.mapQuery)}" rel="noopener" target="_blank" title="${L(p.note)}">${L(p.name)} ${meta}</a>` : `<span class="pick" title="${L(p.note)}">${L(p.name)} ${meta}</span>`;
}
function renderResult(plan: Plan, p: Procedure): string {
const t = T();
const nat = currentNat();
const levels = (P as any).levels as { level: number; label: any; desc: any }[];
const diets = (P as any).diets as Record<string, { label: any; desc: any }>;
const lv = (n: number) => levels.find((x) => x.level === n)!;
const hn = p.hospitalNights.value;
const kpi = (label: string, valueHtml: string | null, st: string, sub?: string) => `<div class="k"><span>${esc(label)}</span><b>${valueHtml === null ? esc(t.pending) : valueHtml}</b><small>${statusTag(st)}${sub ? ' ' + esc(sub) : ''}</small></div>`;
const num = (n: string | number, unit: string) => `<span class="num">${esc(n)}</span><span class="wd">${esc(unit)}</span>`;
const stitchDisplay = (p.stitchRemovalDay as any).display?.[state.lang];
const kpis = [
kpi(t.arriveBy, fmtKpi(plan.arriveBy), p.preOpDays.status, p.preOpDays.note ? (p.preOpDays.note as any)[state.lang] : undefined),
kpi(t.surgery, fmtKpi(plan.surgeryDate), 'clinic'),
kpi(t.hospital, hn === null ? null : hn === 0 ? esc(t.hospitalNone) : num(hn, fill(t.hospitalNights, { n: '' }).trim()), p.hospitalNights.status),
kpi(t.stitch, plan.stitchDate ? fmtKpi(plan.stitchDate) : stitchDisplay ? esc(stitchDisplay) : null, p.stitchRemovalDay.status, p.stitchRemovalDay.note ? (p.stitchRemovalDay.note as any)[state.lang] : undefined),
kpi(t.departure, plan.earliestDeparture ? fmtKpi(plan.earliestDeparture) : null, p.earliestDepartureDay.status),
kpi(t.stayNights, plan.stayNights === null ? null : num(plan.stayNights, fill(t.nights, { n: '' }).trim()), p.earliestDepartureDay.status),
].join('');
// 체류·비자
let visa = '';
if (nat) {
const stay = plan.stayNights === null ? null : plan.stayNights + 1;
const country = (nat.label as any)[state.lang];
let cls = 'ok', msg = '';
if (nat.visaFreeDays === null) { cls = 'warn'; msg = fill(t.visaRequired, { country }); }
else if (stay !== null && stay > nat.visaFreeDays) { cls = 'warn'; msg = fill(t.visaOver, { country, days: nat.visaFreeDays, stay }); }
else if (stay !== null) { msg = fill(t.visaOk, { country, days: nat.visaFreeDays, stay }); }
if (msg) visa = `<section class="pcard ${cls}">${head("Check", t.h_visa, t.visaTitle)}<p>${esc(msg)}</p><p class="fine">${esc(t.visaVerify)} <a href="${esc((P as any).nationalities.portal)}" rel="noopener" target="_blank">${esc(t.visaPortal)}</a></p></section>`;
}
const legend = `<div class="legend">${levels.map((l) => `<span><span class="tag l${l.level}">${L(l.label)}</span>${L(l.desc)}</span>`).join('')}</div>`;
const days = plan.days.map((d) => {
const ev = eventLabel(d);
const lvl = lv(d.level);
const picks = d.picks.length ? `<div class="picks">${d.picks.map(pickHtml).join('')}</div>` : d.level === 0 || d.isSurgery ? `<div class="rest">${esc(t.noPicks)}</div>` : '';
const dietTag = d.offset >= 0 && d.diet !== 'normal' ? `<span class="tag diet" title="${L(diets[d.diet].desc)}">${esc(t.diet)}: ${L(diets[d.diet].label)}</span>` : '';
return `<div class="day ${ev ? 'mark' : ''}"><div class="d"><b>${esc(fmtDate(d.date))}</b><span>${esc(dayLabel(d))}</span>${ev ? `<span class="ev">${esc(ev)}</span>` : ''}</div><div class="body"><div class="tags"><span class="tag l${d.level}">${esc(t.level)}: ${L(lvl.label)}</span>${dietTag}<span class="desc">${L(lvl.desc)}</span></div>${picks}</div></div>`;
}).join('');
const cons = p.constraints.map((c) => `<li>${L((P as any).constraints[c])}</li>`).join('');
const stepNotes = p.outingLevels.filter((s) => s.note).map((s) => `<li>${L(s.note)} ${statusTag(s.status)}</li>`).join('');
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
const hotels = places.filter((x) => x.category === 'hotel').sort((a, b) => a.distanceKm - b.distanceKm).map((h) => `
<div class="hotel"><b>${L(h.name)}</b><small>${L(h.area)} · ${esc(fill(t.km, { km: h.distanceKm }))}</small><p>${L(h.note)}</p>
<div class="links"><a href="${bookingUrl(h.bookingQuery ?? (h.name as any).en, checkin, checkout, state.lang)}" rel="noopener sponsored" target="_blank">${esc(t.hotelSearch)}</a>${h.mapQuery ? `<a href="${mapUrl(h.mapQuery)}" rel="noopener" target="_blank">${esc(t.map)}</a>` : ''}</div></div>`).join('');
const srcs = p.sources.map((id) => (P as any).clinicSources.find((s: any) => s.id === id)).filter(Boolean).map((s: any) => `<li><a href="${esc(s.url)}" rel="noopener" target="_blank">${L(s.label)}</a></li>`).join('');
return `
<div class="plan-result" id="result">
<section class="pcard">${head("Step 4", t.h_step4, `${t.step4 ? t.step4 + " · " : ""}${(p.label as any)[state.lang]}`)}
<div class="kpi-row">${kpis}</div>
<p class="fine" style="margin-top:0.8rem">${esc(t.departureNote)}</p><p class="fine">${esc(t.provisionalNote)}</p>
${p.readMore ? `<p class="fine"><a href="${esc(p.readMore)}">${esc(t.readMore)}</a></p>` : ''}
</section>
${visa}
<section class="pcard">${head("Rules", t.h_rules, t.constraints)}<ul>${cons}</ul>${stepNotes ? `<h3>${esc(t.levelLabel)}</h3><ul>${stepNotes}</ul>` : ''}</section>
<section class="pcard">${head("Timeline", t.h_days, t.levelLabel)}${legend}<div class="timeline">${days}</div></section>
<section class="pcard">${head("Step 5", t.h_book, t.step5)}
<h3 class="serif">${esc(t.h_hotels)}</h3><p class="h3sub">${esc(t.hotels)}</p><p class="fine">${esc(t.hotelHint)}</p><div class="hotel-list">${hotels}</div>
<h3 class="serif">${esc(t.h_flights)}</h3><p class="fine">${esc(t.flightHint)}</p><p><a class="btn ghost small" href="${googleFlightsUrl(checkin, checkout, state.lang)}" rel="noopener" target="_blank">${esc(t.flightSearch)}</a></p>
<h3 class="serif">${esc(t.h_consult)}</h3><p class="h3sub">${esc(t.consult)}</p><p class="fine">${esc(t.consultHint)}</p>
<div class="plan-actions"><a class="btn primary" href="${esc(reservationUrl)}" rel="noopener">${esc(t.consult)}</a><button type="button" class="btn ghost small" data-action="copy">${esc(t.copySummary)}</button><button type="button" class="btn ghost small" data-action="share">${esc(t.shareLink)}</button><button type="button" class="btn ghost small" data-action="ics">${esc(t.ics)}</button><button type="button" class="btn ghost small" data-action="print">${esc(t.print)}</button></div>
<p class="fine" style="margin-top:1rem">${esc(t.affiliate)}</p>
<p class="fine">${esc(t.medical)}</p>
${srcs ? `<p class="fine" style="margin-bottom:0.2rem">${esc(t.sourceLabel)}</p><ul class="sources-list">${srcs}</ul>` : ''}
</section>
</div>`;
}
let lastPlan: Plan | null = null;
function render() {
const p = currentProc();
let result = '';
lastPlan = null;
if (p && isIsoDate(state.date)) {
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
result = renderResult(lastPlan, p);
}
root.innerHTML = renderSteps() + result;
const t = T();
document.getElementById('plan-eyebrow')!.textContent = t.eyebrow;
document.getElementById('plan-h1')!.textContent = t.h_hero;
document.getElementById('plan-title')!.textContent = t.title;
document.getElementById('plan-lede')!.textContent = t.lede;
document.querySelectorAll('#lang-top button').forEach((b) => b.classList.toggle('on', (b as HTMLElement).dataset.lang === state.lang));
syncHash();
}
// ---------- 이벤트 ----------
document.addEventListener('click', (e) => {
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action],[data-lang]');
if (!el) return;
if (el.dataset.lang) { e.preventDefault(); const target = el.dataset.lang as Lang; if (target !== state.lang) { state.lang = target; location.href = `${pageUrl(target)}#${encodeHash({ p: state.proc || undefined, d: state.date || undefined, l: target, n: state.nat || undefined, pref: state.prefs })}`; } return; }
const a = el.dataset.action;
if (a === 'proc') { state.proc = el.dataset.id!; render(); if (state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
else if (a === 'pref') { const id = el.dataset.id!; state.prefs = state.prefs.includes(id) ? state.prefs.filter((x) => x !== id) : [...state.prefs, id]; render(); }
else if (a === 'go') { render(); const r = document.getElementById('result'); if (r) r.scrollIntoView({ behavior: 'smooth', block: 'start' }); else (document.getElementById('sdate') as HTMLInputElement | null)?.focus(); }
else if (a === 'reset') { state.proc = ''; state.date = ''; state.prefs = []; state.nat = ''; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); }
else if (a === 'copy' && lastPlan) { navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).then(() => toast(T().copied)); }
else if (a === 'share') { navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); }
else if (a === 'print') { window.print(); }
else if (a === 'ics' && lastPlan) {
const t = T();
const ics = buildIcs(lastPlan, { arrive: t.arriveBy, surgery: `${t.surgery} · ${(currentProc()!.label as any)[state.lang]}`, stitch: t.stitch, departure: t.departure }, t.summaryTitle);
const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));
const link = Object.assign(document.createElement('a'), { href: url, download: `recovery-plan-${state.date}.ics` });
document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url);
}
});
document.addEventListener('change', (e) => {
const el = e.target as HTMLInputElement | HTMLSelectElement;
if (el.dataset.action === 'nat') { state.nat = el.value; render(); }
if (el.dataset.action === 'date') { state.date = isIsoDate(el.value) ? el.value : ''; render(); if (state.proc && state.date) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
});
render();
</script>

View File

@ -0,0 +1,47 @@
{
"_comment": "회복기 스케줄러 큐레이션 장소 (설계 v0.2 §13.5 1순위 소스). 기준점은 신논현역(뷰성형외과). distanceKm 는 직선 거리 근사값이고 verify 가 true 인 항목은 출시 전 영업 여부·거리를 확인한다. 링크는 구글 지도 검색과 예약 사이트 검색 결과만 쓴다(지어낸 상세 URL 없음). 축제·행사(날짜 의존)는 한국관광공사 Tour API 연결(P1) 뒤에 추가한다.",
"origin": { "label": { "ko": "신논현역 3번 출구", "en": "Sinnonhyeon Station Exit 3" }, "lat": 37.5045, "lng": 127.0250 },
"categories": {
"sightseeing": { "ko": "관광", "en": "Sightseeing" },
"shopping": { "ko": "쇼핑", "en": "Shopping" },
"food": { "ko": "미식", "en": "Food" },
"kpop": { "ko": "K-pop·핫플", "en": "K-pop & hotspots" },
"care": { "ko": "휴식·케어", "en": "Rest & care" },
"essentials": { "ko": "생활", "en": "Essentials" },
"hotel": { "ko": "숙소", "en": "Hotels" }
},
"places": [
{ "id": "pharmacy-sinnonhyeon", "name": { "ko": "신논현역 주변 약국", "en": "Pharmacies near Sinnonhyeon Station" }, "category": "essentials", "minLevel": 1, "distanceKm": 0.2, "indoor": true, "area": { "ko": "신논현", "en": "Sinnonhyeon" }, "note": { "ko": "병원 건물 옆 골목에 약국이 있습니다. 처방약과 냉찜질 팩을 구할 수 있습니다.", "en": "Pharmacies in the alley next to the clinic building. Prescriptions and cold packs." }, "mapQuery": "약국 신논현역", "verify": true },
{ "id": "convenience-sinnonhyeon", "name": { "ko": "편의점 (신논현역 일대)", "en": "Convenience stores (Sinnonhyeon area)" }, "category": "essentials", "minLevel": 1, "distanceKm": 0.1, "indoor": true, "area": { "ko": "신논현", "en": "Sinnonhyeon" }, "note": { "ko": "생수, 죽, 빨대, 얼음을 24시간 살 수 있습니다.", "en": "Water, porridge, straws and ice, 24 hours." }, "mapQuery": "편의점 신논현역", "verify": false },
{ "id": "porridge-chain", "name": { "ko": "죽 전문점 (본죽 등 체인)", "en": "Korean porridge (Bonjuk and similar chains)" }, "category": "food", "minLevel": 1, "dietOk": ["liquid", "soft", "mild", "normal"], "distanceKm": 0.4, "indoor": true, "area": { "ko": "신논현·강남", "en": "Sinnonhyeon / Gangnam" }, "note": { "ko": "유동식 단계에서 먹을 수 있는 죽을 배달과 포장으로 받을 수 있습니다.", "en": "Porridge suitable for the liquid-diet stage, by delivery or takeout." }, "mapQuery": "본죽 신논현역", "verify": true },
{ "id": "smoothie-cafe", "name": { "ko": "스무디·주스 카페 (강남대로)", "en": "Smoothie and juice cafes (Gangnam-daero)" }, "category": "food", "minLevel": 1, "dietOk": ["liquid", "soft", "mild", "normal"], "distanceKm": 0.5, "indoor": true, "area": { "ko": "강남대로", "en": "Gangnam-daero" }, "note": { "ko": "빨대로 마실 수 있는 메뉴가 많습니다. 뜨겁지 않은 것으로 고르세요.", "en": "Plenty of straw-friendly options. Choose nothing hot." }, "mapQuery": "스무디 카페 신논현역", "verify": true },
{ "id": "seolleongtang", "name": { "ko": "설렁탕·곰탕 (강남 일대)", "en": "Seolleongtang beef bone soup (Gangnam area)" }, "category": "food", "minLevel": 1, "dietOk": ["soft", "mild", "normal"], "distanceKm": 0.6, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "국물 위주라 부드러운 음식 단계부터 가능합니다. 밥을 말아 천천히 드세요.", "en": "Broth-based, fine from the soft-food stage. Eat slowly with rice in the soup." }, "mapQuery": "설렁탕 강남역", "verify": true },
{ "id": "sulbing", "name": { "ko": "설빙 (빙수 디저트)", "en": "Sulbing shaved-ice dessert" }, "category": "food", "minLevel": 1, "dietOk": ["soft", "mild", "normal"], "distanceKm": 0.6, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "차갑고 부드러워 부기가 있을 때도 먹기 편합니다.", "en": "Cold and soft, comfortable even with swelling." }, "mapQuery": "설빙 강남", "verify": true },
{ "id": "cafe-garosugil", "name": { "ko": "가로수길 카페", "en": "Garosu-gil cafes" }, "category": "food", "minLevel": 2, "dietOk": ["soft", "mild", "normal"], "distanceKm": 1.2, "indoor": true, "area": { "ko": "신사동 가로수길", "en": "Garosu-gil, Sinsa" }, "note": { "ko": "조용한 카페가 많아 오후에 잠깐 앉아 있기 좋습니다.", "en": "Quiet cafes, good for a short afternoon sit-down." }, "mapQuery": "가로수길 카페", "verify": false },
{ "id": "korean-bbq-gangnam", "name": { "ko": "강남 한식·구이", "en": "Korean BBQ and set meals (Gangnam)" }, "category": "food", "minLevel": 2, "dietOk": ["normal"], "distanceKm": 0.8, "indoor": true, "area": { "ko": "강남", "en": "Gangnam" }, "note": { "ko": "일반식이 가능한 단계부터입니다. 담당 원장이 술을 허용하기 전에는 음료로 대신하세요.", "en": "From the regular-diet stage. Skip alcohol until your surgeon allows it." }, "mapQuery": "한식 맛집 강남역", "verify": false },
{ "id": "garosugil", "name": { "ko": "가로수길", "en": "Garosu-gil" }, "category": "shopping", "minLevel": 2, "distanceKm": 1.2, "indoor": false, "area": { "ko": "신사동", "en": "Sinsa" }, "note": { "ko": "편집숍과 뷰티 매장이 모여 있는 거리입니다. 택시로 5분.", "en": "A street of boutiques and beauty stores. 5 minutes by taxi." }, "mapQuery": "가로수길", "verify": false },
{ "id": "oliveyoung-gangnam", "name": { "ko": "올리브영 강남 타운", "en": "Olive Young Gangnam Town" }, "category": "shopping", "minLevel": 2, "distanceKm": 0.9, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "K-뷰티 대형 매장입니다. 수술 부위에 닿는 화장품은 담당 원장 안내를 따르세요.", "en": "Large K-beauty store. Follow your surgeon's guidance on products near the surgical area." }, "mapQuery": "올리브영 강남타운", "verify": true },
{ "id": "shinsegae-gangnam", "name": { "ko": "신세계백화점 강남점", "en": "Shinsegae Department Store Gangnam" }, "category": "shopping", "minLevel": 2, "distanceKm": 2.4, "indoor": true, "area": { "ko": "고속터미널", "en": "Express Bus Terminal" }, "note": { "ko": "지하 파미에스테이션과 연결됩니다. 실내라 날씨와 상관없이 다닐 수 있습니다.", "en": "Connected to the Famille Station underground mall. Fully indoors." }, "mapQuery": "신세계백화점 강남점", "verify": false },
{ "id": "hyundai-apgujeong", "name": { "ko": "현대백화점 압구정본점", "en": "Hyundai Department Store Apgujeong" }, "category": "shopping", "minLevel": 2, "distanceKm": 2.0, "indoor": true, "area": { "ko": "압구정", "en": "Apgujeong" }, "note": { "ko": "택시로 10분. 배송 서비스를 이용하면 짐을 들지 않아도 됩니다.", "en": "10 minutes by taxi. Use the delivery counter so you carry nothing." }, "mapQuery": "현대백화점 압구정본점", "verify": false },
{ "id": "coex-mall", "name": { "ko": "코엑스몰·별마당도서관", "en": "COEX Mall and Starfield Library" }, "category": "shopping", "minLevel": 2, "distanceKm": 3.4, "indoor": true, "area": { "ko": "삼성동", "en": "Samseong" }, "note": { "ko": "실내 쇼핑몰과 도서관, 아쿠아리움이 한 건물에 있습니다.", "en": "Indoor mall, library and aquarium in one building." }, "mapQuery": "코엑스몰", "verify": false },
{ "id": "apgujeong-rodeo", "name": { "ko": "압구정 로데오·K-Star Road", "en": "Apgujeong Rodeo and K-Star Road" }, "category": "kpop", "minLevel": 2, "distanceKm": 2.6, "indoor": false, "area": { "ko": "압구정", "en": "Apgujeong" }, "note": { "ko": "기획사 건물과 아이돌 조형물이 있는 거리입니다.", "en": "Street of entertainment agencies and idol art figures." }, "mapQuery": "K-Star Road 압구정", "verify": true },
{ "id": "kwangya-seongsu", "name": { "ko": "KWANGYA@SEOUL (성수)", "en": "KWANGYA@SEOUL (Seongsu)" }, "category": "kpop", "minLevel": 3, "distanceKm": 5.5, "indoor": true, "area": { "ko": "성수동", "en": "Seongsu" }, "note": { "ko": "SM 엔터테인먼트 공식 굿즈 매장입니다. 운영 시간을 확인하고 가세요.", "en": "SM Entertainment's official merchandise store. Check hours before visiting." }, "mapQuery": "KWANGYA@SEOUL", "verify": true },
{ "id": "seongsu", "name": { "ko": "성수동 카페·팝업", "en": "Seongsu cafes and pop-ups" }, "category": "kpop", "minLevel": 3, "distanceKm": 5.5, "indoor": false, "area": { "ko": "성수동", "en": "Seongsu" }, "note": { "ko": "브랜드 팝업과 카페가 모인 동네입니다. 주말은 인파가 많습니다.", "en": "Brand pop-ups and cafes. Crowded on weekends." }, "mapQuery": "성수동 팝업", "verify": false },
{ "id": "hongdae", "name": { "ko": "홍대", "en": "Hongdae" }, "category": "kpop", "minLevel": 3, "distanceKm": 11, "indoor": false, "area": { "ko": "홍대입구", "en": "Hongik Univ." }, "note": { "ko": "버스킹과 굿즈숍이 있습니다. 왕복 1시간 이상 걸리니 자유 외출 단계에서 가세요.", "en": "Busking and merch shops. Over an hour round trip, so save it for full-day outings." }, "mapQuery": "홍대 걷고싶은거리", "verify": false },
{ "id": "kakao-friends-gangnam", "name": { "ko": "카카오프렌즈 강남 플래그십", "en": "Kakao Friends Gangnam Flagship" }, "category": "kpop", "minLevel": 2, "distanceKm": 0.9, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "캐릭터 굿즈 매장입니다. 걸어서도 갈 수 있는 거리입니다.", "en": "Character merchandise store, within walking distance." }, "mapQuery": "카카오프렌즈 강남플래그십스토어", "verify": true },
{ "id": "bongeunsa", "name": { "ko": "봉은사", "en": "Bongeunsa Temple" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 3.6, "indoor": false, "area": { "ko": "삼성동", "en": "Samseong" }, "note": { "ko": "코엑스 맞은편의 사찰입니다. 경사가 완만해 천천히 걷기 좋습니다.", "en": "Temple across from COEX. Gentle slopes, good for a slow walk." }, "mapQuery": "봉은사", "verify": false },
{ "id": "seonjeongneung", "name": { "ko": "선정릉", "en": "Seonjeongneung Royal Tombs" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 2.4, "indoor": false, "area": { "ko": "선릉", "en": "Seolleung" }, "note": { "ko": "유네스코 세계유산 왕릉입니다. 숲길이 그늘져서 햇빛을 피하기 좋습니다.", "en": "UNESCO World Heritage royal tombs. Shaded forest paths." }, "mapQuery": "선정릉", "verify": false },
{ "id": "banpo-hangang", "name": { "ko": "반포한강공원·달빛무지개분수", "en": "Banpo Hangang Park and Moonlight Rainbow Fountain" }, "category": "sightseeing", "minLevel": 2, "distanceKm": 2.8, "indoor": false, "area": { "ko": "반포", "en": "Banpo" }, "note": { "ko": "저녁에 분수 공연을 볼 수 있습니다. 운영 기간을 확인하세요.", "en": "Evening fountain show. Check the season schedule." }, "mapQuery": "반포한강공원", "verify": false },
{ "id": "gyeongbokgung", "name": { "ko": "경복궁·북촌", "en": "Gyeongbokgung Palace and Bukchon" }, "category": "sightseeing", "minLevel": 3, "distanceKm": 9, "indoor": false, "area": { "ko": "종로", "en": "Jongno" }, "note": { "ko": "택시로 30분 이상 걸립니다. 걷는 시간이 길어 자유 외출 단계에서 가세요.", "en": "Over 30 minutes by taxi and a lot of walking. Save for full-day outings." }, "mapQuery": "경복궁", "verify": false },
{ "id": "lotte-tower", "name": { "ko": "롯데월드타워·서울스카이", "en": "Lotte World Tower and Seoul Sky" }, "category": "sightseeing", "minLevel": 3, "distanceKm": 8, "indoor": true, "area": { "ko": "잠실", "en": "Jamsil" }, "note": { "ko": "실내 전망대와 쇼핑몰입니다. 택시로 25분.", "en": "Indoor observatory and mall. 25 minutes by taxi." }, "mapQuery": "롯데월드타워", "verify": false },
{ "id": "myeongdong", "name": { "ko": "명동", "en": "Myeongdong" }, "category": "shopping", "minLevel": 3, "distanceKm": 7, "indoor": false, "area": { "ko": "중구", "en": "Jung-gu" }, "note": { "ko": "화장품과 길거리 음식이 많습니다. 인파가 많으니 부딪히지 않게 다니세요.", "en": "Cosmetics and street food. Crowded, so mind bumps." }, "mapQuery": "명동", "verify": false },
{ "id": "clinic-aftercare", "name": { "ko": "뷰성형외과 애프터케어", "en": "View Plastic Surgery Aftercare" }, "category": "care", "minLevel": 1, "distanceKm": 0.05, "indoor": true, "area": { "ko": "병원", "en": "Clinic" }, "note": { "ko": "고주파 관리와 경과 확인은 병원 안내에 따라 예약합니다.", "en": "Radiofrequency care and follow-up checks, booked per the clinic's guidance." }, "mapQuery": "뷰성형외과", "verify": false },
{ "id": "clinic-dental", "name": { "ko": "뷰성형외과 15층 치과", "en": "View Plastic Surgery Dental Clinic (15F)" }, "category": "care", "minLevel": 1, "distanceKm": 0.05, "indoor": true, "area": { "ko": "병원", "en": "Clinic" }, "note": { "ko": "양악·윤곽 협진 치과입니다. 여유 시간에 검진을 받을 수 있는지 상담에서 물어보세요.", "en": "Dental clinic for jaw and contouring cases. Ask at consultation about a check-up during your stay." }, "mapQuery": "뷰성형외과", "verify": false },
{ "id": "hotel-spa-gangnam", "name": { "ko": "호텔 스파·라운지 (숙소 안)", "en": "Hotel spa and lounge (at your hotel)" }, "category": "care", "minLevel": 0, "distanceKm": 0, "indoor": true, "area": { "ko": "숙소", "en": "Your hotel" }, "note": { "ko": "사우나와 마사지는 담당 원장이 허용하기 전에는 피하고 라운지와 룸서비스만 이용하세요.", "en": "Skip sauna and massage until your surgeon allows. Lounge and room service are fine." }, "mapQuery": null, "verify": false },
{ "id": "hotel-cappuccino", "name": { "ko": "호텔 카푸치노", "en": "Hotel Cappuccino" }, "category": "hotel", "minLevel": 0, "distanceKm": 0.5, "indoor": true, "area": { "ko": "논현동 봉은사로", "en": "Nonhyeon, Bongeunsa-ro" }, "note": { "ko": "병원과 같은 봉은사로에 있어 걸어서 다닐 수 있습니다.", "en": "On the same road as the clinic, within walking distance." }, "mapQuery": "호텔 카푸치노 논현", "bookingQuery": "Hotel Cappuccino Seoul", "verify": true },
{ "id": "novotel-gangnam", "name": { "ko": "노보텔 앰배서더 강남", "en": "Novotel Ambassador Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.0, "indoor": true, "area": { "ko": "역삼동", "en": "Yeoksam" }, "note": { "ko": "강남역 근처 대형 호텔입니다. 룸서비스와 24시간 프런트가 있습니다.", "en": "Large hotel near Gangnam Station with room service and a 24-hour front desk." }, "mapQuery": "노보텔 앰배서더 강남", "bookingQuery": "Novotel Ambassador Seoul Gangnam", "verify": true },
{ "id": "l7-gangnam", "name": { "ko": "L7 강남 바이 롯데", "en": "L7 Gangnam by Lotte" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.4, "indoor": true, "area": { "ko": "강남역", "en": "Gangnam Station" }, "note": { "ko": "강남역 인근이며 택시로 5분입니다.", "en": "Near Gangnam Station, 5 minutes by taxi." }, "mapQuery": "L7 강남", "bookingQuery": "L7 Gangnam by Lotte", "verify": true },
{ "id": "andaz-gangnam", "name": { "ko": "안다즈 서울 강남", "en": "Andaz Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 2.6, "indoor": true, "area": { "ko": "압구정 로데오", "en": "Apgujeong Rodeo" }, "note": { "ko": "압구정 로데오에 있는 고급 호텔입니다. 병원까지 택시로 10분.", "en": "Upscale hotel at Apgujeong Rodeo. 10 minutes to the clinic by taxi." }, "mapQuery": "안다즈 서울 강남", "bookingQuery": "Andaz Seoul Gangnam", "verify": true },
{ "id": "josun-palace", "name": { "ko": "조선 팰리스 강남", "en": "Josun Palace Seoul Gangnam" }, "category": "hotel", "minLevel": 0, "distanceKm": 1.8, "indoor": true, "area": { "ko": "역삼동", "en": "Yeoksam" }, "note": { "ko": "역삼동의 고급 호텔입니다. 병원까지 택시로 10분.", "en": "Luxury hotel in Yeoksam. 10 minutes to the clinic by taxi." }, "mapQuery": "조선 팰리스 서울 강남", "bookingQuery": "Josun Palace Seoul Gangnam", "verify": true }
]
}

View File

@ -0,0 +1,179 @@
{
"_comment": "회복기 스케줄러 UI 문자열. ko/en 두 언어. 일본어·중국어는 P1에서 같은 키로 추가한다. 고객 화면이므로 운영자 어휘를 쓰지 않는다.",
"ko": {
"eyebrow": "Recovery Planner",
"title": "수술일을 정하셨다면, 여행 일정은 여기서 짜세요.",
"lede": "시술과 수술일을 고르면 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 그날 갈 만한 곳을 정리해 드립니다. 계산은 이 화면 안에서만 이루어지고 어디에도 저장되지 않습니다.",
"step1": "언어와 국적",
"step2": "시술과 수술일",
"step3": "여유 시간에 하고 싶은 것",
"step4": "일정표",
"step5": "예약과 저장",
"lang": "언어",
"nationality": "국적",
"procedure": "시술",
"surgeryDate": "수술일",
"surgeryDateHint": "상담에서 확정한 날짜를 넣으세요. 아직 정하지 않았다면 예상일도 괜찮습니다.",
"prefs": "여러 개 고를 수 있습니다",
"pref_sightseeing": "관광",
"pref_shopping": "쇼핑",
"pref_food": "미식",
"pref_kpop": "K-pop·핫플",
"pref_care": "휴식·케어",
"generate": "일정표 만들기",
"arriveBy": "입국 권장일",
"surgery": "수술일",
"hospital": "입원",
"hospitalNights": "{n}박",
"hospitalNone": "당일 귀가",
"stitch": "실밥·부목 제거",
"departure": "출국 가능 최소일",
"stayNights": "총 체류",
"nights": "{n}박",
"pending": "확인",
"provisional": "병원 확인 전",
"clinicSourced": "병원 안내 기준",
"departureNote": "출국 가능 최소일은 최소 기준입니다. 담당 원장이 경과를 보고 다르게 안내할 수 있으니 항공권은 변경 가능한 조건으로 예약하세요.",
"dayBefore": "수술 {n}일 전",
"daySurgery": "수술 당일",
"dayAfter": "수술 후 {n}일",
"level": "외출",
"diet": "식사",
"picks": "추천",
"noPicks": "이 날은 쉬는 날입니다. 배달과 룸서비스를 이용하세요.",
"constraints": "회복기 동안 지킬 것",
"visaTitle": "체류 기간 확인",
"visaOk": "{country} 국적의 무비자 체류 한도는 {days}일이고 이 일정은 {stay}일입니다. 한도 안에 있습니다.",
"visaOver": "{country} 국적의 무비자 체류 한도는 {days}일인데 이 일정은 {stay}일입니다. 비자가 필요할 수 있으니 확인하세요.",
"visaRequired": "{country} 국적은 사전 비자가 필요할 수 있습니다. 출발 전 확인하세요.",
"visaVerify": "체류 한도는 2026년 9월 기준이며 바뀔 수 있습니다. 대한민국 비자포털에서 확인하세요.",
"visaPortal": "대한민국 비자포털",
"hotels": "병원 근처 숙소",
"hotelHint": "입국 권장일부터 출국 가능 최소일까지 날짜를 넣어 검색합니다. 엘리베이터, 룸서비스, 24시간 프런트가 있는 곳을 골랐습니다.",
"hotelSearch": "이 날짜로 검색",
"flights": "항공권",
"flightHint": "인천공항 도착 기준으로 날짜를 채워 검색합니다. 변경 가능한 요금을 권합니다.",
"flightSearch": "구글 항공권에서 보기",
"consult": "상담 예약",
"consultHint": "아래 일정 요약을 복사해 상담 신청 때 함께 보내면 병원이 회복 일정에 맞춰 안내할 수 있습니다.",
"copySummary": "일정 요약 복사",
"copied": "복사했습니다",
"shareLink": "링크 복사",
"ics": "캘린더에 저장",
"print": "인쇄",
"map": "지도",
"readMore": "회복 일정 자세히",
"affiliate": "숙소·항공 링크는 각 예약 사이트로 이동하며, 제휴 링크가 적용될 경우 이 매체가 소정의 수수료를 받을 수 있습니다. 예약 조건과 결제는 각 사이트의 안내를 따릅니다.",
"medical": "이 일정표는 병원 공개 안내를 바탕으로 한 일반 정보입니다. 본인 수술 범위와 경과에 따라 담당 원장의 지시가 우선합니다.",
"km": "{km}km",
"walk": "도보",
"taxi": "택시",
"indoor": "실내",
"summaryTitle": "회복 일정 요약",
"levelLabel": "외출 단계",
"sourceLabel": "근거",
"reset": "처음부터",
"provisionalNote": "\"병원 확인 전\"으로 표시된 값은 병원 공개 안내에 없어 일반 기준으로 넣은 잠정값입니다. 상담에서 본인 일정으로 확정하세요.",
"noRemoval": "제거 방문 없음",
"h_hero": "Plan Your Recovery Stay",
"h_step1": "Language & Nationality",
"h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time",
"h_step4": "Your Schedule",
"h_visa": "Length of Stay",
"h_rules": "During Recovery",
"h_days": "Day by Day",
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation"
},
"en": {
"eyebrow": "Recovery Planner",
"title": "Surgery date set? Plan the rest of your trip here.",
"lede": "Pick your procedure and surgery date. We work out when to arrive, the earliest you can fly home, which recovery days you can go out, and where to go on those days. Everything is calculated on this page and stored nowhere.",
"step1": "",
"step2": "",
"step3": "",
"step4": "",
"step5": "",
"lang": "Language",
"nationality": "Nationality",
"procedure": "Procedure",
"surgeryDate": "Surgery date",
"surgeryDateHint": "Use the date confirmed at consultation. An estimate is fine if it's not set yet.",
"prefs": "Choose as many as you like",
"pref_sightseeing": "Sightseeing",
"pref_shopping": "Shopping",
"pref_food": "Food",
"pref_kpop": "K-pop & hotspots",
"pref_care": "Rest & care",
"generate": "Build my schedule",
"arriveBy": "Arrive by",
"surgery": "Surgery",
"hospital": "Hospital stay",
"hospitalNights": "{n} night(s)",
"hospitalNone": "Same-day discharge",
"stitch": "Stitch / splint removal",
"departure": "Earliest departure",
"stayNights": "Total stay",
"nights": "{n} nights",
"pending": "To confirm",
"provisional": "Provisional",
"clinicSourced": "From clinic guidance",
"departureNote": "Earliest departure is a minimum. Your surgeon may advise differently after follow-up, so book flights with flexible terms.",
"dayBefore": "{n} day(s) before surgery",
"daySurgery": "Surgery day",
"dayAfter": "Day {n} after surgery",
"level": "Outing",
"diet": "Diet",
"picks": "Suggestions",
"noPicks": "A rest day. Use delivery and room service.",
"constraints": "During recovery",
"visaTitle": "Length of stay check",
"visaOk": "Visa-free stay for {country} nationals is {days} days and this plan is {stay} days. You're within the limit.",
"visaOver": "Visa-free stay for {country} nationals is {days} days but this plan is {stay} days. You may need a visa, please check.",
"visaRequired": "{country} nationals may need a visa in advance. Check before you travel.",
"visaVerify": "Stay limits are as of September 2026 and may change. Confirm at the Korea Visa Portal.",
"visaPortal": "Korea Visa Portal",
"hotels": "Hotels near the clinic",
"hotelHint": "Searches with your arrival and earliest-departure dates filled in. We picked places with elevators, room service and a 24-hour front desk.",
"hotelSearch": "Search these dates",
"flights": "Flights",
"flightHint": "Searches with your dates filled in, arriving at Incheon. Flexible fares recommended.",
"flightSearch": "Open in Google Flights",
"consult": "Book a consultation",
"consultHint": "Copy the summary below and send it with your consultation request so the clinic can plan around your recovery dates.",
"copySummary": "Copy summary",
"copied": "Copied",
"shareLink": "Copy link",
"ics": "Save to calendar",
"print": "Print",
"map": "Map",
"readMore": "Full recovery timeline",
"affiliate": "Hotel and flight links open the booking sites. Where an affiliate link applies, this site may earn a small commission. Booking terms and payment follow each site.",
"medical": "This schedule is general information based on the clinic's published guidance. Your surgeon's instructions for your own case take priority.",
"km": "{km} km",
"walk": "walk",
"taxi": "taxi",
"indoor": "indoor",
"summaryTitle": "Recovery schedule summary",
"levelLabel": "Outing level",
"sourceLabel": "Source",
"reset": "Start over",
"provisionalNote": "Values marked \"Provisional\" are not in the clinic's published guidance and use general estimates. Confirm your own dates at consultation.",
"noRemoval": "No removal visit",
"h_hero": "Plan Your Recovery Stay",
"h_step1": "Language & Nationality",
"h_step2": "Procedure & Surgery Date",
"h_step3": "Your Free Time",
"h_step4": "Your Schedule",
"h_visa": "Length of Stay",
"h_rules": "During Recovery",
"h_days": "Day by Day",
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation"
}
}

View File

@ -0,0 +1,989 @@
{
"_comment": "회복기 스케줄러 시술별 규칙표 (설계 v0.2 §13.4). 숫자마다 status 가 붙는다. clinic = 병원 공개 주의사항·애프터케어에 있는 값, provisional = 병원 확인 전 잠정값(화면에 배지 표시), null = 값 없음(화면에 '확인'). 병원 확인이 끝나면 status 를 clinic 으로 바꾸고 source 를 채운다.",
"clinicSources": [
{
"id": "precautions",
"label": {
"ko": "뷰성형외과 홈페이지 · 수술 전·후 주의사항",
"en": "View Plastic Surgery · Pre/Post-operative precautions"
},
"url": "https://www.viewclinic.com/system/precautions/",
"accessed": "2026-09-07"
},
{
"id": "aftercare",
"label": {
"ko": "뷰성형외과 홈페이지 · 애프터케어",
"en": "View Plastic Surgery · Aftercare"
},
"url": "https://www.viewclinic.com/system/after-care/",
"accessed": "2026-09-07"
}
],
"levels": [
{
"level": 0,
"label": {
"ko": "실내 안정",
"en": "Rest indoors"
},
"desc": {
"ko": "병실이나 호텔에서 쉬는 날입니다. 필요한 것은 배달과 룸서비스로 해결합니다.",
"en": "Rest at the hospital or hotel. Use delivery and room service."
}
},
{
"level": 1,
"label": {
"ko": "근거리 외출",
"en": "Short walks"
},
"desc": {
"ko": "호텔 반경 500m 안에서 짧게 다녀옵니다. 도보 10분 이내.",
"en": "Short trips within 500 m of your hotel. Up to a 10-minute walk."
}
},
{
"level": 2,
"label": {
"ko": "반나절 외출",
"en": "Half-day outing"
},
"desc": {
"ko": "택시로 반경 3km 안 쇼핑·식사·가벼운 관광이 가능합니다.",
"en": "Shopping, dining and light sightseeing within 3 km by taxi."
}
},
{
"level": 3,
"label": {
"ko": "자유 외출",
"en": "Full day out"
},
"desc": {
"ko": "하루 단위 외출이 가능합니다. 시내 관광, 행사, 인파가 있는 곳도 담당 원장 안내 안에서 가능합니다.",
"en": "Full-day outings. City sightseeing and events, within your surgeon's guidance."
}
}
],
"diets": {
"liquid": {
"label": {
"ko": "유동식",
"en": "Liquid diet"
},
"desc": {
"ko": "건더기 없는 유동식만 드십니다.",
"en": "Liquids only, no solids."
}
},
"soft": {
"label": {
"ko": "부드러운 음식",
"en": "Soft foods"
},
"desc": {
"ko": "건더기가 있어도 부드러운 음식까지 가능합니다.",
"en": "Soft foods are fine. Avoid anything chewy or hard."
}
},
"mild": {
"label": {
"ko": "자극 없는 일반식",
"en": "Regular, non-spicy"
},
"desc": {
"ko": "맵고 짜거나 딱딱한 음식은 피합니다.",
"en": "Regular meals. Avoid spicy, salty or hard foods."
}
},
"normal": {
"label": {
"ko": "일반식",
"en": "Regular diet"
},
"desc": {
"ko": "식사 제한이 없습니다.",
"en": "No dietary restrictions."
}
}
},
"constraints": {
"no-heavy-lifting": {
"ko": "무거운 짐과 팔을 많이 쓰는 동작을 피합니다. 쇼핑은 배송 서비스를 이용하세요.",
"en": "Avoid heavy bags and arm-heavy movement. Use delivery for shopping."
},
"sports-bra": {
"ko": "스포츠브라를 1~3개월 유지합니다.",
"en": "Wear a sports bra for 1 to 3 months."
},
"no-alcohol-30d": {
"ko": "음주와 흡연은 최소 1개월 이후입니다.",
"en": "No alcohol or smoking for at least 1 month."
},
"no-sauna-60d": {
"ko": "사우나, 찜질방, 수영, 과한 운동은 약 2개월 후입니다.",
"en": "No sauna, spa, swimming or intense exercise for about 2 months."
},
"compression-band": {
"ko": "압박밴드를 2~4주 착용합니다. 취침 시에는 하지 않습니다.",
"en": "Wear the compression band for 2 to 4 weeks, not while sleeping."
},
"sleep-elevated": {
"ko": "머리를 심장보다 높게 하고 정면 자세로 잡니다. 1주 후부터 옆으로 누워도 됩니다.",
"en": "Sleep with your head above heart level, facing up. Side sleeping from week 2."
},
"saline-gargle": {
"ko": "약 1개월간 생리식염수 가글을 꾸준히 합니다.",
"en": "Rinse with saline regularly for about 1 month."
},
"no-hard-food-30d": {
"ko": "질기고 딱딱한 음식은 한 달간 피합니다.",
"en": "Avoid chewy or hard food for 1 month."
},
"no-sun": {
"ko": "직사광선을 피합니다. 모자와 선글라스를 챙기세요.",
"en": "Avoid direct sun. Bring a hat and sunglasses."
},
"mask": {
"ko": "외출 시 마스크를 쓰면 부기와 멍이 덜 보입니다.",
"en": "A mask helps cover swelling and bruising when out."
},
"no-glasses": {
"ko": "안경 착용을 피합니다. 콘택트렌즈나 안경 없이 다닐 계획을 세우세요.",
"en": "Avoid glasses resting on your nose. Plan for contacts or going without."
},
"no-lens": {
"ko": "콘택트렌즈는 담당 원장이 허용할 때까지 쓰지 않습니다.",
"en": "No contact lenses until your surgeon allows."
},
"cold-compress": {
"ko": "초기에는 냉찜질, 이후 온찜질을 합니다.",
"en": "Cold compresses at first, then warm."
},
"no-nose-blow": {
"ko": "코를 세게 풀거나 코 부위에 압력을 주지 않습니다.",
"en": "Do not blow your nose or press on the nose area."
},
"no-massage": {
"ko": "수술 부위 마사지와 강한 압박을 피합니다.",
"en": "No massage or firm pressure on the surgical area."
},
"no-hat-pressure": {
"ko": "이식 부위를 누르는 모자나 헬멧을 피합니다.",
"en": "Avoid hats or helmets that press on the graft area."
},
"no-scratch": {
"ko": "가려워도 긁지 않습니다.",
"en": "Do not scratch, even if itchy."
},
"driving": {
"ko": "수술 당일은 운전할 수 없습니다. 보호자 동반을 권합니다.",
"en": "No driving on surgery day. A companion is recommended."
}
},
"procedures": [
{
"id": "breast",
"label": {
"ko": "가슴성형",
"en": "Breast Augmentation"
},
"group": {
"ko": "가슴",
"en": "Breast"
},
"readMore": "/posts/breast-recovery-timeline",
"preOpDays": {
"value": 2,
"status": "provisional",
"note": {
"ko": "검진과 상담 1일, 여유 1일",
"en": "1 day for tests and consultation, 1 buffer day"
}
},
"hospitalNights": {
"value": 1,
"status": "provisional"
},
"stitchRemovalDay": {
"value": 7,
"range": [
5,
10
],
"status": "clinic",
"source": "precautions",
"note": {
"ko": "실밥이 있으면 약 1주에 제거. 피부 접착제를 쓴 경우 실밥 없음",
"en": "Stitches, if any, removed at about 1 week. No stitches if skin adhesive was used"
}
},
"earliestDepartureDay": {
"value": 10,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "clinic",
"source": "precautions",
"note": {
"ko": "부기는 2~3일째 가장 심합니다",
"en": "Swelling peaks on days 2 to 3"
}
},
{
"fromDay": 3,
"level": 1,
"status": "clinic",
"source": "precautions",
"note": {
"ko": "3일 이후 붕대를 뗍니다",
"en": "Bandage removed after day 3"
}
},
{
"fromDay": 7,
"level": 2,
"status": "provisional"
},
{
"fromDay": 14,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "normal",
"status": "clinic",
"source": "precautions"
}
],
"constraints": [
"driving",
"no-heavy-lifting",
"sports-bra",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": [
"precautions",
"aftercare"
]
},
{
"id": "contour",
"label": {
"ko": "안면윤곽·양악",
"en": "Facial Contouring / Jaw Surgery"
},
"group": {
"ko": "얼굴뼈",
"en": "Facial bone"
},
"readMore": "/posts/contour-recovery",
"preOpDays": {
"value": 2,
"status": "provisional",
"note": {
"ko": "3D-CT 촬영과 검진, 상담",
"en": "3D-CT, tests and consultation"
}
},
"hospitalNights": {
"value": 1,
"status": "provisional"
},
"stitchRemovalDay": {
"value": null,
"status": "clinic",
"source": "precautions",
"note": {
"ko": "입안 실은 녹는 실이라 보통 한 달 후 저절로 없어집니다. 별도 제거 방문 없음",
"en": "Intraoral stitches dissolve on their own in about a month. No removal visit"
},
"display": {
"ko": "제거 방문 없음",
"en": "No removal visit"
}
},
"earliestDepartureDay": {
"value": 14,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "clinic",
"source": "precautions",
"note": {
"ko": "부기는 3~4일까지 늘다가 가라앉습니다",
"en": "Swelling increases until day 3 to 4, then subsides"
}
},
{
"fromDay": 5,
"level": 1,
"status": "clinic",
"source": "precautions",
"note": {
"ko": "5~7일 후 일상생활이 가능합니다",
"en": "Daily activities possible after day 5 to 7"
}
},
{
"fromDay": 10,
"level": 2,
"status": "provisional"
},
{
"fromDay": 21,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "liquid",
"status": "clinic",
"source": "precautions"
},
{
"fromDay": 7,
"type": "soft",
"status": "clinic",
"source": "precautions"
},
{
"fromDay": 14,
"type": "mild",
"status": "clinic",
"source": "precautions"
},
{
"fromDay": 30,
"type": "normal",
"status": "clinic",
"source": "precautions"
}
],
"constraints": [
"driving",
"compression-band",
"sleep-elevated",
"saline-gargle",
"no-hard-food-30d",
"no-alcohol-30d",
"no-sauna-60d",
"no-massage"
],
"sources": [
"precautions",
"aftercare"
]
},
{
"id": "eye",
"label": {
"ko": "눈성형",
"en": "Eyelid Surgery"
},
"group": {
"ko": "눈",
"en": "Eyes"
},
"readMore": "/posts/eye-canthoplasty-types",
"preOpDays": {
"value": 1,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": 6,
"range": [
5,
7
],
"status": "provisional"
},
"earliestDepartureDay": {
"value": 7,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "provisional"
},
{
"fromDay": 2,
"level": 1,
"status": "provisional"
},
{
"fromDay": 5,
"level": 2,
"status": "provisional"
},
{
"fromDay": 10,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"driving",
"cold-compress",
"no-sun",
"no-lens",
"no-scratch",
"no-alcohol-30d"
],
"sources": []
},
{
"id": "nose",
"label": {
"ko": "코성형",
"en": "Rhinoplasty"
},
"group": {
"ko": "코",
"en": "Nose"
},
"readMore": "/posts/functional-rhinoplasty-ent",
"preOpDays": {
"value": 2,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": 7,
"range": [
5,
7
],
"status": "provisional",
"note": {
"ko": "부목과 실밥 제거",
"en": "Splint and stitch removal"
}
},
"earliestDepartureDay": {
"value": 10,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "provisional"
},
{
"fromDay": 3,
"level": 1,
"status": "provisional"
},
{
"fromDay": 7,
"level": 2,
"status": "provisional"
},
{
"fromDay": 14,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "mild",
"status": "provisional"
},
{
"fromDay": 14,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"driving",
"mask",
"no-glasses",
"no-nose-blow",
"no-sun",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": []
},
{
"id": "fat",
"label": {
"ko": "지방성형",
"en": "Liposuction / Fat Grafting"
},
"group": {
"ko": "지방",
"en": "Fat"
},
"readMore": null,
"preOpDays": {
"value": 1,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": 7,
"status": "provisional"
},
"earliestDepartureDay": {
"value": 7,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "provisional"
},
{
"fromDay": 2,
"level": 1,
"status": "provisional"
},
{
"fromDay": 5,
"level": 2,
"status": "provisional"
},
{
"fromDay": 14,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"driving",
"compression-band",
"no-massage",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": []
},
{
"id": "lifting",
"label": {
"ko": "안티에이징·리프팅",
"en": "Anti-aging / Face Lift"
},
"group": {
"ko": "리프팅",
"en": "Lifting"
},
"readMore": "/posts/thread-lift-vs-facelift",
"preOpDays": {
"value": 1,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": 7,
"status": "provisional"
},
"earliestDepartureDay": {
"value": 10,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "provisional"
},
{
"fromDay": 3,
"level": 1,
"status": "provisional"
},
{
"fromDay": 7,
"level": 2,
"status": "provisional"
},
{
"fromDay": 14,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "soft",
"status": "provisional"
},
{
"fromDay": 7,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"driving",
"mask",
"no-massage",
"no-sun",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": []
},
{
"id": "skin",
"label": {
"ko": "피부과 시술",
"en": "Dermatology Treatment"
},
"group": {
"ko": "피부",
"en": "Skin"
},
"readMore": null,
"preOpDays": {
"value": 1,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": null,
"status": "provisional",
"note": {
"ko": "실밥 없음",
"en": "No stitches"
},
"display": {
"ko": "제거 방문 없음",
"en": "No removal visit"
}
},
"earliestDepartureDay": {
"value": 2,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 1,
"status": "provisional"
},
{
"fromDay": 1,
"level": 2,
"status": "provisional"
},
{
"fromDay": 3,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"no-sun",
"no-sauna-60d"
],
"sources": []
},
{
"id": "hair",
"label": {
"ko": "모발이식",
"en": "Hair Transplant"
},
"group": {
"ko": "모발",
"en": "Hair"
},
"readMore": null,
"preOpDays": {
"value": 1,
"status": "provisional"
},
"hospitalNights": {
"value": 0,
"status": "provisional"
},
"stitchRemovalDay": {
"value": null,
"status": "provisional",
"note": {
"ko": "채취 방식에 따라 다릅니다",
"en": "Depends on the harvesting method"
}
},
"earliestDepartureDay": {
"value": 3,
"status": "provisional"
},
"outingLevels": [
{
"fromDay": 0,
"level": 0,
"status": "provisional"
},
{
"fromDay": 1,
"level": 1,
"status": "provisional"
},
{
"fromDay": 3,
"level": 2,
"status": "provisional"
},
{
"fromDay": 7,
"level": 3,
"status": "provisional"
}
],
"diet": [
{
"fromDay": 0,
"type": "normal",
"status": "provisional"
}
],
"constraints": [
"no-sun",
"no-hat-pressure",
"no-scratch",
"no-alcohol-30d",
"no-sauna-60d"
],
"sources": []
}
],
"nationalities": {
"_comment": "무비자 체류 한도(일). 2026-09 기준 확인 필요. null 은 사전 비자 필요. 화면은 항상 대한민국 비자포털 링크와 함께 보여준다.",
"portal": "https://www.visa.go.kr",
"list": [
{
"code": "US",
"label": {
"ko": "미국",
"en": "United States"
},
"visaFreeDays": 90,
"longHaul": true
},
{
"code": "CA",
"label": {
"ko": "캐나다",
"en": "Canada"
},
"visaFreeDays": 180,
"longHaul": true
},
{
"code": "GB",
"label": {
"ko": "영국",
"en": "United Kingdom"
},
"visaFreeDays": 180,
"longHaul": true
},
{
"code": "AU",
"label": {
"ko": "호주",
"en": "Australia"
},
"visaFreeDays": 90,
"longHaul": true
},
{
"code": "JP",
"label": {
"ko": "일본",
"en": "Japan"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "TW",
"label": {
"ko": "대만",
"en": "Taiwan"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "HK",
"label": {
"ko": "홍콩",
"en": "Hong Kong"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "SG",
"label": {
"ko": "싱가포르",
"en": "Singapore"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "TH",
"label": {
"ko": "태국",
"en": "Thailand"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "MY",
"label": {
"ko": "말레이시아",
"en": "Malaysia"
},
"visaFreeDays": 90,
"longHaul": false
},
{
"code": "AE",
"label": {
"ko": "아랍에미리트",
"en": "United Arab Emirates"
},
"visaFreeDays": 90,
"longHaul": true
},
{
"code": "RU",
"label": {
"ko": "러시아",
"en": "Russia"
},
"visaFreeDays": 60,
"longHaul": true
},
{
"code": "KZ",
"label": {
"ko": "카자흐스탄",
"en": "Kazakhstan"
},
"visaFreeDays": 30,
"longHaul": true
},
{
"code": "CN",
"label": {
"ko": "중국",
"en": "China"
},
"visaFreeDays": null,
"longHaul": false
},
{
"code": "VN",
"label": {
"ko": "베트남",
"en": "Vietnam"
},
"visaFreeDays": null,
"longHaul": false
},
{
"code": "ID",
"label": {
"ko": "인도네시아",
"en": "Indonesia"
},
"visaFreeDays": null,
"longHaul": false
},
{
"code": "PH",
"label": {
"ko": "필리핀",
"en": "Philippines"
},
"visaFreeDays": null,
"longHaul": false
},
{
"code": "MN",
"label": {
"ko": "몽골",
"en": "Mongolia"
},
"visaFreeDays": null,
"longHaul": false
},
{
"code": "OTHER",
"label": {
"ko": "그 외",
"en": "Other"
},
"visaFreeDays": null,
"longHaul": true
}
]
}
}

View File

@ -11,15 +11,17 @@ interface Props {
ogImage?: string;
type?: 'website' | 'article';
noindex?: boolean;
lang?: 'ko' | 'en';
alternates?: { lang: string; href: string }[];
}
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', alternates = [] } = Astro.props;
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);
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,7 +35,8 @@ 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={lang === 'en' ? 'en_US' : 'ko_KR'} />
{alternates.map((a) => <link rel="alternate" hreflang={a.lang} href={`${site}${a.href}`} />)}
<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 />
@ -51,6 +54,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<a href="/videos">의료진 영상</a>
<a href="/newsroom">뉴스룸</a>
<a href="/visit">방문 안내</a>
<a href="/plan">회복 일정</a>
<a href="/about">매체 소개</a>
</nav>
<a class="cta" href={reservationUrl || '/visit'} rel="noopener">상담 예약</a>
@ -80,7 +84,7 @@ const ld = jsonLd ? JSON.stringify({ '@context': 'https://schema.org', ...(Array
<p><strong>{SITE_NAME}</strong> · {SITE_TAGLINE}</p>
<p>이 매체는 {v(f.shortName, '병원')}의 지원을 받아 서포터즈가 운영합니다. 글의 의학적 내용은 {v(f.shortName, '병원')} 담당 원장의 검토를 거쳐 표시하며, 검토 전 글은 "의학 검토 대기"로 표시합니다. 편집 책임 {ed.name}.</p>
<p>{fact.sideEffectNotice}</p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/about">이 사이트에 대해</a> · <a href="/corrections">정정 기록</a> · <a href={ed.email ? `mailto:${ed.email}` : '/corrections'}>정정 요청</a> · <a href="/editorial">편집 기준 (운영자용)</a></p>
<p><a href="/clinic">병원 정보</a> · <a href="/visit">방문 안내</a> · <a href="/plan">회복 일정</a> · <a href="/en/plan">Recovery Planner</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>
{has(S.logoFoot?.src) && <p><img src={S.logoFoot.src} alt={S.logoFoot.alt} width={S.logoFoot.width} height={S.logoFoot.height} style="width:138px;height:auto;margin-bottom:0.5rem" /></p>}

173
supporters/src/lib/plan.ts Normal file
View File

@ -0,0 +1,173 @@
// 회복기 스케줄러 계산 로직 (설계 v0.2 §13.3·§13.7·§13.8). 화면과 분리된 순수 함수만 둔다.
// 입력: 시술 id, 수술일, 국적, 선호. 출력: 날짜별 외출 등급·식사·추천과 예약 링크. 서버 호출 없음.
// 검증: node --experimental-strip-types scripts/gate/plan.test.mjs
export type Lang = 'ko' | 'en';
export type L10n = Record<Lang, string>;
export type Status = 'clinic' | 'provisional';
export interface Valued { value: number | null; range?: [number, number]; status: Status; source?: string; note?: L10n }
export interface LevelStep { fromDay: number; level: number; status: Status; source?: string; note?: L10n }
export interface DietStep { fromDay: number; type: 'liquid' | 'soft' | 'mild' | 'normal'; status: Status; source?: string }
export interface Procedure {
id: string; label: L10n; group: L10n; readMore: string | null;
preOpDays: Valued; hospitalNights: Valued; stitchRemovalDay: Valued; earliestDepartureDay: Valued;
outingLevels: LevelStep[]; diet: DietStep[]; constraints: string[]; sources: string[];
}
export interface Place {
id: string; name: L10n; category: string; minLevel: number; dietOk?: string[]; distanceKm: number; indoor: boolean;
area: L10n; note: L10n; mapQuery: string | null; bookingQuery?: string; verify: boolean;
}
export interface Nationality { code: string; label: L10n; visaFreeDays: number | null; longHaul: boolean }
export interface PlanInput { procedure: Procedure; surgeryDate: string; nationality?: Nationality | null; prefs: string[] }
export interface PlanDay { date: string; offset: number; level: number; diet: DietStep['type']; picks: Place[]; isSurgery: boolean; isHospital: boolean; isStitch: boolean; isArrival: boolean; isDeparture: boolean }
export interface Plan {
arriveBy: string; surgeryDate: string; dischargeDate: string | null; stitchDate: string | null; earliestDeparture: string | null;
stayNights: number | null; preOpDays: number; days: PlanDay[];
}
const DAY = 86_400_000;
export const addDays = (iso: string, n: number): string => {
const d = new Date(`${iso}T00:00:00Z`);
return new Date(d.getTime() + n * DAY).toISOString().slice(0, 10);
};
export const isIsoDate = (s: unknown): s is string => typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
/** fromDay 오름차순 단계표에서 offset 에 해당하는 단계. 첫 단계 이전이면 첫 단계. */
function stepAt<T extends { fromDay: number }>(steps: T[], offset: number): T {
const sorted = [...steps].sort((a, b) => a.fromDay - b.fromDay);
let cur = sorted[0];
for (const s of sorted) if (offset >= s.fromDay) cur = s;
return cur;
}
/** 장거리 국적은 시차 회복으로 준비일에 1일을 더한다 (§13.3). */
export function effectivePreOpDays(p: Procedure, nat?: Nationality | null): number {
const base = p.preOpDays.value ?? 2;
return base + (nat?.longHaul ? 1 : 0);
}
/**
* 장소 점수. 선호 일치가 가장 크고, 등급이 딱 맞을수록, 가까울수록 높다.
* 선호가 비어 있으면 카테고리 무관하게 거리와 등급만 본다.
*/
export function scorePlace(place: Place, level: number, diet: DietStep['type'], prefs: string[]): number {
if (place.category === 'hotel') return -1;
if (place.minLevel > level) return -1;
if (place.category === 'food' && place.dietOk && !place.dietOk.includes(diet)) return -1;
let s = 0;
if (prefs.length === 0 || prefs.includes(place.category)) s += 3;
if (place.category === 'essentials' || place.category === 'care') s += 1; // 회복에 직접 필요한 것은 기본 가점
s += 2 - Math.min(2, level - place.minLevel); // 오늘 등급에 맞는 곳을 우선
s += Math.max(0, 1.5 - place.distanceKm / 4); // 가까울수록 가점(최대 1.5)
return s;
}
/** 하루에 최대 3곳. 직전 2일에 나온 곳은 뒤로 미뤄 날마다 다른 곳이 나오게 한다. */
export function pickForDay(places: Place[], level: number, diet: DietStep['type'], prefs: string[], recent: Set<string>, max = 3): Place[] {
return places
.map((p) => ({ p, s: scorePlace(p, level, diet, prefs) - (recent.has(p.id) ? 2.5 : 0) }))
.filter((x) => x.s >= 0)
.sort((a, b) => b.s - a.s || a.p.distanceKm - b.p.distanceKm)
.slice(0, max)
.map((x) => x.p);
}
export function buildPlan(input: PlanInput, places: Place[]): Plan {
const { procedure: p, surgeryDate, nationality, prefs } = input;
const preOp = effectivePreOpDays(p, nationality);
const arriveBy = addDays(surgeryDate, -preOp);
const nights = p.hospitalNights.value;
const dischargeDate = nights === null ? null : addDays(surgeryDate, nights);
const stitchDate = p.stitchRemovalDay.value === null ? null : addDays(surgeryDate, p.stitchRemovalDay.value);
const dep = p.earliestDepartureDay.value;
const earliestDeparture = dep === null ? null : addDays(surgeryDate, dep);
const lastOffset = dep ?? Math.max(14, ...p.outingLevels.map((l) => l.fromDay));
const days: PlanDay[] = [];
const recent: string[] = [];
for (let off = -preOp; off <= lastOffset; off++) {
const date = addDays(surgeryDate, off);
const isHospital = nights !== null && off >= 0 && off < nights;
const pre = off < 0;
const level = pre ? 2 : isHospital ? 0 : stepAt(p.outingLevels, off).level;
const diet = pre ? 'normal' : stepAt(p.diet, off).type;
const recentSet = new Set(recent.slice(-6));
const picks = level === 0 || off === 0 ? [] : pickForDay(places, level, diet, prefs, recentSet);
recent.push(...picks.map((x) => x.id));
days.push({ date, offset: off, level, diet, picks, isSurgery: off === 0, isHospital, isStitch: stitchDate === date, isArrival: off === -preOp, isDeparture: earliestDeparture === date });
}
return { arriveBy, surgeryDate, dischargeDate, stitchDate, earliestDeparture, stayNights: dep === null ? null : preOp + dep, preOpDays: preOp, days };
}
// ---------- 예약 링크 (§13.7). 검색 결과 페이지 패턴만 쓴다. 제휴 ID 는 확정 후 aid 파라미터로 붙인다. ----------
export function bookingUrl(query: string, checkin: string, checkout: string, lang: Lang, aid?: string): string {
const u = new URL('https://www.booking.com/searchresults.html');
u.searchParams.set('ss', query);
u.searchParams.set('checkin', checkin);
u.searchParams.set('checkout', checkout);
u.searchParams.set('group_adults', '1');
u.searchParams.set('no_rooms', '1');
u.searchParams.set('lang', lang === 'ko' ? 'ko' : 'en-us');
if (aid) u.searchParams.set('aid', aid);
return u.toString();
}
export function googleFlightsUrl(arrive: string, depart: string, lang: Lang): string {
const q = `Flights to ICN on ${arrive} returning ${depart}`;
return `https://www.google.com/travel/flights?hl=${lang}&q=${encodeURIComponent(q)}`;
}
export function mapUrl(query: string): string {
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
// ---------- 공유 URL 해시 (서버 미전송) ----------
export interface HashState { p?: string; d?: string; l?: Lang; n?: string; pref?: string[] }
export function encodeHash(s: HashState): string {
const q = new URLSearchParams();
if (s.p) q.set('p', s.p);
if (s.d) q.set('d', s.d);
if (s.l) q.set('l', s.l);
if (s.n) q.set('n', s.n);
if (s.pref?.length) q.set('pref', s.pref.join(','));
return q.toString();
}
export function decodeHash(hash: string): HashState {
const q = new URLSearchParams(hash.replace(/^#/, ''));
const l = q.get('l');
return {
p: q.get('p') ?? undefined,
d: isIsoDate(q.get('d')) ? (q.get('d') as string) : undefined,
l: l === 'ko' || l === 'en' ? l : undefined,
n: q.get('n') ?? undefined,
pref: (q.get('pref') ?? '').split(',').filter(Boolean),
};
}
// ---------- ICS (종일 일정 4개: 입국·수술·실밥·출국) ----------
export function buildIcs(plan: Plan, titles: { arrive: string; surgery: string; stitch: string; departure: string }, calName: string): string {
const ev = (date: string, title: string, uid: string) => [
'BEGIN:VEVENT', `UID:${uid}@view-supporters`, `DTSTAMP:${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}Z`,
`DTSTART;VALUE=DATE:${date.replace(/-/g, '')}`, `DTEND;VALUE=DATE:${addDays(date, 1).replace(/-/g, '')}`, `SUMMARY:${title}`, 'END:VEVENT',
];
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//View Supporters//Recovery Planner//KO', `X-WR-CALNAME:${calName}`];
lines.push(...ev(plan.arriveBy, titles.arrive, `arrive-${plan.arriveBy}`));
lines.push(...ev(plan.surgeryDate, titles.surgery, `surgery-${plan.surgeryDate}`));
if (plan.stitchDate) lines.push(...ev(plan.stitchDate, titles.stitch, `stitch-${plan.stitchDate}`));
if (plan.earliestDeparture) lines.push(...ev(plan.earliestDeparture, titles.departure, `depart-${plan.earliestDeparture}`));
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}
/** 상담 신청에 붙여 보낼 요약문. */
export function summaryText(plan: Plan, p: Procedure, lang: Lang, t: Record<string, string>): string {
const pending = t.pending;
const row = (k: string, v: string | null) => `${k}: ${v ?? pending}`;
return [
`${t.summaryTitle} (${p.label[lang]})`,
row(t.arriveBy, plan.arriveBy),
row(t.surgery, plan.surgeryDate),
row(t.hospital, p.hospitalNights.value === null ? null : p.hospitalNights.value === 0 ? t.hospitalNone : t.hospitalNights.replace('{n}', String(p.hospitalNights.value))),
row(t.stitch, plan.stitchDate),
row(t.departure, plan.earliestDeparture),
row(t.stayNights, plan.stayNights === null ? null : t.nights.replace('{n}', String(plan.stayNights))),
].join('\n');
}

View File

@ -0,0 +1,16 @@
---
// Recovery planner, English page. Body lives in components/Planner.astro. Korean page is /plan.
import Base from '../../layouts/Base.astro';
import Planner from '../../components/Planner.astro';
import { fact, SITE_NAME, clinicSchema, v } from '../../lib';
const f = fact as Record<string, any>;
const clinicEn = 'View Plastic Surgery';
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [
{ '@type': 'WebPage', '@id': `${site}/en/plan#page`, name: `${clinicEn} Recovery Planner`, url: `${site}/en/plan`, inLanguage: 'en', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base lang="en" title="Recovery Planner · Arrival, earliest departure and recovery outings" description={`Plan your stay around your surgery date at ${clinicEn} in Seoul: when to arrive, the earliest you can fly home, which recovery days you can go out and where, plus hotels near the clinic. Calculated in your browser, stored nowhere.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/plan' }, { lang: 'en', href: '/en/plan' }]}>
<Planner lang="en" />
</Base>

View File

@ -0,0 +1,16 @@
---
// 회복기 스케줄러 한국어 페이지. 본문은 components/Planner.astro. 영어 페이지는 /en/plan.
import Base from '../layouts/Base.astro';
import Planner from '../components/Planner.astro';
import { fact, SITE_NAME, clinicSchema, v } from '../lib';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
const site = Astro.site!.toString().replace(/\/$/, '');
const ld = [
{ '@type': 'WebPage', '@id': `${site}/plan#page`, name: `${clinic} 회복 일정 플래너`, url: `${site}/plan`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${fact.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } },
clinicSchema(site),
];
---
<Base title="회복 일정 · 입국일, 출국 가능일, 회복기 외출 계획" description={`${clinic} 수술일을 기준으로 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 갈 만한 곳, 병원 근처 숙소를 한 화면에서 정리합니다. 외국에서 오시는 분을 위한 일정표입니다.`} jsonLd={ld} alternates={[{ lang: 'ko', href: '/plan' }, { lang: 'en', href: '/en/plan' }]}>
<Planner lang="ko" />
</Base>

View File

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

View File

@ -2,6 +2,7 @@
// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다.
import Base from '../layouts/Base.astro';
import Gallery from '../components/Gallery.astro';
import '../styles/plan.css';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib';
const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원');
@ -36,6 +37,12 @@ const ld = [
<h2>4. 도착하면</h2>
<p>{v(S.arrivalGuide, `도착 후 동선은 병원 확인 뒤 싣습니다. ${v(null)}.`)} 층별 구성 전체는 <a href="/clinic">병원 정보</a>에 있습니다.</p>
<div class="plan-entry">
<h2>외국에서 오신다면: 회복 일정 플래너</h2>
<p>시술과 수술일을 고르면 입국 권장일, 출국 가능 최소일, 회복기 동안 외출할 수 있는 날과 갈 만한 곳, 병원 근처 숙소를 한 화면에서 정리해 드립니다. 영어 화면도 있습니다.</p>
<a class="btn primary" href="/plan">일정표 만들기</a>
</div>
<h2>5. 상담 전에 읽어볼 글</h2>
<ul>
{(S.visitReads ?? []).map((r) => <li><a href={r.href}>{r.title}</a> {r.note}</li>)}

View File

@ -0,0 +1,109 @@
/* 회복기 스케줄러 (/plan). global.css 토큰만 쓴다. 흰 배경 위 강조는 인디고(#1D0024), 연보라는 다크 배경 전용. */
:root { --indigo: #1D0024; }
.plan-hero { padding: 1.6rem 0 0.6rem; }
.plan-hero h1 { font-size: 2.6rem; font-weight: 700; letter-spacing: 0; line-height: 1.15; color: var(--primary-900); }
.plan-hero .lede { color: var(--slate-600); font-size: 1rem; max-width: 760px; margin: 0.6rem 0 0; }
.plan-hero .lede strong { color: var(--indigo); }
.plan-hero .lang { display: inline-flex; gap: 0.35rem; margin-bottom: 0.7rem; }
.plan-hero .lang a, .plan-steps .lang a, .plan-hero .lang button, .plan-steps .lang button { text-decoration: none; border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.35rem 0.9rem; font: inherit; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
.plan-hero .lang a.on, .plan-steps .lang a.on, .plan-hero .lang button.on, .plan-steps .lang button.on { background: var(--primary-900); color: #fff; border-color: var(--primary-900); }
.plan-steps { display: grid; gap: 1.1rem; margin: 0.8rem 0 2rem; }
.step { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.4rem 1.6rem; box-shadow: var(--shadow); }
.phead { margin-bottom: 0.9rem; }
.phead .eyebrow { margin-bottom: 0.25rem; font-size: 0.72rem; }
.phead h2 { margin: 0; font-size: 1.5rem; font-weight: 700; color: var(--primary-900); letter-spacing: 0; }
.phead .sub { margin: 0.15rem 0 0; font-size: 0.95rem; color: var(--slate-600); }
.h3sub { font-size: 0.88rem; color: var(--slate-600); margin: -0.2rem 0 0.4rem; }
.step .hint { font-size: 0.88rem; color: var(--slate-500); margin: 0.5rem 0 0; }
.field { display: grid; gap: 0.35rem; margin-top: 0.8rem; }
.field label { font-size: 0.85rem; font-weight: 600; color: var(--slate-600); }
.field select, .field input[type="date"] { font: inherit; font-size: 1rem; padding: 0.6rem 0.8rem; border: 1px solid var(--slate-200); border-radius: 12px; background: #fff; color: var(--indigo); max-width: 320px; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 1rem; }
.proc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0.6rem; }
.proc-grid button { text-align: left; border: 1px solid var(--slate-200); background: #fff; border-radius: 14px; padding: 0.8rem 0.9rem; font: inherit; cursor: pointer; color: var(--indigo); display: grid; gap: 0.15rem; }
.proc-grid button small { color: var(--slate-500); font-size: 0.75rem; letter-spacing: 0.04em; text-transform: uppercase; font-family: Inter, Pretendard, sans-serif; }
.proc-grid button b { font-size: 0.97rem; font-weight: 700; }
.proc-grid button.on { border-color: var(--accent); background: var(--st-good-bg); box-shadow: 0 0 0 2px rgba(108,92,231,0.25) inset; }
.pref-row { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pref-row button { border: 1px solid var(--slate-200); background: #fff; color: var(--indigo); border-radius: 999px; padding: 0.5rem 1rem; font: inherit; font-size: 0.92rem; font-weight: 600; cursor: pointer; }
.pref-row button.on { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; border-color: transparent; }
.plan-actions { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: center; margin-top: 1rem; }
.btn.small { padding: 0.55rem 1rem; font-size: 0.88rem; }
.btn.ghost { background: #fff; color: var(--indigo); border: 1px solid var(--slate-200); }
button.btn { font: inherit; cursor: pointer; border: 0; }
/* 결과 */
.plan-result { display: grid; gap: 1.5rem; margin-bottom: 3rem; }
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0.8rem; }
.kpi-row .k { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1rem 1.1rem; box-shadow: var(--shadow); display: grid; gap: 0.25rem; align-content: start; }
.kpi-row .k > span { font-size: 0.74rem; color: var(--slate-500); font-weight: 600; letter-spacing: 0.02em; }
.kpi-row .k b { font-size: 1.15rem; color: var(--indigo); line-height: 1.2; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem; }
.kpi-row .k b .num { font-family: "Playfair Display", Georgia, serif; font-size: 1.45rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-900); }
.kpi-row .k b .wd { font-size: 0.82rem; font-weight: 600; color: var(--slate-500); }
.kpi-row .k small { font-size: 0.75rem; color: var(--slate-500); line-height: 1.5; }
.kpi-row .k .tag { white-space: normal; margin-right: 0.2rem; }
.tag { display: inline-block; font-size: 0.72rem; font-weight: 700; padding: 0.12rem 0.55rem; border-radius: 999px; border: 1px solid transparent; vertical-align: middle; white-space: nowrap; }
.tag.prov { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); }
.tag.clinic { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.tag.l0 { background: var(--st-critical-bg); color: var(--st-critical-text); border-color: var(--st-critical-border); }
.tag.l1 { background: var(--st-warning-bg); color: var(--st-warning-text); border-color: var(--st-warning-border); }
.tag.l2 { background: var(--st-info-bg); color: var(--st-info-text); border-color: var(--st-info-border); }
.tag.l3 { background: var(--st-good-bg); color: var(--st-good-text); border-color: var(--st-good-border); }
.tag.diet { background: var(--slate-100); color: var(--slate-700); }
.pcard { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.5rem; box-shadow: var(--shadow); }
.pcard h3 { margin: 1.2rem 0 0.35rem; font-size: 1.15rem; color: var(--primary-900); font-weight: 700; }
.pcard p { margin: 0 0 0.6rem; font-size: 0.95rem; }
.pcard ul { margin: 0.3rem 0 0; padding-left: 1.2rem; font-size: 0.93rem; }
.pcard.warn { background: var(--st-warning-bg); border-color: var(--st-warning-border); }
.pcard.warn p, .pcard.warn h2, .pcard.warn .eyebrow { color: var(--st-warning-text); }
.pcard.ok { background: var(--st-good-bg); border-color: var(--st-good-border); }
.pcard.ok p, .pcard.ok h2, .pcard.ok .eyebrow { color: var(--st-good-text); }
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.85rem; color: var(--slate-600); margin-bottom: 0.8rem; }
.legend span { display: inline-flex; align-items: center; gap: 0.35rem; }
.timeline { display: grid; gap: 0.5rem; }
.day { display: grid; grid-template-columns: 150px 1fr; gap: 0.6rem 1.2rem; align-items: start; padding: 0.85rem 1rem; border-radius: 14px; border: 1px solid var(--slate-100); background: #fff; }
.day.mark { border-color: var(--st-good-border); background: #FBFAFF; }
.day .d { display: grid; gap: 0.15rem; }
.day .d b { color: var(--indigo); font-size: 0.98rem; }
.day .d span { font-size: 0.78rem; color: var(--slate-500); }
.day .d .ev { font-size: 0.78rem; font-weight: 700; color: var(--grad-start); }
.day .body { display: grid; gap: 0.45rem; }
.day .tags { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.day .tags .desc { font-size: 0.85rem; color: var(--slate-600); }
.day .picks { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.day .picks a, .day .picks span.pick { display: inline-flex; align-items: center; gap: 0.35rem; border: 1px solid var(--slate-200); border-radius: 999px; padding: 0.3rem 0.75rem; font-size: 0.85rem; color: var(--indigo); background: #fff; }
.day .picks a:hover { border-color: var(--accent); text-decoration: none; }
.day .picks small { color: var(--slate-500); font-size: 0.72rem; }
.day .rest { font-size: 0.88rem; color: var(--slate-500); }
@media (max-width: 640px) { .day { grid-template-columns: 1fr; } }
.hotel-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.8rem; }
.hotel { border: 1px solid var(--slate-100); border-radius: 14px; padding: 0.95rem 1.05rem; display: grid; gap: 0.3rem; background: #fff; }
.hotel b { color: var(--indigo); font-size: 0.98rem; }
.hotel small { color: var(--slate-500); font-size: 0.8rem; }
.hotel p { font-size: 0.88rem; margin: 0; color: var(--slate-600); }
.hotel .links { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.3rem; }
.hotel .links a { font-size: 0.85rem; font-weight: 600; }
.fine { font-size: 0.82rem; color: var(--slate-500); }
.sources-list { font-size: 0.85rem; color: var(--slate-600); }
.toast { position: fixed; left: 50%; bottom: 2rem; transform: translateX(-50%); background: var(--primary-900); color: #fff; padding: 0.6rem 1.1rem; border-radius: 999px; font-size: 0.9rem; opacity: 0; transition: opacity 0.2s; pointer-events: none; z-index: 20; }
.toast.show { opacity: 1; }
.plan-entry { background: linear-gradient(to right, #fff3eb, #e4cfff, #f5f9ff); border-radius: 16px; padding: 1.3rem 1.5rem; box-shadow: var(--shadow); margin: 2rem 0; display: grid; gap: 0.4rem; }
.plan-entry h2 { margin: 0; font-size: 1.1rem; color: var(--indigo); }
.plan-entry p { margin: 0; color: var(--slate-700); font-size: 0.95rem; }
.plan-entry .btn { justify-self: start; margin-top: 0.4rem; }
@media print {
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .plan-steps, .plan-actions, .toast, .no-print { display: none !important; }
.plan-hero { padding: 0 0 1rem; }
.pcard, .day, .kpi-row .k { box-shadow: none; break-inside: avoid; }
}