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:
parent
85a9aa1f54
commit
e44ff60537
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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' }));
|
||||||
|
|||||||
@ -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": "",
|
||||||
|
|||||||
@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string));
|
const esc = (s: unknown) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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' }));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user