@roottale/analytics-runtime 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-DIN7LIST.js +95 -0
- package/dist/chunk-DIN7LIST.js.map +1 -0
- package/dist/{chunk-FPKZZSGL.js → chunk-G7OLBHT5.js} +62 -52
- package/dist/chunk-G7OLBHT5.js.map +1 -0
- package/dist/chunk-GBIGQHVN.js +123 -0
- package/dist/chunk-GBIGQHVN.js.map +1 -0
- package/dist/chunk-GW523RS4.js +200 -0
- package/dist/chunk-GW523RS4.js.map +1 -0
- package/dist/events.d.ts +147 -0
- package/dist/events.js +11 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +117 -3
- package/dist/index.js +33 -5
- package/dist/index.js.map +1 -1
- package/dist/next.d.ts +7 -0
- package/dist/next.js +33 -0
- package/dist/next.js.map +1 -0
- package/dist/react.d.ts +16 -1
- package/dist/react.js +32 -4
- package/dist/react.js.map +1 -1
- package/package.json +16 -1
- package/dist/chunk-FPKZZSGL.js.map +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/beacon.ts
|
|
2
|
+
var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
|
|
3
|
+
var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4
|
+
var RUNTIME = `(function(){
|
|
5
|
+
var C=__RT_BEACON__;
|
|
6
|
+
function metaPid(){try{var m=document.querySelector('meta[name="rt:content-id"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}
|
|
7
|
+
function send(ev,extra){
|
|
8
|
+
try{
|
|
9
|
+
var p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};
|
|
10
|
+
if(extra){for(var k in extra){p[k]=extra[k];}}
|
|
11
|
+
var body=JSON.stringify(p);
|
|
12
|
+
if(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}
|
|
13
|
+
else{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}
|
|
14
|
+
}catch(e){}
|
|
15
|
+
try{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}
|
|
16
|
+
try{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}
|
|
17
|
+
}
|
|
18
|
+
if(typeof window!=='undefined'){window.__rtTrack=send;}
|
|
19
|
+
document.addEventListener('click',function(e){
|
|
20
|
+
var t=e.target;
|
|
21
|
+
var el=t&&t.closest?t.closest('[data-track]'):null;
|
|
22
|
+
if(!el)return;
|
|
23
|
+
var extra={};
|
|
24
|
+
var ds=el.dataset||{};
|
|
25
|
+
for(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}
|
|
26
|
+
send(el.getAttribute('data-track'),extra);
|
|
27
|
+
},{capture:true});
|
|
28
|
+
if(C.attr){
|
|
29
|
+
try{
|
|
30
|
+
var AK='_rt_attr';
|
|
31
|
+
var cur=null;
|
|
32
|
+
try{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}
|
|
33
|
+
var expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;
|
|
34
|
+
var sp=new URLSearchParams(location.search);
|
|
35
|
+
var rs=sp.get('rt_src');
|
|
36
|
+
var us=sp.get('utm_source');
|
|
37
|
+
var um=sp.get('utm_medium');
|
|
38
|
+
var uc=sp.get('utm_campaign');
|
|
39
|
+
var ut=sp.get('utm_term');
|
|
40
|
+
var un=sp.get('utm_content');
|
|
41
|
+
var hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));
|
|
42
|
+
var extRef=null;
|
|
43
|
+
try{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}
|
|
44
|
+
var env=cur;
|
|
45
|
+
if(expired){
|
|
46
|
+
env={v:2,lp:location.pathname,ts:Date.now()};
|
|
47
|
+
if(rs)env.rs=rs.slice(0,32);
|
|
48
|
+
if(us)env.us=us.slice(0,100);
|
|
49
|
+
if(um)env.um=um.slice(0,100);
|
|
50
|
+
if(uc)env.uc=uc.slice(0,100);
|
|
51
|
+
if(ut)env.ut=ut.slice(0,100);
|
|
52
|
+
if(un)env.un=un.slice(0,100);
|
|
53
|
+
if(hasGcl)env.gcl=1;
|
|
54
|
+
if(extRef)env.rf=extRef;
|
|
55
|
+
}
|
|
56
|
+
var ch=null;
|
|
57
|
+
if(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(hasGcl){ch='gcl';}else if(extRef){ch='rf';}
|
|
58
|
+
if(ch){
|
|
59
|
+
var lt={ts:Date.now(),ch:ch};
|
|
60
|
+
if(rs)lt.rs=rs.slice(0,32);
|
|
61
|
+
if(us)lt.us=us.slice(0,100);
|
|
62
|
+
if(um)lt.um=um.slice(0,100);
|
|
63
|
+
if(uc)lt.uc=uc.slice(0,100);
|
|
64
|
+
if(ut)lt.ut=ut.slice(0,100);
|
|
65
|
+
if(un)lt.un=un.slice(0,100);
|
|
66
|
+
if(hasGcl)lt.gcl=1;
|
|
67
|
+
if(extRef)lt.rf=extRef;
|
|
68
|
+
env.lt=lt;
|
|
69
|
+
}
|
|
70
|
+
if(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}
|
|
71
|
+
}catch(e){}
|
|
72
|
+
}
|
|
73
|
+
if(C.pv){var _pid=metaPid();send('pageview',_pid?{pid:_pid}:null);}
|
|
74
|
+
})();`;
|
|
75
|
+
function renderBeaconScript(options) {
|
|
76
|
+
const cfg = JSON.stringify({
|
|
77
|
+
ep: options.collectUrl,
|
|
78
|
+
sid: options.siteId,
|
|
79
|
+
pv: options.trackPageview !== false,
|
|
80
|
+
attr: options.captureAttribution !== false
|
|
81
|
+
});
|
|
82
|
+
return "var __RT_BEACON__=" + cfg + ";" + RUNTIME;
|
|
83
|
+
}
|
|
84
|
+
function trackCustomEvent(ev, extra) {
|
|
85
|
+
if (typeof window === "undefined") return;
|
|
86
|
+
window.__rtTrack?.(ev, extra);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
ATTRIBUTION_STORAGE_KEY,
|
|
91
|
+
ATTRIBUTION_TTL_MS,
|
|
92
|
+
renderBeaconScript,
|
|
93
|
+
trackCustomEvent
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=chunk-DIN7LIST.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/beacon.ts"],"sourcesContent":["// ADR-0038 plane ② — first-party 비콘 dispatcher.\n//\n// `data-track` 컨벤션(className 아님): 루트 1개 delegated listener 가\n// `[data-track]` 클릭을 잡아 first-party `/collect` 로 전송. cms-blocks 가\n// 채워지면 컴포넌트가 `data-track` 을 emit → 모든 테넌트 사이트가 일관 이벤트.\n//\n// `window.__rtTrack(ev, extra?)` — 클릭이 아닌 시점(마운트 등)에 커스텀 이벤트를\n// 보내야 하는 극소수 케이스(예: starter `not-found.tsx` 의 404 비콘, W4-5 PR 2)\n// 를 위한 탈출구. `send()` 를 그대로 노출하므로 payload 형태·anonymize 정책은\n// data-track 경로와 동일(단일소스). 이 스크립트가 렌더되지 않은 사이트(비콘\n// 미설정 — collectUrl/siteId 없음)에서는 전역 자체가 정의되지 않으므로 호출부는\n// 항상 optional chaining(`window.__rtTrack?.(...)`) 으로 감싸 게이트를 존중한다.\n//\n// 동의 면제 조건(ADR-0038 §2): cookieless · cross-site 영속 ID 없음 ·\n// raw IP/UA 는 서버(/collect)에서 hash/truncate · 제3자 미공유. 따라서 본\n// dispatcher 는 동의 게이트 없이 동작(운영 목적 first-party 분석).\n//\n// dual-emit 금지: 본 비콘은 plane ② 전용. GA4(plane ①) 로는 보내지 않는다.\n//\n// ADR-0048 §1 — first-touch 어트리뷰션 캡처: 페이지 로드 시 landing path ·\n// rt_src(단축링크/QR 토큰) · utm 3종 · 외부 referrer hostname 을\n// localStorage[\"_rt_attr\"] 에 저장. 이미 있으면 유지(first-touch 우선, TTL 30일).\n// 식별자를 저장하지 않으므로 위 동의 면제 논리가 그대로 성립한다. 문의 폼이\n// `@roottale/cms-client` readAttribution() 으로 읽어 inquiry 에 동반시킨다 —\n// 키/값 형태는 cms-client 와의 공유 계약(변경 시 양쪽 동시 갱신).\n//\n// 설계(measurement-design-draft.md) §2 — 어트리뷰션 v2: utm_term(ut)·\n// utm_content(un) 추가 + gclid/fbclid 는 **원값 저장 금지**, 존재 여부만\n// `gcl:1` 플래그로 남긴다(P0-4 — cookieless·비식별 원칙 보존). 추가로 last\n// non-direct touch 슬롯 `lt` — 우선순위 rt_src > utm_* > gclid/fbclid 존재 >\n// 외부 referrer 로 판정, 신호가 있는 로드에서만 통째 교체(직접 재방문은\n// 무갱신). first-touch 필드는 절대 덮어쓰지 않음(변하지 않는 first-touch 계약\n// 유지) — `lt` 만 같은 30일 봉투 안에서 갱신된다. 새로 캡처되는 envelope 은\n// `v:2`, 기존 v1 envelope 은 그대로 읽고(하위호환) `lt` 만 추가로 얹는다(구조\n// 마이그레이션은 하지 않음 — 읽기 관용이 계약의 전부).\n//\n// 설계 §3/§0 — beacon send() 는 window.__rtGa4Mirror·window.__rtJourneyRecord\n// 전역 훅이 있으면 호출한다(각각 ga4-bridge.ts·journey.ts 가 별도 스크립트로\n// 설치). data-track 클릭 delegation 도 동일 send() 를 거치므로(클릭 이벤트가\n// 대다수인 phone_click 등도 포함) 이 지점이 유일한 공통 choke point다. 각 훅은\n// 독립 try/catch — 훅이 없거나 던져도 실제 /collect 전송에는 영향 없음.\n\nexport interface BeaconOptions {\n /** first-party 수집 엔드포인트 (예: https://api.roottale.com/v1/collect). */\n collectUrl: string;\n /** site 식별자 (테넌트 cross-site 아님 — site 단위 집계용). */\n siteId: string;\n /** pageview 자동 전송 (기본 true). */\n trackPageview?: boolean;\n /** first-touch 어트리뷰션 캡처 (ADR-0048 §1, 기본 true). */\n captureAttribution?: boolean;\n}\n\n/**\n * first-touch 저장 localStorage 키 — `@roottale/cms-client` readAttribution()\n * 과의 공유 계약. 값 형태는 {@link FirstTouchAttribution}.\n */\nexport const ATTRIBUTION_STORAGE_KEY = \"_rt_attr\";\n\n/** first-touch 보존 기간 (30일, ms). 경과 시 다음 방문이 새 first-touch. */\nexport const ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\n/**\n * localStorage[\"_rt_attr\"] 에 저장되는 first-touch 형태 (식별자 없음).\n * 키를 짧게 유지 — 비콘 인라인 런타임과 폼 hidden field 양쪽에서 쓰인다.\n *\n * v1 데이터 하위호환: 이미 저장된 v1 envelope(ut/un/gcl/lt 없음)도 유효하게\n * 읽는다 — TTL 이 남아있으면 구조를 마이그레이션하지 않고 그대로 유지하며,\n * 신호가 있는 로드에서 `lt` 만 추가로 얹는다. 새로 캡처되는 envelope 만 `v:2`.\n */\nexport interface FirstTouchAttribution {\n /** 포맷 버전. 저장 시점의 v1 데이터도 유효하게 읽는다(하위호환). */\n v: 1 | 2;\n /** landing pathname (query 제외). */\n lp: string;\n /** 캡처 시각 (epoch ms). */\n ts: number;\n /** rt_src — 단축링크/QR 토큰 (있을 때만). */\n rs?: string;\n /** utm_source (있을 때만). */\n us?: string;\n /** utm_medium (있을 때만). */\n um?: string;\n /** utm_campaign (있을 때만). */\n uc?: string;\n /** utm_term (있을 때만, v2). */\n ut?: string;\n /** utm_content (있을 때만, v2). */\n un?: string;\n /** 광고 클릭 유입 존재 플래그(v2) — gclid/fbclid **원값은 절대 저장하지 않음**. */\n gcl?: 1;\n /** 외부 referrer hostname — raw URL 미저장, 동일 host 면 생략. */\n rf?: string;\n /** last non-direct touch (v2) — 신호 있는 로드에서만 통째 교체. */\n lt?: LastTouchAttribution;\n}\n\n/** `lt` 슬롯을 교체시킨 채널 — 우선순위 rs > utm > gcl > rf. */\nexport type LastTouchChannel = \"rs\" | \"utm\" | \"gcl\" | \"rf\";\n\n/**\n * last non-direct touch — first-touch 와 동형이나 `ch`(판정 채널)를 더 갖는다.\n * first-touch 와 동일한 30일 봉투 안에서 관리(별도 TTL 없음).\n */\nexport interface LastTouchAttribution {\n /** 갱신 시각 (epoch ms). */\n ts: number;\n /** 이 갱신을 유발한 채널(우선순위 rs > utm > gcl > rf). */\n ch: LastTouchChannel;\n rs?: string;\n us?: string;\n um?: string;\n uc?: string;\n ut?: string;\n un?: string;\n /** 광고 클릭 유입 존재 플래그 — 원값 미저장(first-touch 와 동일 정책). */\n gcl?: 1;\n rf?: string;\n}\n\nconst RUNTIME = `(function(){\nvar C=__RT_BEACON__;\nfunction metaPid(){try{var m=document.querySelector('meta[name=\"rt:content-id\"]');var v=m&&m.getAttribute('content');return v?String(v).slice(0,64):null;}catch(e){return null;}}\nfunction send(ev,extra){\ntry{\nvar p={ev:ev,sid:C.sid,path:location.pathname,ref:document.referrer?1:0,ts:Date.now()};\nif(extra){for(var k in extra){p[k]=extra[k];}}\nvar body=JSON.stringify(p);\nif(navigator.sendBeacon){navigator.sendBeacon(C.ep,new Blob([body],{type:'application/json'}));}\nelse{fetch(C.ep,{method:'POST',body:body,headers:{'Content-Type':'application/json'},keepalive:true}).catch(function(){});}\n}catch(e){}\ntry{if(window.__rtGa4Mirror)window.__rtGa4Mirror(ev,extra);}catch(e){}\ntry{if(window.__rtJourneyRecord)window.__rtJourneyRecord(ev,extra);}catch(e){}\n}\nif(typeof window!=='undefined'){window.__rtTrack=send;}\ndocument.addEventListener('click',function(e){\nvar t=e.target;\nvar el=t&&t.closest?t.closest('[data-track]'):null;\nif(!el)return;\nvar extra={};\nvar ds=el.dataset||{};\nfor(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}\nsend(el.getAttribute('data-track'),extra);\n},{capture:true});\nif(C.attr){\ntry{\nvar AK='_rt_attr';\nvar cur=null;\ntry{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}\nvar expired=!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6;\nvar sp=new URLSearchParams(location.search);\nvar rs=sp.get('rt_src');\nvar us=sp.get('utm_source');\nvar um=sp.get('utm_medium');\nvar uc=sp.get('utm_campaign');\nvar ut=sp.get('utm_term');\nvar un=sp.get('utm_content');\nvar hasGcl=!!(sp.get('gclid')||sp.get('fbclid'));\nvar extRef=null;\ntry{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)extRef=rh.slice(0,253);}}catch(e){}\nvar env=cur;\nif(expired){\nenv={v:2,lp:location.pathname,ts:Date.now()};\nif(rs)env.rs=rs.slice(0,32);\nif(us)env.us=us.slice(0,100);\nif(um)env.um=um.slice(0,100);\nif(uc)env.uc=uc.slice(0,100);\nif(ut)env.ut=ut.slice(0,100);\nif(un)env.un=un.slice(0,100);\nif(hasGcl)env.gcl=1;\nif(extRef)env.rf=extRef;\n}\nvar ch=null;\nif(rs){ch='rs';}else if(us||um||uc||ut||un){ch='utm';}else if(hasGcl){ch='gcl';}else if(extRef){ch='rf';}\nif(ch){\nvar lt={ts:Date.now(),ch:ch};\nif(rs)lt.rs=rs.slice(0,32);\nif(us)lt.us=us.slice(0,100);\nif(um)lt.um=um.slice(0,100);\nif(uc)lt.uc=uc.slice(0,100);\nif(ut)lt.ut=ut.slice(0,100);\nif(un)lt.un=un.slice(0,100);\nif(hasGcl)lt.gcl=1;\nif(extRef)lt.rf=extRef;\nenv.lt=lt;\n}\nif(expired||ch){localStorage.setItem(AK,JSON.stringify(env));}\n}catch(e){}\n}\nif(C.pv){var _pid=metaPid();send('pageview',_pid?{pid:_pid}:null);}\n})();`;\n\n/**\n * 비콘 dispatcher JS 본문. <script is:inline> 로 감싸 주입.\n * `ref` 는 referrer 존재 여부(0/1)만 — raw referrer 미전송(privacy).\n *\n * ADR-0065 — 글 조회수 식별자: pageview 시 `<meta name=\"rt:content-id\">` 의\n * content 를 읽어 `pid`(=post_id) 로 동봉한다. 사이트 레벨 단일 비콘이 페이지별\n * 글 식별자를 갖게 해 URL/컬렉션 basePath 변경에 무관한 안정 매칭을 만든다.\n * meta 미존재 시 `pid` 생략(graceful) — `/collect` 가 post_id 없는 pageview 로 처리.\n * 값 형태는 `/v1/collect` 와의 공유 계약(변경 시 양쪽 동시 갱신).\n */\nexport function renderBeaconScript(options: BeaconOptions): string {\n const cfg = JSON.stringify({\n ep: options.collectUrl,\n sid: options.siteId,\n pv: options.trackPageview !== false,\n attr: options.captureAttribution !== false,\n });\n return \"var __RT_BEACON__=\" + cfg + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** RUNTIME 이 노출하는 send() — data-track 클릭 delegation 과 동일 함수. */\n __rtTrack?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n\n/**\n * `window.__rtTrack` 안전 호출 래퍼(W4-5 PR 2) — 클릭이 아닌 시점(마운트 등)\n * 에 커스텀 이벤트를 보내야 하는 클라이언트 컴포넌트(예: starter\n * `app/not-found.tsx` 의 404 비콘)의 표준 진입점. `RootTaleAnalytics` 가 비콘\n * 없이 렌더된 사이트(collectUrl/siteId 미설정)에서는 전역 자체가 없으므로\n * 자동으로 no-op — 별도 게이트 없이 안전.\n */\nexport function trackCustomEvent(ev: string, extra?: Record<string, unknown>): void {\n if (typeof window === \"undefined\") return;\n window.__rtTrack?.(ev, extra);\n}\n"],"mappings":";AAyDO,IAAM,0BAA0B;AAGhC,IAAM,qBAAqB,KAAK,KAAK,KAAK,KAAK;AA4DtD,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkFT,SAAS,mBAAmB,SAAgC;AACjE,QAAM,MAAM,KAAK,UAAU;AAAA,IACzB,IAAI,QAAQ;AAAA,IACZ,KAAK,QAAQ;AAAA,IACb,IAAI,QAAQ,kBAAkB;AAAA,IAC9B,MAAM,QAAQ,uBAAuB;AAAA,EACvC,CAAC;AACD,SAAO,uBAAuB,MAAM,MAAM;AAC5C;AAgBO,SAAS,iBAAiB,IAAY,OAAuC;AAClF,MAAI,OAAO,WAAW,YAAa;AACnC,SAAO,YAAY,IAAI,KAAK;AAC9B;","names":[]}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GA4_MIRROR,
|
|
3
|
+
TRACK_EVENTS
|
|
4
|
+
} from "./chunk-GBIGQHVN.js";
|
|
5
|
+
|
|
1
6
|
// src/loader.ts
|
|
2
7
|
function renderConsentBootstrap() {
|
|
3
8
|
return [
|
|
@@ -39,6 +44,7 @@ function applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}
|
|
|
39
44
|
window.RootTaleAnalytics={
|
|
40
45
|
manifest:M,
|
|
41
46
|
getConsent:function(){return Object.assign({},consent);},
|
|
47
|
+
isTagLoaded:function(provider){return !!loaded[provider];},
|
|
42
48
|
updateConsent:function(partial){
|
|
43
49
|
for(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}
|
|
44
50
|
if(typeof gtag==='function'){gtag('consent','update',partial);}
|
|
@@ -103,57 +109,59 @@ function escapeAttr(s) {
|
|
|
103
109
|
return escapeHtml(s).replace(/"/g, """);
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
// src/
|
|
107
|
-
var ATTRIBUTION_STORAGE_KEY = "_rt_attr";
|
|
108
|
-
var ATTRIBUTION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
112
|
+
// src/ga4-bridge.ts
|
|
109
113
|
var RUNTIME2 = `(function(){
|
|
110
|
-
var
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
var
|
|
115
|
-
if(
|
|
116
|
-
|
|
117
|
-
if(
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
var
|
|
124
|
-
if(!el)return;
|
|
125
|
-
var extra={};
|
|
126
|
-
var ds=el.dataset||{};
|
|
127
|
-
for(var k in ds){if(k.indexOf('track')===0&&k!=='track'){extra[k]=ds[k];}}
|
|
128
|
-
send(el.getAttribute('data-track'),extra);
|
|
129
|
-
},{capture:true});
|
|
130
|
-
if(C.attr){
|
|
131
|
-
try{
|
|
132
|
-
var AK='_rt_attr';
|
|
133
|
-
var cur=null;
|
|
134
|
-
try{cur=JSON.parse(localStorage.getItem(AK)||'null');}catch(e){}
|
|
135
|
-
if(!cur||typeof cur.ts!=='number'||Date.now()-cur.ts>2592e6){
|
|
136
|
-
var sp=new URLSearchParams(location.search);
|
|
137
|
-
var a={v:1,lp:location.pathname,ts:Date.now()};
|
|
138
|
-
var rs=sp.get('rt_src');if(rs)a.rs=rs.slice(0,32);
|
|
139
|
-
var us=sp.get('utm_source');if(us)a.us=us.slice(0,100);
|
|
140
|
-
var um=sp.get('utm_medium');if(um)a.um=um.slice(0,100);
|
|
141
|
-
var uc=sp.get('utm_campaign');if(uc)a.uc=uc.slice(0,100);
|
|
142
|
-
try{if(document.referrer){var rh=new URL(document.referrer).hostname;if(rh&&rh!==location.hostname)a.rf=rh.slice(0,253);}}catch(e){}
|
|
143
|
-
localStorage.setItem(AK,JSON.stringify(a));
|
|
144
|
-
}
|
|
145
|
-
}catch(e){}
|
|
114
|
+
var MIRROR=__RT_GA4_MIRROR__;
|
|
115
|
+
window.__rtGa4Mirror=function(ev,extra){
|
|
116
|
+
var name=MIRROR[ev];
|
|
117
|
+
if(!name)return;
|
|
118
|
+
var RA=window.RootTaleAnalytics;
|
|
119
|
+
if(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;
|
|
120
|
+
if(RA.getConsent().analytics_storage!=='granted')return;
|
|
121
|
+
if(!RA.isTagLoaded('ga4'))return;
|
|
122
|
+
if(typeof gtag!=='function')return;
|
|
123
|
+
gtag('event',name,extra||{});
|
|
124
|
+
};
|
|
125
|
+
})();`;
|
|
126
|
+
function renderGa4BridgeScript() {
|
|
127
|
+
return "var __RT_GA4_MIRROR__=" + JSON.stringify(GA4_MIRROR) + ";" + RUNTIME2;
|
|
146
128
|
}
|
|
147
|
-
|
|
129
|
+
|
|
130
|
+
// src/journey.ts
|
|
131
|
+
var JOURNEY_STORAGE_KEY = "_rt_journey";
|
|
132
|
+
var JOURNEY_MAX_ITEMS = 30;
|
|
133
|
+
var JOURNEY_EVENT_NAMES = Object.keys(TRACK_EVENTS).filter((name) => TRACK_EVENTS[name].journey);
|
|
134
|
+
var RUNTIME3 = `(function(){
|
|
135
|
+
var JE=__RT_JOURNEY_EVENTS__;
|
|
136
|
+
window.__rtJourneyRecord=function(ev,extra){
|
|
137
|
+
if(!JE||JE.indexOf(ev)===-1)return;
|
|
138
|
+
var KEY='_rt_journey';
|
|
139
|
+
var arr=null;
|
|
140
|
+
try{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}
|
|
141
|
+
if(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];
|
|
142
|
+
var item={t:Date.now(),ev:ev};
|
|
143
|
+
try{if(location&&location.pathname)item.path=location.pathname;}catch(e){}
|
|
144
|
+
if(extra)item.d=extra;
|
|
145
|
+
arr.push(item);
|
|
146
|
+
if(arr.length>30)arr=arr.slice(arr.length-30);
|
|
147
|
+
sessionStorage.setItem(KEY,JSON.stringify(arr));
|
|
148
|
+
};
|
|
148
149
|
})();`;
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
156
|
-
|
|
150
|
+
function renderJourneyBridgeScript() {
|
|
151
|
+
return "var __RT_JOURNEY_EVENTS__=" + JSON.stringify(JOURNEY_EVENT_NAMES) + ";" + RUNTIME3;
|
|
152
|
+
}
|
|
153
|
+
function readJourney() {
|
|
154
|
+
if (typeof window === "undefined" || typeof sessionStorage === "undefined") {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);
|
|
159
|
+
if (!raw) return [];
|
|
160
|
+
const parsed = JSON.parse(raw);
|
|
161
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
export {
|
|
@@ -161,8 +169,10 @@ export {
|
|
|
161
169
|
renderLoaderScript,
|
|
162
170
|
renderConsentBannerMarkup,
|
|
163
171
|
renderConsentBannerScript,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
172
|
+
renderGa4BridgeScript,
|
|
173
|
+
JOURNEY_STORAGE_KEY,
|
|
174
|
+
JOURNEY_MAX_ITEMS,
|
|
175
|
+
renderJourneyBridgeScript,
|
|
176
|
+
readJourney
|
|
167
177
|
};
|
|
168
|
-
//# sourceMappingURL=chunk-
|
|
178
|
+
//# sourceMappingURL=chunk-G7OLBHT5.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/loader.ts","../src/consent-banner.ts","../src/ga4-bridge.ts","../src/journey.ts"],"sourcesContent":["// ADR-0038 §2·§4 — 공유 주입 산출물. Astro·Next 렌더러가 동일하게 mount.\n//\n// 두 조각으로 head 에 inline:\n// 1. renderConsentBootstrap() — *가장 먼저*. Consent Mode v2 default = 전부\n// denied (fail-closed). 어떤 태그보다 앞서야 함.\n// 2. renderLoaderScript(manifest) — manifest 를 embed 한 런타임. 태그는 동의\n// update 전까지 발화하지 않음. 동의 배너가 `RootTaleAnalytics.updateConsent`\n// 를 호출하면 그때 허용된 태그만 주입.\n//\n// 설계(measurement-design-draft.md) §0 게이트 계약 — `RootTaleAnalytics.\n// isTagLoaded(provider)` 로 태그 로드 완료 상태를 노출한다(내부 `loaded` 맵\n// 그대로 read-only 조회). `ga4-bridge.ts` 가 GA4 미러 발화 전 \"동의 granted\n// 그리고 GA4 로드 완료\" 를 함께 확인하는 데 쓴다.\n\nimport type { AnalyticsManifest } from \"./types\";\n\n/**\n * Consent Mode v2 default 부트스트랩. 전 카테고리 denied + `wait_for_update`.\n * 반드시 어떤 분석/광고 태그보다 먼저 실행돼야 한다.\n */\nexport function renderConsentBootstrap(): string {\n return [\n \"window.dataLayer=window.dataLayer||[];\",\n \"function gtag(){dataLayer.push(arguments);}\",\n \"gtag('consent','default',{\",\n \"'ad_storage':'denied','ad_user_data':'denied',\",\n \"'ad_personalization':'denied','analytics_storage':'denied',\",\n \"'wait_for_update':500});\",\n ].join(\"\");\n}\n\n// 브라우저 런타임 — manifest 를 읽고 동의가 충족된 태그만 주입한다.\n// `__RT_MANIFEST__` 는 renderLoaderScript 가 JSON 으로 치환.\nconst RUNTIME = `(function(){\nvar M=__RT_MANIFEST__;\nvar consent=Object.assign({},M.consentDefault);\nvar loaded={};\nfunction allowed(req){for(var i=0;i<req.length;i++){if(consent[req[i]]!=='granted')return false;}return true;}\nfunction inject(tag){\nif(loaded[tag.provider]||!allowed(tag.requiredConsent))return;\nloaded[tag.provider]=true;\nif(tag.provider==='ga4'){\nvar s=document.createElement('script');s.async=true;\ns.src='https://www.googletagmanager.com/gtag/js?id='+encodeURIComponent(tag.id);\ndocument.head.appendChild(s);gtag('js',new Date());gtag('config',tag.id);\n}else if(tag.provider==='clarity'){\n(function(c,l,a,r,i){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\nvar t=l.createElement(r);t.async=1;t.src='https://www.clarity.ms/tag/'+i;\nvar y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);})(window,document,'clarity','script',tag.id);\n}else if(tag.provider==='meta_pixel'){\n(function(f,b,e,v){if(f.fbq)return;var n=f.fbq=function(){n.callMethod?n.callMethod.apply(n,arguments):n.queue.push(arguments)};\nif(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];var t=b.createElement(e);t.async=!0;\nt.src=v;var s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)})(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init',tag.id);fbq('track','PageView');\n}else if(tag.provider==='naver'){\nvar w=document.createElement('script');w.async=true;w.src='//wcs.naver.net/wcslog.js';\nw.onload=function(){if(window.wcs){window.wcs_add={wa:tag.id};if(window.wcs.inflow)window.wcs.inflow();if(window.wcs_do)window.wcs_do();}};\ndocument.head.appendChild(w);\n}}\nfunction applyAll(){for(var i=0;i<M.tags.length;i++){inject(M.tags[i]);}}\nwindow.RootTaleAnalytics={\nmanifest:M,\ngetConsent:function(){return Object.assign({},consent);},\nisTagLoaded:function(provider){return !!loaded[provider];},\nupdateConsent:function(partial){\nfor(var k in partial){if(Object.prototype.hasOwnProperty.call(consent,k))consent[k]=partial[k];}\nif(typeof gtag==='function'){gtag('consent','update',partial);}\napplyAll();\n}};\napplyAll();\n})();`;\n\n/**\n * manifest 를 embed 한 로더 스크립트. fail-closed — 기본 동의가 denied 라\n * `applyAll()` 은 아무 태그도 주입하지 않고, 동의 배너의 `updateConsent` 후에만\n * 허용된 태그가 붙는다.\n */\nexport function renderLoaderScript(manifest: AnalyticsManifest): string {\n return \"var __RT_MANIFEST__=\" + JSON.stringify(manifest) + \";\" + RUNTIME;\n}\n","// ADR-0038 §2 — 동의 배너. plane ① 외부 트래커는 동의 전까지 미발화(fail-closed)\n// 이므로, 배너가 방문자 선택을 받아 RootTaleAnalytics.updateConsent 를 호출한다.\n//\n// 주의: `set:html`/`dangerouslySetInnerHTML` 로 주입된 <script> 는 실행되지\n// 않는다. 본 모듈은 *마크업* 과 *JS 본문* 을 분리 반환하고, 렌더러가 JS 는\n// <script is:inline> 로, 마크업은 컨테이너로 주입해야 한다.\n//\n// 마크업/스크립트가 공유하는 DOM id (계약):\n// #rt-consent · #rt-consent-all · #rt-consent-analytics · #rt-consent-deny\n\nexport interface ConsentBannerOptions {\n /** 개인정보처리방침 링크 (테넌트별). 없으면 링크 생략. */\n privacyUrl?: string;\n /** 본문 카피. 기본 한국어. */\n message?: string;\n}\n\nconst BANNER_ID = \"rt-consent\";\nconst STORAGE_KEY = \"rt_consent_v1\";\n\n/** 배너 마크업(HTML). 스크립트 미포함 — 컨테이너로 주입. */\nexport function renderConsentBannerMarkup(\n options: ConsentBannerOptions = {},\n): string {\n const message =\n options.message ??\n \"이 사이트는 방문 분석·광고 도구를 사용할 수 있습니다. 동의 여부를 선택해 주세요.\";\n const privacy = options.privacyUrl\n ? ` <a href=\"${escapeAttr(options.privacyUrl)}\" target=\"_blank\" rel=\"noopener\">개인정보처리방침</a>`\n : \"\";\n\n return [\n `<div id=\"${BANNER_ID}\" role=\"dialog\" aria-live=\"polite\" aria-label=\"동의 안내\" hidden`,\n ` style=\"position:fixed;left:0;right:0;bottom:0;z-index:2147483647;`,\n `background:#fff;border-top:1px solid #e5e7eb;padding:16px;`,\n `display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;`,\n `font-size:14px;line-height:1.5;color:#111;box-shadow:0 -2px 12px rgba(0,0,0,.06)\">`,\n `<span style=\"flex:1 1 280px;min-width:240px\">${escapeHtml(message)}${privacy}</span>`,\n `<button id=\"rt-consent-all\" type=\"button\" style=\"${BTN_PRIMARY}\">모두 허용</button>`,\n `<button id=\"rt-consent-analytics\" type=\"button\" style=\"${BTN_SECONDARY}\">분석만 허용</button>`,\n `<button id=\"rt-consent-deny\" type=\"button\" style=\"${BTN_GHOST}\">거부</button>`,\n `</div>`,\n ].join(\"\");\n}\n\n/** 배너 동작(JS 본문). <script is:inline> 로 감싸 주입. */\nexport function renderConsentBannerScript(): string {\n return `(function(){\nvar KEY=${JSON.stringify(STORAGE_KEY)};\nvar el=document.getElementById(${JSON.stringify(BANNER_ID)});\nfunction apply(state){\nif(window.RootTaleAnalytics&&typeof window.RootTaleAnalytics.updateConsent==='function'){\nwindow.RootTaleAnalytics.updateConsent(state);\n}}\nfunction persist(state){try{localStorage.setItem(KEY,JSON.stringify(state));}catch(e){}}\nfunction read(){try{return JSON.parse(localStorage.getItem(KEY)||'null');}catch(e){return null;}}\nvar ALL={analytics_storage:'granted',ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted'};\nvar ANALYTICS={analytics_storage:'granted',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nvar DENY={analytics_storage:'denied',ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied'};\nfunction choose(state){apply(state);persist(state);if(el){el.hidden=true;}}\nvar prior=read();\nif(prior){apply(prior);if(el){el.hidden=true;}}\nelse if(el){el.hidden=false;}\nfunction bind(id,state){var b=document.getElementById(id);if(b){b.addEventListener('click',function(){choose(state);});}}\nbind('rt-consent-all',ALL);\nbind('rt-consent-analytics',ANALYTICS);\nbind('rt-consent-deny',DENY);\n})();`;\n}\n\nconst BTN_BASE =\n \"padding:8px 14px;border-radius:8px;font-size:14px;cursor:pointer;border:1px solid #d1d5db;\";\nconst BTN_PRIMARY = BTN_BASE + \"background:#111;color:#fff;border-color:#111;\";\nconst BTN_SECONDARY = BTN_BASE + \"background:#f3f4f6;color:#111;\";\nconst BTN_GHOST = BTN_BASE + \"background:transparent;color:#6b7280;\";\n\nfunction escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\");\n}\n\nfunction escapeAttr(s: string): string {\n return escapeHtml(s).replace(/\"/g, \""\");\n}\n","// 설계(measurement-design-draft.md) §0(ADR-0038 amend) — GA4 미러 브리지.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtGa4Mirror` 전역 훅이\n// 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그 훅을\n// 설치할 뿐 beacon 을 직접 건드리지 않는다 — 순서 무관하게 안전(훅이 아직\n// 없을 때 이벤트가 나면 그냥 스킵, 다음 이벤트부터 미러링).\n//\n// 게이트(설계 §0): events.ts `GA4_MIRROR` 에 등록된 이벤트 + 동의\n// `analytics_storage==='granted'` + GA4 태그 로드 완료(`isTagLoaded('ga4')`) +\n// `typeof gtag==='function'` **전부** 참일 때만 `gtag('event', ga4Name, params)`.\n// 하나라도 불충족이면 그냥 버린다 — dataLayer 에 선큐잉해 동의 이후 소급\n// 전송하지 않는다(큐·재생 금지). 확정 전환 수는 어디까지나 inquiries DB —\n// 본 미러는 광고 생태계용 근사 신호일 뿐이다(설계 §0 SSoT 계약).\n\nimport { GA4_MIRROR } from \"./events\";\n\nconst RUNTIME = `(function(){\nvar MIRROR=__RT_GA4_MIRROR__;\nwindow.__rtGa4Mirror=function(ev,extra){\nvar name=MIRROR[ev];\nif(!name)return;\nvar RA=window.RootTaleAnalytics;\nif(!RA||typeof RA.getConsent!=='function'||typeof RA.isTagLoaded!=='function')return;\nif(RA.getConsent().analytics_storage!=='granted')return;\nif(!RA.isTagLoaded('ga4'))return;\nif(typeof gtag!=='function')return;\ngtag('event',name,extra||{});\n};\n})();`;\n\n/**\n * GA4 미러 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와 함께\n * (순서 무관) 주입한다. `enableGa4Bridge={false}` 로 아예 생략 가능(react.tsx).\n */\nexport function renderGa4BridgeScript(): string {\n return \"var __RT_GA4_MIRROR__=\" + JSON.stringify(GA4_MIRROR) + \";\" + RUNTIME;\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 GA4 미러 훅(설치돼 있을 때만). */\n __rtGa4Mirror?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n","// 설계(measurement-design-draft.md) §3 — 방문 여정(journey) 링버퍼.\n//\n// beacon(plane ②) `send()` 이 매 이벤트마다 `window.__rtJourneyRecord` 전역\n// 훅이 있으면 호출한다(beacon.ts 참조, try/catch 로 독립). 본 스크립트는 그\n// 훅을 설치할 뿐이다 — `events.ts` `TRACK_EVENTS[ev].journey` 가 true 인\n// 이벤트(pageview + 전환 사다리)만 sessionStorage `_rt_journey` 링버퍼(최대\n// 30항목)에 적재한다. scroll_depth·section_view 등 고빈도 감지 이벤트는\n// journey:false 라 기록되지 않는다.\n//\n// 서버측 계약(설계 §3, 본 모듈 범위 밖): vertical(medical/legal) 게이트는\n// 서버가 재강제하고, 저장은 `inquiries.journey` jsonb 컬럼 + zod 재구성 +\n// 180일 보존이다 — 클라이언트 캡처는 신뢰의 원천이 아니다.\n\nimport { TRACK_EVENTS, type TrackEventName } from \"./events\";\n\n/** sessionStorage 키 — cms-client/renderer 와의 공유 계약(변경 시 양쪽 동시 갱신). */\nexport const JOURNEY_STORAGE_KEY = \"_rt_journey\";\n\n/** 링버퍼 최대 항목 수(변경 시 RUNTIME 내 하드코딩 리터럴도 동시 갱신). */\nexport const JOURNEY_MAX_ITEMS = 30;\n\n/** `_rt_journey` 항목 형태. */\nexport interface JourneyEntry {\n /** 캡처 시각 (epoch ms). */\n t: number;\n /** 이벤트 이름 (events.ts 사전). */\n ev: string;\n /** 발생 경로 (pathname). vertical 게이트 대상은 서버에서 제거될 수 있음. */\n path?: string;\n /** 소형 차원 데이터 (data-track-* 로 캡처된 extra). */\n d?: Record<string, unknown>;\n}\n\n/** journey:true 이벤트 이름 목록 — events.ts SSoT 에서 파생. */\nconst JOURNEY_EVENT_NAMES: TrackEventName[] = (\n Object.keys(TRACK_EVENTS) as TrackEventName[]\n).filter((name) => TRACK_EVENTS[name].journey);\n\nconst RUNTIME = `(function(){\nvar JE=__RT_JOURNEY_EVENTS__;\nwindow.__rtJourneyRecord=function(ev,extra){\nif(!JE||JE.indexOf(ev)===-1)return;\nvar KEY='_rt_journey';\nvar arr=null;\ntry{arr=JSON.parse(sessionStorage.getItem(KEY)||'null');}catch(e){}\nif(Object.prototype.toString.call(arr)!=='[object Array]')arr=[];\nvar item={t:Date.now(),ev:ev};\ntry{if(location&&location.pathname)item.path=location.pathname;}catch(e){}\nif(extra)item.d=extra;\narr.push(item);\nif(arr.length>30)arr=arr.slice(arr.length-30);\nsessionStorage.setItem(KEY,JSON.stringify(arr));\n};\n})();`;\n\n/**\n * journey 기록 브리지 스크립트. `<RootTaleAnalytics>` 가 beacon 스크립트와\n * 함께(순서 무관) 항상 주입한다 — 1차 캡처 인프라라 opt-out 프롭 없음(ga4\n * 미러와 달리).\n */\nexport function renderJourneyBridgeScript(): string {\n return (\n \"var __RT_JOURNEY_EVENTS__=\" +\n JSON.stringify(JOURNEY_EVENT_NAMES) +\n \";\" +\n RUNTIME\n );\n}\n\n/**\n * sessionStorage 의 journey 링버퍼를 읽는다(브라우저 전용). 파싱 실패·미존재\n * 시 빈 배열. cms-client/renderer 가 문의 제출 시 이 값을 재직렬화해 동봉한다\n * (submit capture/`formdata` 시점 — 설계 §3 P1-3, hidden 스냅샷 아님).\n */\nexport function readJourney(): JourneyEntry[] {\n if (typeof window === \"undefined\" || typeof sessionStorage === \"undefined\") {\n return [];\n }\n try {\n const raw = sessionStorage.getItem(JOURNEY_STORAGE_KEY);\n if (!raw) return [];\n const parsed = JSON.parse(raw);\n return Array.isArray(parsed) ? (parsed as JourneyEntry[]) : [];\n } catch {\n return [];\n }\n}\n\ndeclare global {\n interface Window {\n /** beacon send() 가 매 이벤트마다 호출하는 journey 기록 훅(설치돼 있을 때만). */\n __rtJourneyRecord?: (ev: string, extra?: Record<string, unknown>) => void;\n }\n}\n"],"mappings":";;;;;;AAoBO,SAAS,yBAAiC;AAC/C,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAIA,IAAM,UAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA4CT,SAAS,mBAAmB,UAAqC;AACtE,SAAO,yBAAyB,KAAK,UAAU,QAAQ,IAAI,MAAM;AACnE;;;AC9DA,IAAM,YAAY;AAClB,IAAM,cAAc;AAGb,SAAS,0BACd,UAAgC,CAAC,GACzB;AACR,QAAM,UACJ,QAAQ,WACR;AACF,QAAM,UAAU,QAAQ,aACpB,aAAa,WAAW,QAAQ,UAAU,CAAC,0FAC3C;AAEJ,SAAO;AAAA,IACL,YAAY,SAAS;AAAA,IACrB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,gDAAgD,WAAW,OAAO,CAAC,GAAG,OAAO;AAAA,IAC7E,oDAAoD,WAAW;AAAA,IAC/D,0DAA0D,aAAa;AAAA,IACvE,qDAAqD,SAAS;AAAA,IAC9D;AAAA,EACF,EAAE,KAAK,EAAE;AACX;AAGO,SAAS,4BAAoC;AAClD,SAAO;AAAA,UACC,KAAK,UAAU,WAAW,CAAC;AAAA,iCACJ,KAAK,UAAU,SAAS,CAAC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAmB1D;AAEA,IAAM,WACJ;AACF,IAAM,cAAc,WAAW;AAC/B,IAAM,gBAAgB,WAAW;AACjC,IAAM,YAAY,WAAW;AAE7B,SAAS,WAAW,GAAmB;AACrC,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM;AACzB;AAEA,SAAS,WAAW,GAAmB;AACrC,SAAO,WAAW,CAAC,EAAE,QAAQ,MAAM,QAAQ;AAC7C;;;ACrEA,IAAMA,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkBT,SAAS,wBAAgC;AAC9C,SAAO,2BAA2B,KAAK,UAAU,UAAU,IAAI,MAAMA;AACvE;;;ACpBO,IAAM,sBAAsB;AAG5B,IAAM,oBAAoB;AAejC,IAAM,sBACJ,OAAO,KAAK,YAAY,EACxB,OAAO,CAAC,SAAS,aAAa,IAAI,EAAE,OAAO;AAE7C,IAAMC,WAAU;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAsBT,SAAS,4BAAoC;AAClD,SACE,+BACA,KAAK,UAAU,mBAAmB,IAClC,MACAA;AAEJ;AAOO,SAAS,cAA8B;AAC5C,MAAI,OAAO,WAAW,eAAe,OAAO,mBAAmB,aAAa;AAC1E,WAAO,CAAC;AAAA,EACV;AACA,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,mBAAmB;AACtD,QAAI,CAAC,IAAK,QAAO,CAAC;AAClB,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,WAAO,MAAM,QAAQ,MAAM,IAAK,SAA4B,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;","names":["RUNTIME","RUNTIME"]}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// src/events.ts
|
|
2
|
+
var TRACK_EVENTS = {
|
|
3
|
+
pageview: { ga4: false, journey: true, dims: ["pid"], wireDims: ["pid"] },
|
|
4
|
+
lead_submit: {
|
|
5
|
+
ga4: true,
|
|
6
|
+
ga4Name: "generate_lead",
|
|
7
|
+
journey: true,
|
|
8
|
+
dims: ["form_id"],
|
|
9
|
+
wireDims: ["form_id"]
|
|
10
|
+
},
|
|
11
|
+
form_submit_attempt: {
|
|
12
|
+
ga4: false,
|
|
13
|
+
journey: true,
|
|
14
|
+
dims: ["form_id"],
|
|
15
|
+
wireDims: ["form_id"]
|
|
16
|
+
},
|
|
17
|
+
phone_click: {
|
|
18
|
+
ga4: true,
|
|
19
|
+
ga4Name: "phone_call_click",
|
|
20
|
+
journey: true,
|
|
21
|
+
dims: ["placement"],
|
|
22
|
+
wireDims: ["trackPlacement"]
|
|
23
|
+
},
|
|
24
|
+
email_click: {
|
|
25
|
+
ga4: true,
|
|
26
|
+
ga4Name: "email_click",
|
|
27
|
+
journey: true,
|
|
28
|
+
dims: ["placement"],
|
|
29
|
+
wireDims: ["trackPlacement"]
|
|
30
|
+
},
|
|
31
|
+
chat_click: {
|
|
32
|
+
ga4: true,
|
|
33
|
+
ga4Name: "chat_click",
|
|
34
|
+
journey: true,
|
|
35
|
+
dims: ["provider", "placement"],
|
|
36
|
+
wireDims: ["trackProvider", "trackPlacement"]
|
|
37
|
+
},
|
|
38
|
+
booking_click: {
|
|
39
|
+
ga4: true,
|
|
40
|
+
ga4Name: "booking_outbound",
|
|
41
|
+
journey: true,
|
|
42
|
+
dims: ["provider", "placement"],
|
|
43
|
+
wireDims: ["trackProvider", "trackPlacement"]
|
|
44
|
+
},
|
|
45
|
+
cta_click: {
|
|
46
|
+
ga4: false,
|
|
47
|
+
journey: false,
|
|
48
|
+
dims: ["cta_id", "placement"],
|
|
49
|
+
wireDims: ["trackCta", "trackPlacement"]
|
|
50
|
+
},
|
|
51
|
+
pricing_view: {
|
|
52
|
+
ga4: false,
|
|
53
|
+
journey: false,
|
|
54
|
+
dims: ["item"],
|
|
55
|
+
wireDims: ["trackItem"]
|
|
56
|
+
},
|
|
57
|
+
pricing_expand: {
|
|
58
|
+
ga4: false,
|
|
59
|
+
journey: false,
|
|
60
|
+
dims: ["item"],
|
|
61
|
+
wireDims: ["trackItem"]
|
|
62
|
+
},
|
|
63
|
+
faq_open: {
|
|
64
|
+
ga4: false,
|
|
65
|
+
journey: false,
|
|
66
|
+
dims: ["q_index"],
|
|
67
|
+
wireDims: ["trackQ"]
|
|
68
|
+
},
|
|
69
|
+
gallery_interact: {
|
|
70
|
+
ga4: false,
|
|
71
|
+
journey: false,
|
|
72
|
+
dims: ["spot"],
|
|
73
|
+
wireDims: ["trackSpot"]
|
|
74
|
+
},
|
|
75
|
+
form_start: {
|
|
76
|
+
ga4: false,
|
|
77
|
+
journey: false,
|
|
78
|
+
dims: ["form_id"],
|
|
79
|
+
wireDims: ["form_id"]
|
|
80
|
+
},
|
|
81
|
+
form_abandon: {
|
|
82
|
+
ga4: false,
|
|
83
|
+
journey: false,
|
|
84
|
+
dims: ["form_id", "last_field"],
|
|
85
|
+
wireDims: ["form_id", "last_field"]
|
|
86
|
+
},
|
|
87
|
+
section_view: {
|
|
88
|
+
ga4: false,
|
|
89
|
+
journey: false,
|
|
90
|
+
dims: ["section_id"],
|
|
91
|
+
wireDims: ["section_id"]
|
|
92
|
+
},
|
|
93
|
+
scroll_depth: {
|
|
94
|
+
ga4: false,
|
|
95
|
+
journey: false,
|
|
96
|
+
dims: ["pct"],
|
|
97
|
+
wireDims: ["pct"]
|
|
98
|
+
},
|
|
99
|
+
read_complete: {
|
|
100
|
+
ga4: false,
|
|
101
|
+
journey: false,
|
|
102
|
+
dims: ["pid"],
|
|
103
|
+
wireDims: ["pid"]
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
function isTrackEvent(name) {
|
|
107
|
+
return Object.prototype.hasOwnProperty.call(TRACK_EVENTS, name);
|
|
108
|
+
}
|
|
109
|
+
var GA4_MIRROR = (() => {
|
|
110
|
+
const out = {};
|
|
111
|
+
for (const name of Object.keys(TRACK_EVENTS)) {
|
|
112
|
+
const def = TRACK_EVENTS[name];
|
|
113
|
+
if (def.ga4 && def.ga4Name) out[name] = def.ga4Name;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
})();
|
|
117
|
+
|
|
118
|
+
export {
|
|
119
|
+
TRACK_EVENTS,
|
|
120
|
+
isTrackEvent,
|
|
121
|
+
GA4_MIRROR
|
|
122
|
+
};
|
|
123
|
+
//# sourceMappingURL=chunk-GBIGQHVN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/events.ts"],"sourcesContent":["// 설계(measurement-design-draft.md) §1 — 이벤트 사전 SSoT. 이름·GA4 미러 대상\n// 여부·GA4 이벤트명·journey 기록 여부·허용 차원 키(d1/d2/d3 슬롯 매핑 순서)를\n// 여기 한 곳에 고정한다. api-core `/collect` 가 본 사전을 import 해 allowlist\n// 로 쓴다(설계 §4 — 사전 밖 `ev` 는 204 drop). 브라우저 의존 0(순수 데이터) —\n// edge(api-core)·node·browser 어디서나 import 가능.\n//\n// `directions_click`·`vcard_save` 는 v1 제외(설계 §1 각주) — starter 에 실\n// 지도 링크 계약이 아직 없고 / bio-pages 는 별도 앱이라 링크 계약 신설 시\n// 사전에 등록한다.\n//\n// journey 플래그(설계 §3): sessionStorage 여정 링버퍼(journey.ts)에 담을\n// 이벤트만 true. 설계는 \"pageview + 전환 사다리 이벤트\"라 표현하는데, 본\n// 사전은 이를 pageview + GA4 미러 대상(확정 전환 신호: lead_submit·\n// phone_click·email_click·chat_click·booking_click) + 그 직전 단계인\n// form_submit_attempt 로 확정한다. cta_click·form_start·faq_open 등 참여\n// 신호는 scroll_depth·section_view 같은 고빈도 신호와 마찬가지로 journey 에서\n// 제외 — 방문 여정은 \"전환에 이르는 궤적\"만 남긴다(§3 취지).\nexport interface TrackEventDef {\n /** GA4 로 미러 발화하는 이벤트인지(설계 §0 게이트 계약). */\n readonly ga4: boolean;\n /** GA4 미러 시 사용할 gtag 이벤트명. `ga4=true` 일 때만 존재. */\n readonly ga4Name?: string;\n /** sessionStorage 여정 링버퍼(journey.ts)에 기록할지. */\n readonly journey: boolean;\n /**\n * 허용 차원 키(의미상 이름) — 순서대로 blob d1/d2/d3 슬롯에 매핑(설계 §4).\n * 어드민 표시·문서용 semantic name. 실제 payload 파싱은 {@link wireDims}.\n */\n readonly dims: readonly string[];\n /**\n * `dims` 와 순서 동일하되 **실제 /collect JSON payload 상의 키 이름**\n * (beacon.ts RUNTIME 이 두 경로로 값을 보내기 때문에 `dims` 와 다를 수 있다):\n * - 클릭 위임(`[data-track]` delegated click): `dataset` 의 `data-track-*`\n * camelCase 키를 그대로 전송(`data-track-placement`→`trackPlacement`,\n * `data-track-cta`→`trackCta`, `data-track-q`→`trackQ`,\n * `data-track-provider`→`trackProvider`).\n * - JS 직접 호출(`trackCustomEvent`/`window.__rtTrack` — observers.ts·\n * lead-form-meta.client.tsx·not-found 404 비콘 등): 호출부가 이미\n * `dims` 와 같은 snake_case 키(`section_id`·`pct`·`pid`·`form_id`·\n * `last_field`)로 extra 객체를 만들어 보낸다.\n * api-core `/collect` 가 이 키로 body 에서 값을 읽어 blob d1/d2/d3 에 채운다.\n */\n readonly wireDims?: readonly string[];\n}\n\nexport const TRACK_EVENTS = {\n pageview: { ga4: false, journey: true, dims: [\"pid\"], wireDims: [\"pid\"] },\n lead_submit: {\n ga4: true,\n ga4Name: \"generate_lead\",\n journey: true,\n dims: [\"form_id\"],\n wireDims: [\"form_id\"],\n },\n form_submit_attempt: {\n ga4: false,\n journey: true,\n dims: [\"form_id\"],\n wireDims: [\"form_id\"],\n },\n phone_click: {\n ga4: true,\n ga4Name: \"phone_call_click\",\n journey: true,\n dims: [\"placement\"],\n wireDims: [\"trackPlacement\"],\n },\n email_click: {\n ga4: true,\n ga4Name: \"email_click\",\n journey: true,\n dims: [\"placement\"],\n wireDims: [\"trackPlacement\"],\n },\n chat_click: {\n ga4: true,\n ga4Name: \"chat_click\",\n journey: true,\n dims: [\"provider\", \"placement\"],\n wireDims: [\"trackProvider\", \"trackPlacement\"],\n },\n booking_click: {\n ga4: true,\n ga4Name: \"booking_outbound\",\n journey: true,\n dims: [\"provider\", \"placement\"],\n wireDims: [\"trackProvider\", \"trackPlacement\"],\n },\n cta_click: {\n ga4: false,\n journey: false,\n dims: [\"cta_id\", \"placement\"],\n wireDims: [\"trackCta\", \"trackPlacement\"],\n },\n pricing_view: {\n ga4: false,\n journey: false,\n dims: [\"item\"],\n wireDims: [\"trackItem\"],\n },\n pricing_expand: {\n ga4: false,\n journey: false,\n dims: [\"item\"],\n wireDims: [\"trackItem\"],\n },\n faq_open: {\n ga4: false,\n journey: false,\n dims: [\"q_index\"],\n wireDims: [\"trackQ\"],\n },\n gallery_interact: {\n ga4: false,\n journey: false,\n dims: [\"spot\"],\n wireDims: [\"trackSpot\"],\n },\n form_start: {\n ga4: false,\n journey: false,\n dims: [\"form_id\"],\n wireDims: [\"form_id\"],\n },\n form_abandon: {\n ga4: false,\n journey: false,\n dims: [\"form_id\", \"last_field\"],\n wireDims: [\"form_id\", \"last_field\"],\n },\n section_view: {\n ga4: false,\n journey: false,\n dims: [\"section_id\"],\n wireDims: [\"section_id\"],\n },\n scroll_depth: {\n ga4: false,\n journey: false,\n dims: [\"pct\"],\n wireDims: [\"pct\"],\n },\n read_complete: {\n ga4: false,\n journey: false,\n dims: [\"pid\"],\n wireDims: [\"pid\"],\n },\n} as const satisfies Record<string, TrackEventDef>;\n\nexport type TrackEventName = keyof typeof TRACK_EVENTS;\n\n/** `name` 이 사전에 등록된 이벤트인지 — api-core `/collect` allowlist 가드로 재사용. */\nexport function isTrackEvent(name: string): name is TrackEventName {\n return Object.prototype.hasOwnProperty.call(TRACK_EVENTS, name);\n}\n\n/**\n * GA4 미러 대상 이벤트만 뽑은 파생 상수 — `{eventName: ga4EventName}`.\n * `ga4-bridge.ts` 가 이 맵으로 미러 여부·gtag 이벤트명을 조회한다. 사전이\n * SSoT 이므로 이 상수는 `TRACK_EVENTS` 에서 계산될 뿐 별도로 유지하지 않는다.\n */\nexport const GA4_MIRROR: Readonly<Partial<Record<TrackEventName, string>>> =\n (() => {\n const out: Partial<Record<TrackEventName, string>> = {};\n for (const name of Object.keys(TRACK_EVENTS) as TrackEventName[]) {\n const def = TRACK_EVENTS[name];\n if (def.ga4 && def.ga4Name) out[name] = def.ga4Name;\n }\n return out;\n })();\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import {
|
|
2
|
+
trackCustomEvent
|
|
3
|
+
} from "./chunk-DIN7LIST.js";
|
|
4
|
+
|
|
5
|
+
// src/observers.ts
|
|
6
|
+
var SCROLL_MILESTONES = [25, 50, 75, 100];
|
|
7
|
+
var READ_COMPLETE_THRESHOLD = 0.75;
|
|
8
|
+
var READ_COMPLETE_DWELL_MS = 15e3;
|
|
9
|
+
var sectionObserver = null;
|
|
10
|
+
var readObserver = null;
|
|
11
|
+
var readTimer = null;
|
|
12
|
+
var readFired = false;
|
|
13
|
+
var scrollFired = /* @__PURE__ */ new Set();
|
|
14
|
+
var scrollHandler = null;
|
|
15
|
+
var formFocusHandler = null;
|
|
16
|
+
var formInputHandler = null;
|
|
17
|
+
var formSubmitHandler = null;
|
|
18
|
+
var pagehideHandler = null;
|
|
19
|
+
var formState = /* @__PURE__ */ new WeakMap();
|
|
20
|
+
function initSectionView() {
|
|
21
|
+
const els = document.querySelectorAll("[data-track-view]");
|
|
22
|
+
if (els.length === 0) return;
|
|
23
|
+
const fired = /* @__PURE__ */ new Set();
|
|
24
|
+
const observer = new IntersectionObserver(
|
|
25
|
+
(entries) => {
|
|
26
|
+
for (const entry of entries) {
|
|
27
|
+
if (!entry.isIntersecting || fired.has(entry.target)) continue;
|
|
28
|
+
fired.add(entry.target);
|
|
29
|
+
const sectionId = entry.target.getAttribute("data-track-view");
|
|
30
|
+
trackCustomEvent(
|
|
31
|
+
"section_view",
|
|
32
|
+
sectionId ? { section_id: sectionId } : void 0
|
|
33
|
+
);
|
|
34
|
+
observer.unobserve(entry.target);
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{ threshold: 0.5 }
|
|
38
|
+
);
|
|
39
|
+
els.forEach((el) => observer.observe(el));
|
|
40
|
+
sectionObserver = observer;
|
|
41
|
+
}
|
|
42
|
+
function initScrollDepth() {
|
|
43
|
+
scrollFired = /* @__PURE__ */ new Set();
|
|
44
|
+
const onScroll = () => {
|
|
45
|
+
const doc = document.documentElement;
|
|
46
|
+
const scrollable = doc.scrollHeight - doc.clientHeight;
|
|
47
|
+
const pct = scrollable <= 0 ? 100 : Math.round(window.scrollY / scrollable * 100);
|
|
48
|
+
for (const milestone of SCROLL_MILESTONES) {
|
|
49
|
+
if (pct >= milestone && !scrollFired.has(milestone)) {
|
|
50
|
+
scrollFired.add(milestone);
|
|
51
|
+
trackCustomEvent("scroll_depth", { pct: milestone });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
scrollHandler = onScroll;
|
|
56
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
57
|
+
onScroll();
|
|
58
|
+
}
|
|
59
|
+
function initReadComplete() {
|
|
60
|
+
const article = document.querySelector("[data-track-read]");
|
|
61
|
+
if (!article) return;
|
|
62
|
+
readFired = false;
|
|
63
|
+
const observer = new IntersectionObserver(
|
|
64
|
+
(entries) => {
|
|
65
|
+
const entry = entries[0];
|
|
66
|
+
if (!entry) return;
|
|
67
|
+
const visibleEnough = entry.isIntersecting && entry.intersectionRatio >= READ_COMPLETE_THRESHOLD;
|
|
68
|
+
if (visibleEnough) {
|
|
69
|
+
if (readTimer || readFired) return;
|
|
70
|
+
readTimer = setTimeout(() => {
|
|
71
|
+
readTimer = null;
|
|
72
|
+
if (readFired) return;
|
|
73
|
+
readFired = true;
|
|
74
|
+
const pid = article.getAttribute("data-track-read");
|
|
75
|
+
trackCustomEvent("read_complete", pid ? { pid } : void 0);
|
|
76
|
+
}, READ_COMPLETE_DWELL_MS);
|
|
77
|
+
} else if (readTimer) {
|
|
78
|
+
clearTimeout(readTimer);
|
|
79
|
+
readTimer = null;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{ threshold: READ_COMPLETE_THRESHOLD }
|
|
83
|
+
);
|
|
84
|
+
observer.observe(article);
|
|
85
|
+
readObserver = observer;
|
|
86
|
+
}
|
|
87
|
+
function trackedForm(target) {
|
|
88
|
+
const el = target;
|
|
89
|
+
return el?.closest?.("form[data-track-form]") ?? null;
|
|
90
|
+
}
|
|
91
|
+
function initFormTracking() {
|
|
92
|
+
const onFocusIn = (e) => {
|
|
93
|
+
const form = trackedForm(e.target);
|
|
94
|
+
if (!form) return;
|
|
95
|
+
const state = formState.get(form) ?? {
|
|
96
|
+
started: false,
|
|
97
|
+
dirty: false,
|
|
98
|
+
submitted: false
|
|
99
|
+
};
|
|
100
|
+
if (!state.started) {
|
|
101
|
+
state.started = true;
|
|
102
|
+
const formId = form.getAttribute("data-track-form");
|
|
103
|
+
trackCustomEvent("form_start", formId ? { form_id: formId } : void 0);
|
|
104
|
+
}
|
|
105
|
+
formState.set(form, state);
|
|
106
|
+
};
|
|
107
|
+
const onInput = (e) => {
|
|
108
|
+
const form = trackedForm(e.target);
|
|
109
|
+
if (!form) return;
|
|
110
|
+
const state = formState.get(form) ?? {
|
|
111
|
+
started: false,
|
|
112
|
+
dirty: false,
|
|
113
|
+
submitted: false
|
|
114
|
+
};
|
|
115
|
+
state.dirty = true;
|
|
116
|
+
const target = e.target;
|
|
117
|
+
const fieldName = target?.name || target?.id;
|
|
118
|
+
if (fieldName) state.lastField = fieldName;
|
|
119
|
+
formState.set(form, state);
|
|
120
|
+
};
|
|
121
|
+
const onSubmit = (e) => {
|
|
122
|
+
const form = e.target;
|
|
123
|
+
if (!form?.matches?.("form[data-track-form]")) return;
|
|
124
|
+
const state = formState.get(form);
|
|
125
|
+
if (state) {
|
|
126
|
+
state.submitted = true;
|
|
127
|
+
formState.set(form, state);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
131
|
+
document.addEventListener("input", onInput, true);
|
|
132
|
+
document.addEventListener("change", onInput, true);
|
|
133
|
+
document.addEventListener("submit", onSubmit, true);
|
|
134
|
+
formFocusHandler = onFocusIn;
|
|
135
|
+
formInputHandler = onInput;
|
|
136
|
+
formSubmitHandler = onSubmit;
|
|
137
|
+
}
|
|
138
|
+
function initFormAbandon() {
|
|
139
|
+
const onPageHide = (e) => {
|
|
140
|
+
if (e.persisted) return;
|
|
141
|
+
document.querySelectorAll("form[data-track-form]").forEach((form) => {
|
|
142
|
+
const state = formState.get(form);
|
|
143
|
+
if (!state || !state.dirty || state.submitted) return;
|
|
144
|
+
const formId = form.getAttribute("data-track-form");
|
|
145
|
+
trackCustomEvent("form_abandon", {
|
|
146
|
+
...formId ? { form_id: formId } : {},
|
|
147
|
+
...state.lastField ? { last_field: state.lastField } : {}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
pagehideHandler = onPageHide;
|
|
152
|
+
window.addEventListener("pagehide", onPageHide);
|
|
153
|
+
}
|
|
154
|
+
function initObservers() {
|
|
155
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
156
|
+
initSectionView();
|
|
157
|
+
initScrollDepth();
|
|
158
|
+
initReadComplete();
|
|
159
|
+
initFormTracking();
|
|
160
|
+
initFormAbandon();
|
|
161
|
+
}
|
|
162
|
+
function teardownObservers() {
|
|
163
|
+
sectionObserver?.disconnect();
|
|
164
|
+
sectionObserver = null;
|
|
165
|
+
readObserver?.disconnect();
|
|
166
|
+
readObserver = null;
|
|
167
|
+
if (readTimer) {
|
|
168
|
+
clearTimeout(readTimer);
|
|
169
|
+
readTimer = null;
|
|
170
|
+
}
|
|
171
|
+
readFired = false;
|
|
172
|
+
if (scrollHandler) {
|
|
173
|
+
window.removeEventListener("scroll", scrollHandler);
|
|
174
|
+
scrollHandler = null;
|
|
175
|
+
}
|
|
176
|
+
scrollFired = /* @__PURE__ */ new Set();
|
|
177
|
+
if (formFocusHandler) {
|
|
178
|
+
document.removeEventListener("focusin", formFocusHandler, true);
|
|
179
|
+
formFocusHandler = null;
|
|
180
|
+
}
|
|
181
|
+
if (formInputHandler) {
|
|
182
|
+
document.removeEventListener("input", formInputHandler, true);
|
|
183
|
+
document.removeEventListener("change", formInputHandler, true);
|
|
184
|
+
formInputHandler = null;
|
|
185
|
+
}
|
|
186
|
+
if (formSubmitHandler) {
|
|
187
|
+
document.removeEventListener("submit", formSubmitHandler, true);
|
|
188
|
+
formSubmitHandler = null;
|
|
189
|
+
}
|
|
190
|
+
if (pagehideHandler) {
|
|
191
|
+
window.removeEventListener("pagehide", pagehideHandler);
|
|
192
|
+
pagehideHandler = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export {
|
|
197
|
+
initObservers,
|
|
198
|
+
teardownObservers
|
|
199
|
+
};
|
|
200
|
+
//# sourceMappingURL=chunk-GW523RS4.js.map
|