feat(supporters): 주변 안내 축제를 회복 일정의 체류 기간 기준으로 먼저 보여 준다
주변 안내(/stay·/en/stay)의 축제 목록이 계절 묶음만 있고, 회복 일정 플래너에서 고른
기간과 이어지지 않았다(haewon 2026-09-16). 플래너가 체류 기간(도착일~출국 가능일)을
이 기기의 localStorage 에 남기고, 주변 안내는 그 기간과 겹치는 행사를 위로 올린다.
- Planner.astro: 결과를 그릴 때 체류 기간만 저장(시술·수술일은 저장하지 않음, 서버로도
가지 않음). 행사 섹션에 "주변 안내에서 이 기간의 행사 전체 보기" 링크(/stay#plan:from..to)
- stay.astro·en/stay.astro: 카드에 data-start·data-end·data-km, "체류 기간" 칩(기본 숨김),
기간 안내 문단. 스크립트가 URL 해시 또는 localStorage 에서 기간을 읽어 겹치는 카드를
맨 앞으로 옮기고 칩을 켠다. 기간이 짧은 행사가 앞, 90일을 넘는 상시 행사는 뒤('상시 진행')
- 기간이 없거나 겹침이 없으면 화면은 이전과 같다. 저장소를 못 쓰는 브라우저는 조용히 건너뛴다
- global.css: 겹치는 카드 테두리·오른쪽 배지, 숨긴 칩
- run.mjs: planStrings.json 을 코드 갱신 목록에 넣는다. 기존 병원 폴더에 옛 문구 파일이
남아 링크 글자가 비어 나온 것을 캡처로 발견했다
검증: 원진 빌드 게이트 통과, plan.test 14건 통과, 로컬 미리보기(Playwright)로
플래너 저장→주변 안내 정렬(20/24건, 짧은 행사 먼저)·해시 진입·기간 없음·영문·모바일 확인.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
98a3e41837
commit
796fa56106
@ -243,7 +243,8 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
|
||||
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
|
||||
}).join('');
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
|
||||
const stayLink = `<p class="fine" style="margin-top:1rem"><a href="${stayHref(plan)}" data-ga="stay-events">${esc(t.fest_stay_link)} →</a></p>`;
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${stayLink}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
|
||||
|
||||
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
|
||||
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
|
||||
@ -268,6 +269,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
|
||||
}
|
||||
|
||||
/** 체류 기간(도착일~출국 가능일)만 이 기기에 남긴다. 주변 안내(/stay)가 같은 기간의 행사를 먼저 보여 주는 데 쓴다.
|
||||
* 시술·수술일은 저장하지 않고 서버로도 가지 않는다(머리말 원칙). 저장이 막힌 브라우저에서는 조용히 건너뛴다. */
|
||||
const PERIOD_KEY = 'infinith.stay.period';
|
||||
const stayPeriod = (plan: Plan) => ({ from: plan.arriveBy, to: plan.earliestDeparture ?? addDays(plan.surgeryDate, 14) });
|
||||
function savePeriod(plan: Plan) {
|
||||
try { localStorage.setItem(PERIOD_KEY, JSON.stringify(stayPeriod(plan))); } catch { /* 비공개 창 등 */ }
|
||||
}
|
||||
const stayHref = (plan: Plan) => { const { from, to } = stayPeriod(plan); return `${state.lang === 'en' ? '/en/stay' : '/stay'}#plan:${from}..${to}`; };
|
||||
|
||||
let lastPlan: Plan | null = null;
|
||||
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
|
||||
let lastFest: ReturnType<typeof festivalsDuring> = [];
|
||||
@ -279,6 +289,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
|
||||
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
|
||||
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
|
||||
savePeriod(lastPlan);
|
||||
result = renderResult(lastPlan, p);
|
||||
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
|
||||
if (key !== lastTracked) {
|
||||
|
||||
@ -115,7 +115,8 @@
|
||||
"tour_badge": "관광공사",
|
||||
"festival_pick": "행사",
|
||||
"readMoreShort": "홈페이지",
|
||||
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시"
|
||||
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
|
||||
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
|
||||
},
|
||||
"en": {
|
||||
"eyebrow": "Recovery Planner",
|
||||
@ -232,6 +233,7 @@
|
||||
"tour_badge": "KTO",
|
||||
"festival_pick": "Event",
|
||||
"readMoreShort": "Website",
|
||||
"stat_places_d_curated": "Curated picks, with distance from {origin}"
|
||||
"stat_places_d_curated": "Curated picks, with distance from {origin}",
|
||||
"fest_stay_link": "See every event for these dates in the area guide"
|
||||
}
|
||||
}
|
||||
|
||||
@ -320,7 +320,9 @@ const ld = [
|
||||
<p class="sub">{festivals.length > 0
|
||||
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
|
||||
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
|
||||
<p class="sub" data-plan-note hidden></p>
|
||||
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
|
||||
<button class="chip" data-season="plan" hidden>Your stay <b>0</b></button>
|
||||
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = festivals.filter((e: any) => e.season === s.key).length;
|
||||
@ -329,7 +331,7 @@ const ld = [
|
||||
</div>}
|
||||
<div class="cards fest">
|
||||
{festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
@ -499,16 +501,63 @@ const ld = [
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
|
||||
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
|
||||
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
|
||||
(function () {
|
||||
var sec = document.getElementById('festivals'); if (!sec) return;
|
||||
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
|
||||
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
|
||||
var planChip = sec.querySelector('.chip[data-season="plan"]');
|
||||
var note = sec.querySelector('[data-plan-note]');
|
||||
function apply(want) {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
|
||||
cards.forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
|
||||
});
|
||||
}
|
||||
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
|
||||
|
||||
var ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
function readPeriod() {
|
||||
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
|
||||
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
|
||||
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
|
||||
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
|
||||
return null;
|
||||
}
|
||||
var period = readPeriod();
|
||||
if (!period || !planChip || !note) return;
|
||||
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
|
||||
var hits = cards.filter(function (card) {
|
||||
var s = iso(card.dataset.start), e = iso(card.dataset.end);
|
||||
var ok = Boolean(s && e && s <= period.to && e >= period.from);
|
||||
card.dataset.inplan = ok ? '1' : '0';
|
||||
return ok;
|
||||
});
|
||||
var fmt = function (d) { return new Date(d + 'T00:00:00Z').toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }); };
|
||||
if (!hits.length) { note.textContent = 'No events overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Showing all events by season.'; note.hidden = false; return; }
|
||||
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
|
||||
var LONG = 90;
|
||||
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
|
||||
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
|
||||
var grid = sec.querySelector('.cards.fest');
|
||||
hits.forEach(function (card) {
|
||||
card.classList.add('in-plan');
|
||||
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? 'Year-round' : 'Your stay';
|
||||
card.querySelector('.thumb').appendChild(tag);
|
||||
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
|
||||
});
|
||||
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
|
||||
planChip.querySelector('b').textContent = String(hits.length);
|
||||
planChip.hidden = false;
|
||||
note.textContent = 'Showing ' + hits.length + ' events that overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Short-run events come first; year-round ones are listed after them.';
|
||||
note.hidden = false;
|
||||
apply('plan');
|
||||
if (period.fromHash) sec.scrollIntoView();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
@ -321,7 +321,9 @@ const ld = [
|
||||
<p class="sub">{festivals.length > 0
|
||||
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
|
||||
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
|
||||
<p class="sub" data-plan-note hidden></p>
|
||||
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
|
||||
<button class="chip" data-season="plan" hidden>체류 기간 <b>0</b></button>
|
||||
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = festivals.filter((e: any) => e.season === s.key).length;
|
||||
@ -330,7 +332,7 @@ const ld = [
|
||||
</div>}
|
||||
<div class="cards fest">
|
||||
{festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
@ -500,16 +502,63 @@ const ld = [
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
|
||||
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
|
||||
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
|
||||
(function () {
|
||||
var sec = document.getElementById('festivals'); if (!sec) return;
|
||||
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
|
||||
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
|
||||
var planChip = sec.querySelector('.chip[data-season="plan"]');
|
||||
var note = sec.querySelector('[data-plan-note]');
|
||||
function apply(want) {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
|
||||
cards.forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
|
||||
});
|
||||
}
|
||||
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
|
||||
|
||||
var ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
function readPeriod() {
|
||||
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
|
||||
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
|
||||
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
|
||||
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
|
||||
return null;
|
||||
}
|
||||
var period = readPeriod();
|
||||
if (!period || !planChip || !note) return;
|
||||
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
|
||||
var hits = cards.filter(function (card) {
|
||||
var s = iso(card.dataset.start), e = iso(card.dataset.end);
|
||||
var ok = Boolean(s && e && s <= period.to && e >= period.from);
|
||||
card.dataset.inplan = ok ? '1' : '0';
|
||||
return ok;
|
||||
});
|
||||
var fmt = function (d) { return (+d.slice(5, 7)) + '월 ' + (+d.slice(8, 10)) + '일'; };
|
||||
if (!hits.length) { note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사가 없습니다. 계절별 목록을 보여 드립니다.'; note.hidden = false; return; }
|
||||
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
|
||||
var LONG = 90;
|
||||
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
|
||||
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
|
||||
var grid = sec.querySelector('.cards.fest');
|
||||
hits.forEach(function (card) {
|
||||
card.classList.add('in-plan');
|
||||
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? '상시 진행' : '체류 기간';
|
||||
card.querySelector('.thumb').appendChild(tag);
|
||||
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
|
||||
});
|
||||
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
|
||||
planChip.querySelector('b').textContent = String(hits.length);
|
||||
planChip.hidden = false;
|
||||
note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사 ' + hits.length + '건을 먼저 보여 드립니다. 기간이 짧은 행사가 앞이고, 1년 내내 열리는 상시 행사는 뒤에 있습니다.';
|
||||
note.hidden = false;
|
||||
apply('plan');
|
||||
if (period.fromHash) sec.scrollIntoView();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
@ -321,6 +321,10 @@ td { color: var(--slate-700); }
|
||||
}
|
||||
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
|
||||
.stay .card .badge.mono { letter-spacing: 0.06em; }
|
||||
/* 회복 일정의 체류 기간과 겹치는 행사: 카드 테두리와 오른쪽 아래 표시. 월 배지(왼쪽)와 겹치지 않게 오른쪽에 둔다 */
|
||||
.stay .card.in-plan { border-color: var(--accent, #6C5CE7); box-shadow: 3px 4px 12px rgba(108, 92, 231, 0.16); }
|
||||
.stay .card .badge.plan { left: auto; right: 0.6rem; background: var(--accent, #6C5CE7); }
|
||||
.stay .chip[hidden] { display: none; }
|
||||
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
|
||||
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }
|
||||
|
||||
@ -243,7 +243,8 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
|
||||
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
|
||||
}).join('');
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
|
||||
const stayLink = `<p class="fine" style="margin-top:1rem"><a href="${stayHref(plan)}" data-ga="stay-events">${esc(t.fest_stay_link)} →</a></p>`;
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}${stayLink}${tourDate ? `<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p>` : ''}</div></section>`;
|
||||
|
||||
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
|
||||
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
|
||||
@ -268,6 +269,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
|
||||
}
|
||||
|
||||
/** 체류 기간(도착일~출국 가능일)만 이 기기에 남긴다. 주변 안내(/stay)가 같은 기간의 행사를 먼저 보여 주는 데 쓴다.
|
||||
* 시술·수술일은 저장하지 않고 서버로도 가지 않는다(머리말 원칙). 저장이 막힌 브라우저에서는 조용히 건너뛴다. */
|
||||
const PERIOD_KEY = 'infinith.stay.period';
|
||||
const stayPeriod = (plan: Plan) => ({ from: plan.arriveBy, to: plan.earliestDeparture ?? addDays(plan.surgeryDate, 14) });
|
||||
function savePeriod(plan: Plan) {
|
||||
try { localStorage.setItem(PERIOD_KEY, JSON.stringify(stayPeriod(plan))); } catch { /* 비공개 창 등 */ }
|
||||
}
|
||||
const stayHref = (plan: Plan) => { const { from, to } = stayPeriod(plan); return `${state.lang === 'en' ? '/en/stay' : '/stay'}#plan:${from}..${to}`; };
|
||||
|
||||
let lastPlan: Plan | null = null;
|
||||
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
|
||||
let lastFest: ReturnType<typeof festivalsDuring> = [];
|
||||
@ -279,6 +289,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
|
||||
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
|
||||
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
|
||||
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
|
||||
savePeriod(lastPlan);
|
||||
result = renderResult(lastPlan, p);
|
||||
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
|
||||
if (key !== lastTracked) {
|
||||
|
||||
@ -115,7 +115,8 @@
|
||||
"tour_badge": "관광공사",
|
||||
"festival_pick": "행사",
|
||||
"readMoreShort": "홈페이지",
|
||||
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시"
|
||||
"stat_places_d_curated": "큐레이션. {origin} 기준 거리 표시",
|
||||
"fest_stay_link": "주변 안내에서 이 기간의 행사 전체 보기"
|
||||
},
|
||||
"en": {
|
||||
"eyebrow": "Recovery Planner",
|
||||
@ -232,6 +233,7 @@
|
||||
"tour_badge": "KTO",
|
||||
"festival_pick": "Event",
|
||||
"readMoreShort": "Website",
|
||||
"stat_places_d_curated": "Curated picks, with distance from {origin}"
|
||||
"stat_places_d_curated": "Curated picks, with distance from {origin}",
|
||||
"fest_stay_link": "See every event for these dates in the area guide"
|
||||
}
|
||||
}
|
||||
|
||||
@ -320,7 +320,9 @@ const ld = [
|
||||
<p class="sub">{festivals.length > 0
|
||||
? `${festivals.length} events${festKm ? ` within ${festKm} km` : ''}, grouped by season. Right now it is `
|
||||
: 'Events will follow once the clinic\'s location is confirmed. Right now it is '}<strong>{nowSeason}</strong>.</p>
|
||||
<p class="sub" data-plan-note hidden></p>
|
||||
{festivals.length > 0 && <div class="chips" role="group" aria-label="Filter by season">
|
||||
<button class="chip" data-season="plan" hidden>Your stay <b>0</b></button>
|
||||
<button class="chip on" data-season="all">All <b>{festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = festivals.filter((e: any) => e.season === s.key).length;
|
||||
@ -329,7 +331,7 @@ const ld = [
|
||||
</div>}
|
||||
<div class="cards fest">
|
||||
{festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title} : festival near ${clinic}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
@ -499,16 +501,63 @@ const ld = [
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
|
||||
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
|
||||
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
|
||||
(function () {
|
||||
var sec = document.getElementById('festivals'); if (!sec) return;
|
||||
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
|
||||
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
|
||||
var planChip = sec.querySelector('.chip[data-season="plan"]');
|
||||
var note = sec.querySelector('[data-plan-note]');
|
||||
function apply(want) {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
|
||||
cards.forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
|
||||
});
|
||||
}
|
||||
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
|
||||
|
||||
var ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
function readPeriod() {
|
||||
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
|
||||
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
|
||||
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
|
||||
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
|
||||
return null;
|
||||
}
|
||||
var period = readPeriod();
|
||||
if (!period || !planChip || !note) return;
|
||||
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
|
||||
var hits = cards.filter(function (card) {
|
||||
var s = iso(card.dataset.start), e = iso(card.dataset.end);
|
||||
var ok = Boolean(s && e && s <= period.to && e >= period.from);
|
||||
card.dataset.inplan = ok ? '1' : '0';
|
||||
return ok;
|
||||
});
|
||||
var fmt = function (d) { return new Date(d + 'T00:00:00Z').toLocaleDateString('en', { month: 'short', day: 'numeric', timeZone: 'UTC' }); };
|
||||
if (!hits.length) { note.textContent = 'No events overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Showing all events by season.'; note.hidden = false; return; }
|
||||
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
|
||||
var LONG = 90;
|
||||
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
|
||||
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
|
||||
var grid = sec.querySelector('.cards.fest');
|
||||
hits.forEach(function (card) {
|
||||
card.classList.add('in-plan');
|
||||
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? 'Year-round' : 'Your stay';
|
||||
card.querySelector('.thumb').appendChild(tag);
|
||||
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
|
||||
});
|
||||
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
|
||||
planChip.querySelector('b').textContent = String(hits.length);
|
||||
planChip.hidden = false;
|
||||
note.textContent = 'Showing ' + hits.length + ' events that overlap the stay you chose in the recovery planner (' + fmt(period.from) + ' to ' + fmt(period.to) + '). Short-run events come first; year-round ones are listed after them.';
|
||||
note.hidden = false;
|
||||
apply('plan');
|
||||
if (period.fromHash) sec.scrollIntoView();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
@ -321,7 +321,9 @@ const ld = [
|
||||
<p class="sub">{festivals.length > 0
|
||||
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
|
||||
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
|
||||
<p class="sub" data-plan-note hidden></p>
|
||||
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
|
||||
<button class="chip" data-season="plan" hidden>체류 기간 <b>0</b></button>
|
||||
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = festivals.filter((e: any) => e.season === s.key).length;
|
||||
@ -330,7 +332,7 @@ const ld = [
|
||||
</div>}
|
||||
<div class="cards fest">
|
||||
{festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<article class="card" data-season={e.season} data-start={e.startDate || ""} data-end={e.endDate || ""} data-km={e.distanceKm ?? ""}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
|
||||
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
|
||||
@ -500,16 +502,63 @@ const ld = [
|
||||
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
|
||||
})();
|
||||
|
||||
var sc = document.querySelectorAll('#festivals .chip');
|
||||
sc.forEach(function (c) {
|
||||
c.addEventListener('click', function () {
|
||||
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
|
||||
var want = c.dataset.season;
|
||||
document.querySelectorAll('#festivals .card').forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || card.dataset.season === want);
|
||||
// 회복 일정 플래너에서 고른 체류 기간과 겹치는 행사를 먼저 보여 준다.
|
||||
// 기간은 플래너 링크의 URL 해시(#plan:YYYY-MM-DD..YYYY-MM-DD) 또는 이 기기의 localStorage 에서만 읽는다. 서버로 보내지 않는다.
|
||||
// 기간이 없거나 겹치는 행사가 없으면 화면은 계절 묶음 그대로다.
|
||||
(function () {
|
||||
var sec = document.getElementById('festivals'); if (!sec) return;
|
||||
var chips = Array.prototype.slice.call(sec.querySelectorAll('.chip'));
|
||||
var cards = Array.prototype.slice.call(sec.querySelectorAll('.card'));
|
||||
var planChip = sec.querySelector('.chip[data-season="plan"]');
|
||||
var note = sec.querySelector('[data-plan-note]');
|
||||
function apply(want) {
|
||||
chips.forEach(function (x) { x.classList.toggle('on', x.dataset.season === want); });
|
||||
cards.forEach(function (card) {
|
||||
card.hidden = !(want === 'all' || (want === 'plan' ? card.dataset.inplan === '1' : card.dataset.season === want));
|
||||
});
|
||||
}
|
||||
chips.forEach(function (c) { c.addEventListener('click', function () { apply(c.dataset.season); }); });
|
||||
|
||||
var ISO = /^\d{4}-\d{2}-\d{2}$/;
|
||||
function readPeriod() {
|
||||
var m = /plan:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/.exec(location.hash);
|
||||
if (m && m[1] <= m[2]) return { from: m[1], to: m[2], fromHash: true };
|
||||
try {
|
||||
var p = JSON.parse(localStorage.getItem('infinith.stay.period') || 'null');
|
||||
if (p && ISO.test(p.from) && ISO.test(p.to) && p.from <= p.to) return { from: p.from, to: p.to, fromHash: false };
|
||||
} catch (e) { /* 저장소를 못 쓰는 브라우저 */ }
|
||||
return null;
|
||||
}
|
||||
var period = readPeriod();
|
||||
if (!period || !planChip || !note) return;
|
||||
var iso = function (d) { return d && d.length === 8 ? d.slice(0, 4) + '-' + d.slice(4, 6) + '-' + d.slice(6, 8) : ''; };
|
||||
var hits = cards.filter(function (card) {
|
||||
var s = iso(card.dataset.start), e = iso(card.dataset.end);
|
||||
var ok = Boolean(s && e && s <= period.to && e >= period.from);
|
||||
card.dataset.inplan = ok ? '1' : '0';
|
||||
return ok;
|
||||
});
|
||||
var fmt = function (d) { return (+d.slice(5, 7)) + '월 ' + (+d.slice(8, 10)) + '일'; };
|
||||
if (!hits.length) { note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사가 없습니다. 계절별 목록을 보여 드립니다.'; note.hidden = false; return; }
|
||||
// 기간이 짧은 행사가 먼저다. 1년 내내 열리는 상시 행사는 체류 기간과 늘 겹치므로 뒤로 보내고 '상시'로 표시한다.
|
||||
var LONG = 90;
|
||||
var days = function (card) { var s = iso(card.dataset.start), e = iso(card.dataset.end); return s && e ? Math.round((Date.parse(e + 'T00:00:00Z') - Date.parse(s + 'T00:00:00Z')) / 86400000) + 1 : 9999; };
|
||||
hits.sort(function (a, b) { return (days(a) > LONG) - (days(b) > LONG) || days(a) - days(b) || (+a.dataset.km || 0) - (+b.dataset.km || 0); });
|
||||
var grid = sec.querySelector('.cards.fest');
|
||||
hits.forEach(function (card) {
|
||||
card.classList.add('in-plan');
|
||||
var tag = document.createElement('span'); tag.className = 'badge plan'; tag.textContent = days(card) > LONG ? '상시 진행' : '체류 기간';
|
||||
card.querySelector('.thumb').appendChild(tag);
|
||||
grid.appendChild(card); // 겹치는 카드를 가까운 순으로 뒤에 다시 붙인 뒤,
|
||||
});
|
||||
hits.slice().reverse().forEach(function (card) { grid.insertBefore(card, grid.firstChild); }); // 그 순서 그대로 맨 앞으로 (짧은 행사 → 상시 행사 → 나머지)
|
||||
planChip.querySelector('b').textContent = String(hits.length);
|
||||
planChip.hidden = false;
|
||||
note.textContent = '회복 일정에서 고른 체류 기간(' + fmt(period.from) + ' ~ ' + fmt(period.to) + ')과 겹치는 행사 ' + hits.length + '건을 먼저 보여 드립니다. 기간이 짧은 행사가 앞이고, 1년 내내 열리는 상시 행사는 뒤에 있습니다.';
|
||||
note.hidden = false;
|
||||
apply('plan');
|
||||
if (period.fromHash) sec.scrollIntoView();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</Base>
|
||||
|
||||
@ -321,6 +321,10 @@ td { color: var(--slate-700); }
|
||||
}
|
||||
.stay .card .badge em { font-style: normal; opacity: 0.7; margin-left: 0.25em; }
|
||||
.stay .card .badge.mono { letter-spacing: 0.06em; }
|
||||
/* 회복 일정의 체류 기간과 겹치는 행사: 카드 테두리와 오른쪽 아래 표시. 월 배지(왼쪽)와 겹치지 않게 오른쪽에 둔다 */
|
||||
.stay .card.in-plan { border-color: var(--accent, #6C5CE7); box-shadow: 3px 4px 12px rgba(108, 92, 231, 0.16); }
|
||||
.stay .card .badge.plan { left: auto; right: 0.6rem; background: var(--accent, #6C5CE7); }
|
||||
.stay .chip[hidden] { display: none; }
|
||||
.stay .card .body { padding: 0.85rem 0.95rem 1rem; display: flex; flex-direction: column; flex: 1; }
|
||||
.stay .card h4 { margin: 0 0 0.35rem; font-size: 0.98rem; }
|
||||
.stay .card .when { font-size: 0.8rem; color: var(--slate-600, #475569); margin: 0 0 0.4rem; }
|
||||
|
||||
@ -60,7 +60,9 @@ function applySiteUrl() {
|
||||
// 기존 병원 폴더에 닿지 않는다. 2026-09-14 에 소유 확인 메타와 플래너 계측이 이 이유로 배포되지 않았다.
|
||||
// 병원별로 갈리는 것(astro.config.mjs·vercel.json 의 치환값, 구조 기준선, home_text)은 덮지 않는다.
|
||||
const CODE_DIRS = ['src/layouts', 'src/components', 'src/pages', 'src/styles', 'src/lib'];
|
||||
const CODE_FILES = ['src/lib.ts', 'src/content.config.ts'];
|
||||
// src/data 는 병원 데이터라 덮지 않지만, planStrings.json 은 플래너 화면 문구(한·영)라 코드와 함께 간다.
|
||||
// 2026-09-16: 플래너에 새 문구를 넣었는데 기존 병원 폴더의 옛 파일이 남아 링크 글자가 비어 나온 것을 캡처로 발견했다.
|
||||
const CODE_FILES = ['src/lib.ts', 'src/content.config.ts', 'src/data/planStrings.json'];
|
||||
function refreshTemplateCode() {
|
||||
if (!existsSync(TEMPLATE)) return '템플릿 없음';
|
||||
let n = 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user