feat(supporters): 회복 일정 플래너 계측 이벤트 (메인 체크아웃 미커밋분에서 가져옴)

메인 체크아웃(feature/ai-sentiment-rubric)의 작업 트리에 커밋되지 않은 채 남아 있던
플래너 계측 코드가 main 에 없었다. 기록 노트 머리말이 "2026-09-12 추가 작업: 플래너
계측 이벤트 구현"이라고 적은 그 코드다. 브랜치 통합은 커밋된 상태까지만 가져가므로
빠졌고, 그래서 배포된 세 사이트에 플래너 계측이 없었다.

Planner.astro 의 계측 부분만 가져왔다(haewon 결정). 같은 미커밋분이 Base.astro 에도
GA4 태그를 넣는데, main 에는 이미 같은 태그가 들어가 있어 그대로 두었다. 두 구현이
겹치면 태그가 두 번 들어간다.

가져온 것
- track(): window.gtag 가 없으면 아무것도 하지 않는다. GA4 측정 ID 가 비어 있으면
  태그 자체가 안 나가므로 기본 상태에서 조용하다.
- plan_procedure_select · plan_result · plan_save 이벤트.
- leadBucket(): 수술일 원본을 보내지 않고 남은 기간 구간(past·0-7d·8-28d·29-90d·90d+)만
  보낸다. 체류 일수·출국 확정 여부·선호·국적도 값이 아닌 구간·플래그로 보낸다.
- 호텔·항공·상담 링크의 data-ga 속성. Base.astro 의 클릭 리스너가 이 값을 읽는다.

site.json 에 ga4MeasurementId 자리와 설명을 넣었다. 템플릿 빈 데이터셋에는 이미 있었다.

검증: 뷰 34페이지, 게이트 41/41, check.mjs 오류 0건, tsc 0 에러,
빈 데이터 템플릿 16페이지, 내보내기 0. 번들에서 gtag 미존재 시 조기 반환 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-14 08:55:44 +09:00
parent 85a9aa1f54
commit e44ff60537
3 changed files with 72 additions and 14 deletions

View File

@ -76,6 +76,20 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)), prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)),
}; };
// ---------- 계측 (설계 v0.2 §13.9, haewon 결정 2026-09-12) ----------
// 태그가 없으면(site.json 의 ga4MeasurementId 미설정) 아무 일도 하지 않는다.
// 수술일 원본은 보내지 않는다. 남은 기간 구간(leadBucket)과 체류 일수만 보낸다.
const track = (name: string, params: Record<string, string | number> = {}) => {
const g = (window as unknown as { gtag?: (...a: unknown[]) => void }).gtag;
if (typeof g !== 'function') return;
g('event', name, { page_lang: state.lang, ...params });
};
const leadBucket = (iso: string): string => {
const today = new Date().toISOString().slice(0, 10);
const days = Math.round((Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000);
return days < 0 ? 'past' : days <= 7 ? '0-7d' : days <= 28 ? '8-28d' : days <= 90 ? '29-90d' : '90d+';
};
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string)); 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 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 fill = (s: string, vars: Record<string, string | number>) => Object.entries(vars).reduce((acc, [k, val]) => acc.replaceAll(`{${k}}`, String(val)), s);
@ -236,15 +250,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)]; const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)];
const hotels = hotelList.map((h) => ` const hotels = hotelList.map((h) => `
<div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</div><h3>${L(h.name)}</h3><p>${L(h.note)}</p> <div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</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(''); <div class="links"><a href="${bookingUrl(h.bookingQuery ?? (h.name as any).en, checkin, checkout, state.lang)}" rel="noopener sponsored" target="_blank" data-ga="hotel" data-ga-id="${esc(h.id)}">${esc(t.hotelSearch)}</a>${h.mapQuery ? `<a href="${mapUrl(h.mapQuery)}" rel="noopener" target="_blank" data-ga="map">${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 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"> const bookSec = `<section class="section dark"><div class="wrap-wide">
${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)} ${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="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="hotel-grid">${hotels}</div>
<div class="book-row"> <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_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" data-ga="flight">${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">${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" data-ga="reservation">${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 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> </div>
<p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p> <p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p>
@ -255,6 +269,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
} }
let lastPlan: Plan | null = null; let lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = []; let lastFest: ReturnType<typeof festivalsDuring> = [];
function render() { function render() {
const p = currentProc(); const p = currentProc();
@ -265,6 +280,18 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14)); 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); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
result = renderResult(lastPlan, p); result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) {
lastTracked = key;
track('plan_result', {
procedure: p.id,
lead_time: leadBucket(state.date),
stay_nights: lastPlan.stayNights ?? 0,
departure_known: lastPlan.earliestDeparture ? 'yes' : 'pending',
prefs: state.prefs.length ? state.prefs.join(',') : 'none',
nationality: state.nat || 'none',
});
}
} }
root.innerHTML = renderInput() + result; root.innerHTML = renderInput() + result;
syncHash(); syncHash();
@ -288,14 +315,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!el) return; if (!el) return;
const a = el.dataset.action; 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' }); } if (a === 'proc') { state.proc = el.dataset.id!; track('plan_procedure_select', { procedure: state.proc }); 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 === '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 === '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 === '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 === 'copy' && lastPlan) { track('plan_save', { method: 'summary' }); 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 === 'share') { track('plan_save', { method: 'share' }); navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); }
else if (a === 'print') { window.print(); } else if (a === 'print') { track('plan_save', { method: 'print' }); window.print(); }
else if (a === 'ics' && lastPlan) { else if (a === 'ics' && lastPlan) {
track('plan_save', { method: 'ics' });
const t = T(); 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 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 url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));

View File

@ -150,6 +150,8 @@
], ],
"newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.", "newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.",
"newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등", "newsOutletsExample": "메디컬투데이, 전민일보, 아주경제, 머니S, 뉴시스 등",
"ga4MeasurementId": "",
"_comment_ga4": "GA4 측정 ID(G-XXXX). 비우면 태그를 넣지 않는다. 병원 소유 속성이 정해지면 값만 채운다. 키 이벤트 이름은 data/ai_channels.json 의 conversion_events 와 같다. 플래너 이벤트(plan_procedure_select · plan_result · plan_save)는 components/Planner.astro 가 보낸다.",
"heroImageAltEn": "View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor", "heroImageAltEn": "View Plastic Surgery building exterior and interior collage: consultation center, operating room, surgery center corridor",
"indexNowKey": "", "indexNowKey": "",
"googleSiteVerification": "", "googleSiteVerification": "",

View File

@ -76,6 +76,20 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)), prefs: (initial.pref ?? []).filter((x) => PREFS.includes(x)),
}; };
// ---------- 계측 (설계 v0.2 §13.9, haewon 결정 2026-09-12) ----------
// 태그가 없으면(site.json 의 ga4MeasurementId 미설정) 아무 일도 하지 않는다.
// 수술일 원본은 보내지 않는다. 남은 기간 구간(leadBucket)과 체류 일수만 보낸다.
const track = (name: string, params: Record<string, string | number> = {}) => {
const g = (window as unknown as { gtag?: (...a: unknown[]) => void }).gtag;
if (typeof g !== 'function') return;
g('event', name, { page_lang: state.lang, ...params });
};
const leadBucket = (iso: string): string => {
const today = new Date().toISOString().slice(0, 10);
const days = Math.round((Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / 86_400_000);
return days < 0 ? 'past' : days <= 7 ? '0-7d' : days <= 28 ? '8-28d' : days <= 90 ? '29-90d' : '90d+';
};
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string)); 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 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 fill = (s: string, vars: Record<string, string | number>) => Object.entries(vars).reduce((acc, [k, val]) => acc.replaceAll(`{${k}}`, String(val)), s);
@ -236,15 +250,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)]; const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)];
const hotels = hotelList.map((h) => ` const hotels = hotelList.map((h) => `
<div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</div><h3>${L(h.name)}</h3><p>${L(h.note)}</p> <div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</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(''); <div class="links"><a href="${bookingUrl(h.bookingQuery ?? (h.name as any).en, checkin, checkout, state.lang)}" rel="noopener sponsored" target="_blank" data-ga="hotel" data-ga-id="${esc(h.id)}">${esc(t.hotelSearch)}</a>${h.mapQuery ? `<a href="${mapUrl(h.mapQuery)}" rel="noopener" target="_blank" data-ga="map">${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 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"> const bookSec = `<section class="section dark"><div class="wrap-wide">
${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)} ${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="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="hotel-grid">${hotels}</div>
<div class="book-row"> <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_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" data-ga="flight">${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">${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" data-ga="reservation">${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 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> </div>
<p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p> <p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p>
@ -255,6 +269,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
} }
let lastPlan: Plan | null = null; let lastPlan: Plan | null = null;
let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다
let lastFest: ReturnType<typeof festivalsDuring> = []; let lastFest: ReturnType<typeof festivalsDuring> = [];
function render() { function render() {
const p = currentProc(); const p = currentProc();
@ -265,6 +280,18 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14)); 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); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
result = renderResult(lastPlan, p); result = renderResult(lastPlan, p);
const key = [p.id, state.date, state.nat, state.prefs.join('+')].join('|');
if (key !== lastTracked) {
lastTracked = key;
track('plan_result', {
procedure: p.id,
lead_time: leadBucket(state.date),
stay_nights: lastPlan.stayNights ?? 0,
departure_known: lastPlan.earliestDeparture ? 'yes' : 'pending',
prefs: state.prefs.length ? state.prefs.join(',') : 'none',
nationality: state.nat || 'none',
});
}
} }
root.innerHTML = renderInput() + result; root.innerHTML = renderInput() + result;
syncHash(); syncHash();
@ -288,14 +315,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? '';
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]'); const el = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!el) return; if (!el) return;
const a = el.dataset.action; 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' }); } if (a === 'proc') { state.proc = el.dataset.id!; track('plan_procedure_select', { procedure: state.proc }); 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 === '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 === '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 === '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 === 'copy' && lastPlan) { track('plan_save', { method: 'summary' }); 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 === 'share') { track('plan_save', { method: 'share' }); navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); }
else if (a === 'print') { window.print(); } else if (a === 'print') { track('plan_save', { method: 'print' }); window.print(); }
else if (a === 'ics' && lastPlan) { else if (a === 'ics' && lastPlan) {
track('plan_save', { method: 'ics' });
const t = T(); 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 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 url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));