o2o-infinith-demo/supporters/src/components/Planner.astro
Haewon Kam 378ca7e7cd mobile(supporters): 회복 일정 플래너 반응형 정비 + 사이트 헤더 모바일·태블릿 대응
- 헤더(≤900px): 첫 줄 브랜드·상담 예약, 둘째 줄 메뉴 가로 스크롤. 메뉴 6개가 두 줄로 겹치던 문제를 DOM 변경 없이 CSS만으로 해결(구조 지문 유지)
- 플래너 모바일: 히어로 통계·핵심 날짜 2열, 시술 그리드 2열, 타임라인 날짜 머리 가로 배치, 숙소·예약 카드 1열, 입력 16px(iOS 확대 방지)
- 하단 고정 요약 바: 결과가 있고 결과 섹션이 화면 아래에 있을 때만 표시. 시술명, 입국→출국, 박 수, 일정표 보기 버튼
- 검증: 1280 창 안 390/768px 아이프레임 하네스로 확인 (헤드리스 크롬은 창 최소 폭 때문에 400px 직접 캡처가 부정확)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 16:20:39 +09:00

293 lines
22 KiB
Plaintext

---
// 회복기 스케줄러 본문 (설계 v0.2 §13). /plan(한국어)과 /en/plan(영어)이 같은 컴포넌트를 lang 만 바꿔 쓴다.
// 구성은 홈·영상 페이지와 같은 섹션 리듬이다: 다크 히어로(글래스 통계) → 틴트(입력 카드) → 다크(핵심 날짜) → 라이트(확인·규칙) → 틴트(타임라인) → 다크(예약).
// 건강정보(시술·수술일)는 서버로 보내지 않는다. 상태는 URL 해시로만 공유하고, 언어 전환은 해시를 들고 다른 언어 페이지로 이동한다.
import '../styles/plan.css';
import strings from '../data/planStrings.json';
import P from '../data/procedures.json';
import PL from '../data/places.json';
interface Props { lang: 'ko' | 'en' }
const { lang } = Astro.props;
const t = strings[lang];
const nProcs = P.procedures.length;
const nPlaces = PL.places.filter((x) => x.category !== 'hotel').length;
const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
---
<section class="section dark plan-hero">
<div class="wrap-wide">
<div class="sec-head">
<div class="eyebrow" style="color:var(--purple-300)">{t.eyebrow}</div>
<h1>{t.h_hero}</h1>
<p class="lede"><strong>{t.title}</strong> {t.lede}</p>
<div class="lang-links" role="group" aria-label="Language">
<a href="/plan" data-lang="ko" class={lang === 'ko' ? 'on' : ''}><span class="ico">KO</span>한국어</a>
<a href="/en/plan" data-lang="en" class={lang === 'en' ? 'on' : ''}><span class="ico">EN</span>English</a>
</div>
</div>
<div class="stats">
<div class="stat"><b>{nProcs}</b><span class="label">{t.stat_procs}</span><span class="desc">{t.stat_procs_d}</span></div>
<div class="stat"><b>{nPlaces}</b><span class="label">{t.stat_places}</span><span class="desc">{t.stat_places_d}</span></div>
<div class="stat"><b>{nHotels}</b><span class="label">{t.stat_hotels}</span><span class="desc">{t.stat_hotels_d}</span></div>
<div class="stat"><b>0</b><span class="label">{t.stat_privacy}</span><span class="desc">{t.stat_privacy_d}</span></div>
</div>
</div>
</section>
<div id="planner" data-lang={lang}>
<section class="section tint"><div class="wrap-wide"><noscript><p class="notice">{lang === 'ko' ? '이 일정표는 브라우저에서 계산합니다. 자바스크립트를 켜 주세요.' : 'This planner runs in your browser. Please enable JavaScript.'}</p></noscript></div></section>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="sticky-bar" id="sticky" hidden><div class="s"><b id="sticky-title"></b><small id="sticky-sub"></small></div><button type="button" data-action="go">{t.stickyView}</button></div>
<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 sticky = document.getElementById('sticky')!;
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 locale = () => (state.lang === 'ko' ? 'ko-KR' : 'en-GB');
const fmtDate = (iso: string) => new Intl.DateTimeFormat(locale(), { month: 'short', day: 'numeric', weekday: 'short', timeZone: 'UTC' }).format(new Date(`${iso}T00:00:00Z`));
const weekday = (iso: string) => new Intl.DateTimeFormat(locale(), { weekday: 'short', 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 statusTag = (st: string) => st === 'clinic' ? `<span class="tag clinic">${esc(T().clinicSourced)}</span>` : `<span class="tag prov">${esc(T().provisional)}</span>`;
/** 섹션 머리: 아이브로우(영문) + Playfair 영문 제목 + 한글 부제. 홈의 .sec-head 규격 */
const secHead = (eyebrow: string, en: string, sub: string, dark = false) => `<div class="sec-head"><div class="eyebrow"${dark ? ' style="color:var(--purple-300)"' : ''}>${esc(eyebrow)}</div><h2>${esc(en)}</h2>${sub ? `<p class="sub">${esc(sub)}</p>` : ''}</div>`;
const cardHead = (n: number, en: string, sub: string) => `<div class="cat step"><span class="n">${n}</span>Step ${n}</div><h3 class="serif">${esc(en)}</h3>${sub ? `<p class="sub2">${esc(sub)}</p>` : ''}`;
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); }
// ---------- 입력 (틴트 섹션, 카드 3장) ----------
function renderInput(): 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 `
<section class="section tint plan-input" style="padding:3.2rem 0"><div class="wrap-wide">
${secHead('Plan', t.h_input, t.sub_input)}
<div class="steps-grid">
<div class="card">
${cardHead(1, t.h_step1, t.step1)}
<div class="field"><label>${esc(t.lang)}</label><div class="lang-inline"><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>
<div class="card">
${cardHead(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}" /></div>
<p class="hint">${esc(t.surgeryDateHint)}</p>
</div>
<div class="card span">
${cardHead(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>
</div>
</div>
</div></section>`;
}
// ---------- 결과 ----------
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 renderEmpty(): string {
const t = T();
return `<section class="section dark" id="result"><div class="wrap-wide">${secHead('Step 4', t.h_step4, t.step4, true)}<div class="empty"><b>${esc(t.empty_title)}</b>${esc(t.empty_sub)}</div></div></section>`;
}
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;
// 다크 섹션: 핵심 날짜 6개 (글래스 통계 카드)
const dateVal = (iso: string) => `${iso.replace(/-/g, '.')}<span class="wd">${esc(weekday(iso))}</span>`;
const numVal = (n: number, unit: string) => `${n}<span class="wd">${esc(unit)}</span>`;
const txtVal = (s: string) => `<span class="txt">${esc(s)}</span>`;
const stat = (label: string, valueHtml: string | null, st: string, note?: string) => `<div class="stat"><span class="label">${esc(label)}</span><b>${valueHtml === null ? txtVal(t.pending) : valueHtml}</b><span class="desc">${statusTag(st)}${note ? ' ' + esc(note) : ''}</span></div>`;
const stitchDisplay = (p.stitchRemovalDay as any).display?.[state.lang];
const stats = [
stat(t.arriveBy, dateVal(plan.arriveBy), p.preOpDays.status, p.preOpDays.note ? (p.preOpDays.note as any)[state.lang] : undefined),
stat(t.surgery, dateVal(plan.surgeryDate), 'clinic'),
stat(t.hospital, hn === null ? null : hn === 0 ? txtVal(t.hospitalNone) : numVal(hn, fill(t.hospitalNights, { n: '' }).trim()), p.hospitalNights.status),
stat(t.stitch, plan.stitchDate ? dateVal(plan.stitchDate) : stitchDisplay ? txtVal(stitchDisplay) : null, p.stitchRemovalDay.status, p.stitchRemovalDay.note ? (p.stitchRemovalDay.note as any)[state.lang] : undefined),
stat(t.departure, plan.earliestDeparture ? dateVal(plan.earliestDeparture) : null, p.earliestDepartureDay.status),
stat(t.stayNights, plan.stayNights === null ? null : numVal(plan.stayNights, fill(t.nights, { n: '' }).trim()), p.earliestDepartureDay.status),
].join('');
const scheduleSec = `<section class="section dark" id="result"><div class="wrap-wide">
${secHead('Step 4', t.h_step4, `${t.step4 ? t.step4 + ' · ' : ''}${(p.label as any)[state.lang]}. ${t.sub_schedule}`, true)}
<div class="stats dates">${stats}</div>
<p class="note">${esc(t.departureNote)} ${esc(t.provisionalNote)}${p.readMore ? ` <a href="${esc(p.readMore)}">${esc(t.readMore)}</a>` : ''}</p>
</div></section>`;
// 라이트 섹션: 체류 확인 · 회복 규칙 · 외출 단계 근거
let visaCard = '';
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) visaCard = `<div class="card ${cls}"><div class="cat">Check</div><h3>${esc(t.h_visa)}</h3><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></div>`;
}
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 rulesSec = `<section class="section light"><div class="wrap-wide">
${secHead('Rules', t.h_rules, t.sub_rules)}
<div class="grid">
${visaCard}
<div class="card"><div class="cat">${esc(t.constraints)}</div><h3>${esc(t.h_rules)}</h3><ul class="rules">${cons}</ul></div>
${stepNotes ? `<div class="card"><div class="cat">${esc(t.levelLabel)}</div><h3>${esc(t.h_days)}</h3><ul class="rules">${stepNotes}</ul></div>` : ''}
</div>
</div></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 daysSec = `<section class="section tint"><div class="wrap-wide">
${secHead('Timeline', t.h_days, t.sub_days)}
${legend}
<div class="timeline">${days}</div>
</div></section>`;
// 다크 섹션: 예약과 저장
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="card"><div class="cat">${L(h.area)} · ${esc(fill(t.km, { km: h.distanceKm }))}</div><h3>${L(h.name)}</h3><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('');
const bookSec = `<section class="section dark"><div class="wrap-wide">
${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)}
<div class="cat" style="font-size:0.75rem;color:var(--purple-300);font-weight:700;letter-spacing:0.06em;font-family:Inter,Pretendard,sans-serif;margin-bottom:0.8rem">${esc(t.h_hotels).toUpperCase()} · ${esc(t.hotels)}</div>
<div class="hotel-grid">${hotels}</div>
<div class="book-row">
<div class="card"><div class="cat">${esc(t.h_flights)}</div><h3>${esc(t.flights)}</h3><p>${esc(t.flightHint)}</p><div class="plan-actions"><a class="btn ghost small" href="${googleFlightsUrl(checkin, checkout, state.lang)}" rel="noopener" target="_blank">${esc(t.flightSearch)}</a></div></div>
<div class="card"><div class="cat">${esc(t.h_consult)}</div><h3>${esc(t.consult)}</h3><p>${esc(t.consultHint)}</p><div class="plan-actions"><a class="btn primary small" href="${esc(reservationUrl)}" rel="noopener">${esc(t.consult)}</a><button type="button" class="btn ghost small" data-action="copy">${esc(t.copySummary)}</button></div></div>
<div class="card"><div class="cat">Save</div><h3>${esc(t.ics)}</h3><p>${esc(t.hotelHint)}</p><div class="plan-actions"><button type="button" class="btn ghost small" data-action="ics">${esc(t.ics)}</button><button type="button" class="btn ghost small" data-action="share">${esc(t.shareLink)}</button><button type="button" class="btn ghost small" data-action="print">${esc(t.print)}</button></div></div>
</div>
<p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p>
${srcs ? `<p class="note" style="margin-top:0.6rem">${esc(t.sourceLabel)}</p><ul class="sources-list">${srcs}</ul>` : ''}
</div></section>`;
return scheduleSec + rulesSec + daysSec + bookSec;
}
let lastPlan: Plan | null = null;
function render() {
const p = currentProc();
lastPlan = null;
let result = renderEmpty();
if (p && isIsoDate(state.date)) {
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
result = renderResult(lastPlan, p);
}
root.innerHTML = renderInput() + result;
syncHash();
updateSticky();
}
/** 모바일 하단 요약 바: 결과가 있고 결과 섹션이 아직 화면 아래에 있을 때만 보인다. */
function updateSticky() {
const p = currentProc();
if (!lastPlan || !p) { sticky.hidden = true; return; }
const t = T();
const short = (iso: string) => iso.slice(5).replace('-', '.');
(document.getElementById('sticky-title') as HTMLElement).textContent = (p.label as any)[state.lang];
(document.getElementById('sticky-sub') as HTMLElement).textContent = fill(t.stickyStay, { a: short(lastPlan.arriveBy), b: lastPlan.earliestDeparture ? short(lastPlan.earliestDeparture) : t.pending, n: lastPlan.stayNights ?? '?' });
const r = document.getElementById('result');
sticky.hidden = !r || r.getBoundingClientRect().top < window.innerHeight * 0.6;
}
window.addEventListener('scroll', () => { if (lastPlan) updateSticky(); }, { passive: true });
// ---------- 이벤트 ----------
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) { 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(); if (lastPlan) document.getElementById('result')?.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>