diff --git a/supporters/src/components/Planner.astro b/supporters/src/components/Planner.astro index 1cf8dd3..413425e 100644 --- a/supporters/src/components/Planner.astro +++ b/supporters/src/components/Planner.astro @@ -76,6 +76,20 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; 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 = {}) => { + 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 T = (): Strings => (STR as any)[state.lang]; const fill = (s: string, vars: Record) => 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 hotels = hotelList.map((h) => `
${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}

${L(h.name)}

${L(h.note)}

-
`).join(''); + `).join(''); const srcs = p.sources.map((id) => (P as any).clinicSources.find((s: any) => s.id === id)).filter(Boolean).map((s: any) => `
  • ${L(s.label)}
  • `).join(''); const bookSec = `
    ${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)}
    ${esc(t.h_hotels).toUpperCase()} · ${esc(t.hotels)}
    ${hotels}
    -
    ${esc(t.h_flights)}

    ${esc(t.flights)}

    ${esc(t.flightHint)}

    -
    ${esc(t.h_consult)}

    ${esc(t.consult)}

    ${esc(t.consultHint)}

    ${esc(t.consult)}
    +
    ${esc(t.h_flights)}

    ${esc(t.flights)}

    ${esc(t.flightHint)}

    +
    ${esc(t.h_consult)}

    ${esc(t.consult)}

    ${esc(t.consultHint)}

    ${esc(t.consult)}
    Save

    ${esc(t.ics)}

    ${esc(t.hotelHint)}

    ${esc(t.affiliate)} ${esc(t.medical)}

    @@ -255,6 +269,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; } let lastPlan: Plan | null = null; + let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다 let lastFest: ReturnType = []; function render() { 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)); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places); 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; syncHash(); @@ -288,14 +315,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; const el = (e.target as HTMLElement).closest('[data-action]'); if (!el) 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' }); } + 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 === 'go') { render(); if (lastPlan) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); else (document.getElementById('sdate') as HTMLInputElement | null)?.focus(); } else if (a === 'reset') { state.proc = ''; state.date = ''; state.prefs = []; state.nat = ''; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); } - else if (a === 'copy' && lastPlan) { navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).then(() => toast(T().copied)); } - else if (a === 'share') { navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); } - else if (a === 'print') { window.print(); } + else if (a === 'copy' && lastPlan) { track('plan_save', { method: 'summary' }); navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).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') { track('plan_save', { method: 'print' }); window.print(); } else if (a === 'ics' && lastPlan) { + track('plan_save', { method: 'ics' }); const t = T(); const ics = buildIcs(lastPlan, { arrive: t.arriveBy, surgery: `${t.surgery} · ${(currentProc()!.label as any)[state.lang]}`, stitch: t.stitch, departure: t.departure }, t.summaryTitle); const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' })); diff --git a/supporters/src/data/site.json b/supporters/src/data/site.json index c162160..e67f2f1 100644 --- a/supporters/src/data/site.json +++ b/supporters/src/data/site.json @@ -150,6 +150,8 @@ ], "newsNote": "뷰성형외과 홈페이지 언론보도 게시판과 네이버 뉴스에서 모은 기사입니다(2026-09-04 기준). 병원이 알리거나 원장이 설명한 기사만 모았고, 그 밖의 보도는 포함하지 않았습니다. 기사 본문은 원문에서 읽을 수 있으며, 원문이 사라진 기사는 병원 게시판 사본으로 연결됩니다.", "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", "indexNowKey": "", "googleSiteVerification": "", diff --git a/templates/supporters-astro/src/components/Planner.astro b/templates/supporters-astro/src/components/Planner.astro index 1cf8dd3..413425e 100644 --- a/templates/supporters-astro/src/components/Planner.astro +++ b/templates/supporters-astro/src/components/Planner.astro @@ -76,6 +76,20 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; 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 = {}) => { + 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 T = (): Strings => (STR as any)[state.lang]; const fill = (s: string, vars: Record) => 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 hotels = hotelList.map((h) => `
    ${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}

    ${L(h.name)}

    ${L(h.note)}

    -
    `).join(''); +
    `).join(''); const srcs = p.sources.map((id) => (P as any).clinicSources.find((s: any) => s.id === id)).filter(Boolean).map((s: any) => `
  • ${L(s.label)}
  • `).join(''); const bookSec = `
    ${secHead('Step 5', t.h_book, `${t.step5 ? t.step5 + '. ' : ''}${t.sub_book}`, true)}
    ${esc(t.h_hotels).toUpperCase()} · ${esc(t.hotels)}
    ${hotels}
    -
    ${esc(t.h_flights)}

    ${esc(t.flights)}

    ${esc(t.flightHint)}

    -
    ${esc(t.h_consult)}

    ${esc(t.consult)}

    ${esc(t.consultHint)}

    ${esc(t.consult)}
    +
    ${esc(t.h_flights)}

    ${esc(t.flights)}

    ${esc(t.flightHint)}

    +
    ${esc(t.h_consult)}

    ${esc(t.consult)}

    ${esc(t.consultHint)}

    ${esc(t.consult)}
    Save

    ${esc(t.ics)}

    ${esc(t.hotelHint)}

    ${esc(t.affiliate)} ${esc(t.medical)}

    @@ -255,6 +269,7 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; } let lastPlan: Plan | null = null; + let lastTracked = ''; // 같은 입력으로 다시 그릴 때 plan_result 를 중복 전송하지 않는다 let lastFest: ReturnType = []; function render() { 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)); lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places); 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; syncHash(); @@ -288,14 +315,15 @@ const originLabel = (PL as any).origin?.label?.[lang] ?? ''; const el = (e.target as HTMLElement).closest('[data-action]'); if (!el) 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' }); } + 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 === 'go') { render(); if (lastPlan) document.getElementById('result')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); else (document.getElementById('sdate') as HTMLInputElement | null)?.focus(); } else if (a === 'reset') { state.proc = ''; state.date = ''; state.prefs = []; state.nat = ''; render(); window.scrollTo({ top: 0, behavior: 'smooth' }); } - else if (a === 'copy' && lastPlan) { navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).then(() => toast(T().copied)); } - else if (a === 'share') { navigator.clipboard.writeText(location.href).then(() => toast(T().copied)); } - else if (a === 'print') { window.print(); } + else if (a === 'copy' && lastPlan) { track('plan_save', { method: 'summary' }); navigator.clipboard.writeText(summaryText(lastPlan, currentProc()!, state.lang, T())).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') { track('plan_save', { method: 'print' }); window.print(); } else if (a === 'ics' && lastPlan) { + track('plan_save', { method: 'ics' }); const t = T(); const ics = buildIcs(lastPlan, { arrive: t.arriveBy, surgery: `${t.surgery} · ${(currentProc()!.label as any)[state.lang]}`, stitch: t.stitch, departure: t.departure }, t.summaryTitle); const url = URL.createObjectURL(new Blob([ics], { type: 'text/calendar;charset=utf-8' }));