o2o-infinith-demo/supporters/src/components/Planner.astro
Haewon Kam e40f9ceb94 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>
2026-09-10 16:00:33 +09:00

242 lines
18 KiB
Plaintext

---
// 회복기 스케줄러 본문 (설계 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>