design(supporters): 회복 일정 플래너를 홈·영상 페이지와 같은 섹션 리듬으로 재구성

- 다크 히어로(세리프 그라디언트 제목, 언어 알약, 글래스 통계 4장) → 틴트(Step 1~3 카드 그리드) → 다크(핵심 날짜 6개 글래스 통계) → 라이트(체류 확인·회복 규칙·외출 단계 카드) → 틴트(타임라인 흰 카드) → 다크(숙소·항공·상담·저장 카드)
- 데이터 타일의 웜 그라디언트 제거(가드레일: CTA 카드 한 장에만). 전역 .kpi 규칙과 충돌하던 클래스명 교체
- 영문 세리프 제목 + 한글 부제로 위계, 날짜·박 수는 Playfair 숫자
- 빈 상태(시술·날짜 미선택) 다크 섹션 추가

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-10 16:10:29 +09:00
parent e40f9ceb94
commit 27e77add65
3 changed files with 236 additions and 145 deletions

View File

@ -1,24 +1,41 @@
---
// 회복기 스케줄러 본문 (설계 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;
---
<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>
<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="toast" id="toast" role="status" aria-live="polite"></div>
</article>
<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>
<script>
import P from '../data/procedures.json';
@ -52,14 +69,16 @@ const t = strings[lang];
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 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 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>`;
/** 섹션 머리: 아이브로우(영문) + 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 });
@ -67,34 +86,36 @@ const t = strings[lang];
}
function toast(msg: string) { toastEl.textContent = msg; toastEl.classList.add('show'); setTimeout(() => toastEl.classList.remove('show'), 1600); }
// ---------- 입력 단계 ----------
function renderSteps(): string {
// ---------- 입력 (틴트 섹션, 카드 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 `
<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>
<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>
</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>`;
<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>`;
}
// ---------- 결과 ----------
@ -119,6 +140,12 @@ const t = strings[lang];
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();
@ -126,20 +153,29 @@ const t = strings[lang];
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 = '';
// 다크 섹션: 핵심 날짜 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];
@ -147,9 +183,20 @@ const t = strings[lang];
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>`;
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);
@ -158,55 +205,44 @@ const t = strings[lang];
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 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="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 `
<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>`;
return scheduleSec + rulesSec + daysSec + bookSec;
}
let lastPlan: Plan | null = null;
function render() {
const p = currentProc();
let result = '';
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 = 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));
root.innerHTML = renderInput() + result;
syncHash();
}
@ -214,11 +250,11 @@ const t = strings[lang];
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; }
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(); const r = document.getElementById('result'); if (r) r.scrollIntoView({ behavior: 'smooth', block: 'start' }); else (document.getElementById('sdate') as HTMLInputElement | null)?.focus(); }
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)); }

View File

@ -86,7 +86,23 @@
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation"
"h_consult": "Consultation",
"h_input": "Build Your Schedule",
"sub_input": "세 가지만 고르면 일정표가 바로 나옵니다.",
"stat_procs": "시술",
"stat_procs_d": "가슴·윤곽·눈·코·지방·리프팅·피부·모발",
"stat_places": "추천 장소",
"stat_places_d": "신논현역 기준 도보·택시 거리와 실내 여부를 함께 표시",
"stat_hotels": "근처 숙소",
"stat_hotels_d": "병원 도보 10분 안, 엘리베이터·룸서비스·24시간 프런트",
"stat_privacy": "서버 저장",
"stat_privacy_d": "시술과 수술일은 이 화면 안에서만 계산합니다",
"sub_schedule": "핵심 날짜 여섯 개입니다. 항공권과 숙소는 이 날짜로 검색합니다.",
"sub_days": "날짜마다 외출 단계와 식사 단계, 그날 갈 만한 곳을 표시합니다.",
"sub_book": "입국 권장일부터 출국 가능 최소일까지 날짜를 채워 검색합니다.",
"sub_rules": "담당 원장 안내가 우선이고, 아래는 병원 공개 안내 기준입니다.",
"empty_title": "시술과 수술일을 고르면 여기에 일정표가 나옵니다",
"empty_sub": "수술일은 예상일이어도 됩니다."
},
"en": {
"eyebrow": "Recovery Planner",
@ -174,6 +190,22 @@
"h_book": "Book & Save",
"h_hotels": "Hotels Near the Clinic",
"h_flights": "Flights",
"h_consult": "Consultation"
"h_consult": "Consultation",
"h_input": "Build Your Schedule",
"sub_input": "Pick three things and your schedule appears.",
"stat_procs": "procedures",
"stat_procs_d": "Breast, contouring, eyes, nose, fat, lifting, skin, hair",
"stat_places": "curated places",
"stat_places_d": "With walk or taxi distance from Sinnonhyeon Station and indoor flags",
"stat_hotels": "hotels nearby",
"stat_hotels_d": "Within a 10-minute walk, with elevators, room service and 24-hour desks",
"stat_privacy": "stored on servers",
"stat_privacy_d": "Your procedure and dates are calculated on this page only",
"sub_schedule": "Six key dates. Flights and hotels are searched with these.",
"sub_days": "Each day shows the outing level, diet stage and places worth going.",
"sub_book": "Searches are filled with your arrival and earliest-departure dates.",
"sub_rules": "Your surgeon's instructions come first. Below is the clinic's published guidance.",
"empty_title": "Pick a procedure and a surgery date to see your schedule here",
"empty_sub": "An estimated date is fine."
}
}

View File

@ -1,30 +1,41 @@
/* 회복기 스케줄러 (/plan). global.css 토큰만 쓴다. 흰 배경 위 강조는 인디고(#1D0024), 연보라는 다크 배경 전용. */
/* 회복기 스케줄러 (/plan, /en/plan). 섹션 리듬·카드·통계는 global.css .section/.sec-head/.grid/.card/.stats/.stat 그대로 쓰고,
여기에는 입력 컨트롤·타임라인·배지처럼 페이지에만 있는 요소만 둔다. 배경 강조는 인디고, 연보라는 다크 전용. */
: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); }
/* 히어로 (다크, videos.astro 규격) */
.plan-hero { padding: 3.5rem 0 3rem; }
.plan-hero h1 { font-family: "Playfair Display", Georgia, serif; font-size: 2.3rem; font-weight: 700; letter-spacing: 0; line-height: 1.15; display: inline-block; background: linear-gradient(to right, var(--purple-300), var(--blue-300)); -webkit-background-clip: text; background-clip: text; color: transparent; margin: 0; }
.plan-hero .lede { color: rgba(255,255,255,0.82); font-size: 1.02rem; max-width: 760px; margin: 0.6rem 0 0; }
.plan-hero .lede strong { color: #fff; }
.lang-links { display: flex; flex-wrap: wrap; gap: 0.6rem; margin-top: 1.2rem; }
.lang-links a { display: inline-flex; align-items: center; gap: 0.5rem; padding: 0.45rem 1rem 0.45rem 0.55rem; border-radius: 999px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16); color: #fff; text-decoration: none; font-size: 0.9rem; font-weight: 600; }
.lang-links a .ico { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 999px; background: rgba(255,255,255,0.12); font-family: Inter, sans-serif; font-size: 0.66rem; letter-spacing: 0.06em; color: var(--purple-200); }
.lang-links a.on { background: linear-gradient(to right, var(--grad-start), var(--grad-end)); border-color: transparent; }
.lang-links a.on .ico { background: rgba(255,255,255,0.2); color: #fff; }
.lang-links a:hover { text-decoration: none; border-color: rgba(216,180,254,0.6); }
.plan-hero .stats { margin-top: 1.8rem; }
.plan-hero .stat b { font-size: 2.2rem; }
/* 입력 단계 카드 (틴트 섹션) */
.steps-grid { display: grid; grid-template-columns: minmax(260px, 1fr) 2fr; gap: 1.25rem; }
.steps-grid .card.span { grid-column: 1 / -1; }
@media (max-width: 860px) { .steps-grid { grid-template-columns: 1fr; } }
.card .cat.step { display: inline-flex; align-items: center; gap: 0.45rem; }
.card .cat.step .n { display: inline-grid; place-items: center; width: 1.35rem; height: 1.35rem; border-radius: 999px; background: linear-gradient(to right, var(--grad-start), var(--grad-end)); color: #fff; font-family: "Playfair Display", Georgia, serif; font-size: 0.8rem; font-weight: 700; }
.card h3.serif { font-family: "Playfair Display", Georgia, serif; font-size: 1.2rem; }
.card .sub2 { font-size: 0.88rem; color: var(--slate-600); margin: 0; flex: 0; }
.card .hint { font-size: 0.83rem; color: var(--slate-500); margin: 0.4rem 0 0; flex: 0; }
.field { display: grid; gap: 0.35rem; margin-top: 0.5rem; }
.field label { font-size: 0.82rem; 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; }
.lang-inline { display: inline-flex; gap: 0.35rem; }
.lang-inline a { border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 999px; padding: 0.35rem 0.9rem; font-size: 0.85rem; font-weight: 600; text-decoration: none; }
.lang-inline a.on { background: var(--primary-900); color: #fff; border-color: var(--primary-900); }
.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 { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.55rem; }
.proc-grid button { text-align: left; border: 1px solid var(--slate-200); background: #fff; border-radius: 14px; padding: 0.7rem 0.85rem; font: inherit; cursor: pointer; color: var(--indigo); display: grid; gap: 0.1rem; }
.proc-grid button small { color: var(--slate-500); font-size: 0.7rem; letter-spacing: 0.05em; text-transform: uppercase; font-family: Inter, Pretendard, sans-serif; }
.proc-grid button b { font-size: 0.95rem; 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; }
@ -36,16 +47,11 @@
.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; }
/* 빈 상태 (다크) */
.empty { text-align: center; padding: 2.4rem 1rem; border: 1px dashed rgba(255,255,255,0.25); border-radius: 16px; color: rgba(255,255,255,0.75); }
.empty b { display: block; color: #fff; font-size: 1.1rem; margin-bottom: 0.3rem; }
/* 배지 */
.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); }
@ -55,23 +61,36 @@ button.btn { font: inherit; cursor: pointer; border: 0; }
.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); }
/* 다크 섹션 핵심 날짜: 글래스 통계 카드 변형 */
.stats.dates { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
.stats.dates .stat { padding: 1.2rem 1.3rem; display: flex; flex-direction: column; }
.stats.dates .stat .label { order: -1; margin: 0 0 0.35rem; font-size: 0.8rem; color: rgba(255,255,255,0.72); font-weight: 600; letter-spacing: 0.02em; }
.stats.dates .stat b { font-size: 1.75rem; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.4rem; }
.stats.dates .stat b .wd { font-family: Pretendard, Inter, sans-serif; font-size: 0.85rem; font-weight: 600; color: rgba(255,255,255,0.7); }
.stats.dates .stat b .txt { font-family: Pretendard, Inter, sans-serif; font-size: 1.15rem; color: #fff; }
.stats.dates .stat .desc { margin-top: 0.55rem; }
.stats.dates .tag.clinic { background: rgba(175,144,255,0.18); color: var(--purple-200); border-color: rgba(216,180,254,0.45); }
.section.dark .note { color: rgba(255,255,255,0.72); font-size: 0.88rem; margin: 1.2rem 0 0; max-width: 900px; }
.section.dark .note a { color: var(--purple-300); }
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; font-size: 0.85rem; color: var(--slate-600); margin-bottom: 0.8rem; }
/* 라이트 섹션 카드 내용 */
.card ul.rules { margin: 0.2rem 0 0; padding-left: 1.15rem; font-size: 0.92rem; color: var(--slate-700); }
.card ul.rules li { margin-bottom: 0.4rem; }
.card.ok { background: var(--st-good-bg); border-color: var(--st-good-border); }
.card.warn { background: var(--st-warning-bg); border-color: var(--st-warning-border); }
.card.warn h3, .card.warn p { color: var(--st-warning-text); }
.card.ok h3, .card.ok p { color: var(--st-good-text); }
.card .fine { font-size: 0.8rem; color: var(--slate-500); margin-top: 0.3rem; flex: 0; }
.card.warn .fine, .card.ok .fine { color: inherit; opacity: 0.85; }
/* 타임라인 (틴트 섹션 위 흰 카드) */
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem 1.2rem; font-size: 0.85rem; color: var(--slate-600); margin: -0.4rem 0 1.2rem; }
.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; }
.timeline { display: grid; gap: 0.6rem; }
.day { display: grid; grid-template-columns: 160px 1fr; gap: 0.6rem 1.2rem; align-items: start; padding: 0.95rem 1.2rem; border-radius: 16px; border: 1px solid var(--slate-100); background: #fff; box-shadow: var(--shadow); }
.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 { display: grid; gap: 0.1rem; }
.day .d b { color: var(--primary-900); font-size: 1rem; font-family: "Playfair Display", Georgia, serif; font-weight: 700; }
.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; }
@ -84,26 +103,30 @@ button.btn { font: inherit; cursor: pointer; border: 0; }
.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; }
/* 예약 (다크 섹션 위 흰 카드) */
.hotel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1rem; }
.hotel-grid .card small { color: var(--slate-500); font-size: 0.78rem; }
.hotel-grid .card .links { display: flex; gap: 0.7rem; flex-wrap: wrap; font-size: 0.85rem; font-weight: 600; }
.book-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.25rem; margin-top: 1.5rem; }
.book-row .card .plan-actions { margin-top: 0.4rem; }
.section.dark .disclosure { color: rgba(255,255,255,0.6); border-top-color: rgba(255,255,255,0.12); }
.section.dark .disclosure a, .section.dark .sources-list a { color: var(--purple-300); }
.section.dark .sources-list { font-size: 0.85rem; color: rgba(255,255,255,0.7); margin: 0.3rem 0 0; }
.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; }
/* 방문 안내 진입 CTA 카드 (웜 그라디언트는 CTA 카드 한 장에만) */
.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; }
.site-header, .backbar-wrap, .totop, .site-footer, .sample-banner, .plan-input, .plan-actions, .toast, .lang-links { display: none !important; }
.section { padding: 1rem 0 !important; }
.section.dark, .section.tint { background: #fff !important; color: var(--primary-900); }
.section.dark .sec-head h2, .plan-hero h1 { -webkit-text-fill-color: var(--primary-900); background: none; }
.stat { background: #fff; border: 1px solid var(--slate-200); } .stat b { color: var(--primary-900); } .stat .label, .stat .desc { color: var(--slate-700); }
.card, .day { box-shadow: none; break-inside: avoid; }
}