@sayren/storefront-sdk 0.1.0 → 0.2.1
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/analytics/index.d.mts +82 -0
- package/dist/analytics/index.mjs +315 -0
- package/dist/analytics-ids-o0a07Ohr.mjs +126 -0
- package/dist/index.d.mts +139 -0
- package/dist/index.mjs +6 -1
- package/package.json +7 -2
- package/src/analytics/index.ts +431 -0
- package/src/analytics-ids.ts +42 -0
- package/src/client.ts +12 -0
- package/src/index.ts +2 -0
- package/src/schemas/analytics.ts +111 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
//#region src/analytics/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 방문 분석 브라우저 모듈 — `@sayren/storefront-sdk/analytics`.
|
|
4
|
+
*
|
|
5
|
+
* 페이지뷰·상품 조회·목록 노출·검색·체류를 모아 배치로 보낸다. 장바구니·결제·구매는 스토어프론트 API가
|
|
6
|
+
* 서버에서 기록하므로 여기서 보내지 않는다(클라이언트에 `visitorId`·`sessionId`를 넘기면 이어진다).
|
|
7
|
+
*
|
|
8
|
+
* - 식별자는 셀러 도메인의 1st-party 쿠키(`sy_vid` 1년, `sy_sid` 30분 비활동)다. SSR 서버도 같은 쿠키를 읽는다.
|
|
9
|
+
* - 같은 행동은 한 번만 보낸다(StrictMode 이중 실행·리렌더 흡수). 서버도 결정적 id로 한 번 더 거른다.
|
|
10
|
+
* - 동의 전(`pending`)에는 쿠키를 만들지 않고 큐에만 쌓는다. `denied`면 버린다.
|
|
11
|
+
* - localhost·사설 IP에서는 기본으로 보내지 않는다(`debug: true`로 켠다).
|
|
12
|
+
*/
|
|
13
|
+
export type AnalyticsConsent = "granted" | "denied" | "pending";
|
|
14
|
+
export interface AnalyticsOptions {
|
|
15
|
+
/** 스토어프론트 API 베이스 — 예: `https://api.sayren.app/storefront/v1` */
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
/** 테넌트 스토어 코드 */
|
|
18
|
+
storeCode: string;
|
|
19
|
+
/** 동의 상태. 기본 `pending` — 셀러의 동의 배너가 `setConsent`로 바꾼다 */
|
|
20
|
+
consent?: AnalyticsConsent;
|
|
21
|
+
/** 페이지뷰를 자동으로 보낸다(첫 페이지 + History API 이동). 기본 true */
|
|
22
|
+
autoPageViews?: boolean;
|
|
23
|
+
/** 개발 호스트(localhost 등)에서도 보낸다. 기본 false */
|
|
24
|
+
debug?: boolean;
|
|
25
|
+
/** 쿠키 도메인 — 서브도메인끼리 방문자를 공유하려면 `.myshop.com` */
|
|
26
|
+
cookieDomain?: string;
|
|
27
|
+
/** 배치 전송 간격(ms). 기본 5000 */
|
|
28
|
+
flushIntervalMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export type AnalyticsTrackInput = {
|
|
31
|
+
name: "product_view";
|
|
32
|
+
productId: string;
|
|
33
|
+
variantId?: string;
|
|
34
|
+
} | {
|
|
35
|
+
name: "product_list_view";
|
|
36
|
+
listId: string;
|
|
37
|
+
productIds: string[];
|
|
38
|
+
} | {
|
|
39
|
+
name: "search";
|
|
40
|
+
query: string;
|
|
41
|
+
resultCount: number;
|
|
42
|
+
};
|
|
43
|
+
export interface Analytics {
|
|
44
|
+
/** 행동 이벤트 — 같은 페이지뷰 안의 같은 행동은 한 번만 보낸다 */
|
|
45
|
+
track(input: AnalyticsTrackInput): void;
|
|
46
|
+
/** 페이지뷰를 직접 보낸다(`autoPageViews: false`일 때) */
|
|
47
|
+
page(): void;
|
|
48
|
+
setConsent(consent: AnalyticsConsent): void;
|
|
49
|
+
/** 방문자·세션 ID — `createStorefrontClient({ visitorId, sessionId })`에 넘긴다. 동의 전·거부면 null */
|
|
50
|
+
ids(): {
|
|
51
|
+
visitorId: string;
|
|
52
|
+
sessionId: string;
|
|
53
|
+
} | null;
|
|
54
|
+
/** 큐를 지금 보낸다 */
|
|
55
|
+
flush(): void;
|
|
56
|
+
/** 리스너·타이머를 걷는다 */
|
|
57
|
+
destroy(): void;
|
|
58
|
+
}
|
|
59
|
+
/** 브라우저 의존부 — 테스트는 가짜 환경을 넣는다 */
|
|
60
|
+
export interface AnalyticsEnv {
|
|
61
|
+
now(): number;
|
|
62
|
+
href(): string;
|
|
63
|
+
referrer(): string;
|
|
64
|
+
hostname(): string;
|
|
65
|
+
getCookie(name: string): string | null;
|
|
66
|
+
setCookie(name: string, value: string, maxAgeSec: number, domain?: string): void;
|
|
67
|
+
/** 배치를 보낸다(sendBeacon, 안 되면 fetch keepalive) */
|
|
68
|
+
send(url: string, body: string): void;
|
|
69
|
+
randomId(): string;
|
|
70
|
+
visible(): boolean;
|
|
71
|
+
prerendering(): boolean;
|
|
72
|
+
webdriver(): boolean;
|
|
73
|
+
/** 이벤트 리스너를 달고 떼는 함수를 돌려준다 */
|
|
74
|
+
on(event: "visibilitychange" | "pagehide" | "prerenderingchange", fn: () => void): () => void;
|
|
75
|
+
/** History API 이동(pushState·replaceState·popstate)을 구독한다 */
|
|
76
|
+
onNavigate(fn: () => void): () => void;
|
|
77
|
+
setInterval(fn: () => void, ms: number): () => void;
|
|
78
|
+
}
|
|
79
|
+
export declare function createAnalytics(options: AnalyticsOptions): Analytics;
|
|
80
|
+
/** 테스트·비표준 런타임용 — 환경을 직접 넣는다 */
|
|
81
|
+
export declare function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv): Analytics;
|
|
82
|
+
//#endregion
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { i as ANALYTICS_SESSION_COOKIE, n as safeDecode, o as ANALYTICS_VISITOR_COOKIE } from "../analytics-ids-o0a07Ohr.mjs";
|
|
2
|
+
//#region src/analytics/index.ts
|
|
3
|
+
const VISITOR_MAX_AGE_SEC = 31536e3;
|
|
4
|
+
const SESSION_MAX_AGE_SEC = 1800;
|
|
5
|
+
/** 세션을 시작한 캠페인 — 다른 캠페인으로 들어오면 새 세션이다 */
|
|
6
|
+
const SESSION_CAMPAIGN_COOKIE = "sy_scmp";
|
|
7
|
+
const MAX_BATCH = 50;
|
|
8
|
+
/** 이벤트 이름당 분당 상한 — 무한 루프 버그가 트래픽을 만들지 못하게 */
|
|
9
|
+
const PER_NAME_PER_MINUTE = 30;
|
|
10
|
+
/**
|
|
11
|
+
* 전체 분당 상한 — 서버의 방문자당 분당 한도(120)보다 낮게 둬, SDK가 보낸 이벤트가 서버 한도에 걸려
|
|
12
|
+
* 조용히 버려지지 않게 한다(이름당 상한만으로는 이름 5종 × 30 = 150까지 갈 수 있다).
|
|
13
|
+
*/
|
|
14
|
+
const TOTAL_PER_MINUTE = 100;
|
|
15
|
+
const MAX_QUEUE = 500;
|
|
16
|
+
const DEV_HOST = /^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$|\.local$/i;
|
|
17
|
+
function createAnalytics(options) {
|
|
18
|
+
if (typeof window === "undefined" || typeof document === "undefined") return noopAnalytics;
|
|
19
|
+
return createAnalyticsWith(options, browserEnv());
|
|
20
|
+
}
|
|
21
|
+
/** 테스트·비표준 런타임용 — 환경을 직접 넣는다 */
|
|
22
|
+
function createAnalyticsWith(options, env) {
|
|
23
|
+
const endpoint = `${options.baseUrl.replace(/\/+$/, "")}/events`;
|
|
24
|
+
const enabled = options.debug === true || !DEV_HOST.test(env.hostname());
|
|
25
|
+
let consent = options.consent ?? "pending";
|
|
26
|
+
let queue = [];
|
|
27
|
+
let pageViewId = "";
|
|
28
|
+
let pageKey = "";
|
|
29
|
+
/** 지금 페이지뷰의 URL — SPA 이동 뒤 이전 페이지의 체류를 보낼 때 새 URL과 섞이지 않게 */
|
|
30
|
+
let pageHref = "";
|
|
31
|
+
let visibleSince = null;
|
|
32
|
+
let visibleMs = 0;
|
|
33
|
+
let engagementSent = false;
|
|
34
|
+
let firstPage = true;
|
|
35
|
+
/**
|
|
36
|
+
* 지금 페이지뷰를 보냈는가 — 상한으로 페이지뷰가 버려졌으면 그 페이지의 상품 조회·목록·검색·체류도 보내지 않는다.
|
|
37
|
+
* 그러지 않으면 존재하지 않는 페이지뷰를 가리키는 이벤트가 남는다.
|
|
38
|
+
*/
|
|
39
|
+
let pageAccepted = false;
|
|
40
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41
|
+
let rateMinute = -1;
|
|
42
|
+
const perName = /* @__PURE__ */ new Map();
|
|
43
|
+
let totalThisMinute = 0;
|
|
44
|
+
const cleanups = [];
|
|
45
|
+
const campaignOf = (href) => {
|
|
46
|
+
try {
|
|
47
|
+
const params = new URL(href).searchParams;
|
|
48
|
+
const source = params.get("utm_source");
|
|
49
|
+
return source ? `${source}|${params.get("utm_campaign") ?? ""}` : null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/** 동의가 있을 때만 쿠키를 읽고 만든다. 세션은 이벤트마다 30분 연장한다 */
|
|
55
|
+
const ensureIds = () => {
|
|
56
|
+
if (consent !== "granted") return null;
|
|
57
|
+
let visitorId = env.getCookie(ANALYTICS_VISITOR_COOKIE);
|
|
58
|
+
if (!visitorId) visitorId = env.randomId();
|
|
59
|
+
let sessionId = env.getCookie(ANALYTICS_SESSION_COOKIE);
|
|
60
|
+
const campaign = campaignOf(env.href());
|
|
61
|
+
if (campaign && campaign !== env.getCookie(SESSION_CAMPAIGN_COOKIE)) {
|
|
62
|
+
sessionId = null;
|
|
63
|
+
env.setCookie(SESSION_CAMPAIGN_COOKIE, campaign, SESSION_MAX_AGE_SEC, options.cookieDomain);
|
|
64
|
+
}
|
|
65
|
+
if (!sessionId) sessionId = env.randomId();
|
|
66
|
+
env.setCookie(ANALYTICS_VISITOR_COOKIE, visitorId, VISITOR_MAX_AGE_SEC, options.cookieDomain);
|
|
67
|
+
env.setCookie(ANALYTICS_SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SEC, options.cookieDomain);
|
|
68
|
+
return {
|
|
69
|
+
visitorId,
|
|
70
|
+
sessionId
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const allowRate = (name) => {
|
|
74
|
+
const minute = Math.floor(env.now() / 6e4);
|
|
75
|
+
if (minute !== rateMinute) {
|
|
76
|
+
rateMinute = minute;
|
|
77
|
+
perName.clear();
|
|
78
|
+
totalThisMinute = 0;
|
|
79
|
+
}
|
|
80
|
+
const used = perName.get(name) ?? 0;
|
|
81
|
+
if (used >= PER_NAME_PER_MINUTE || totalThisMinute >= TOTAL_PER_MINUTE) return false;
|
|
82
|
+
perName.set(name, used + 1);
|
|
83
|
+
totalThisMinute += 1;
|
|
84
|
+
return true;
|
|
85
|
+
};
|
|
86
|
+
/** 큐에 넣었으면 true — 중복·상한·거부로 버리면 false */
|
|
87
|
+
const push = (event, dedupeKey) => {
|
|
88
|
+
if (!enabled || consent === "denied") return false;
|
|
89
|
+
if (seen.has(dedupeKey) || !allowRate(event.name)) return false;
|
|
90
|
+
seen.add(dedupeKey);
|
|
91
|
+
if (queue.length >= MAX_QUEUE) queue.shift();
|
|
92
|
+
queue.push(event);
|
|
93
|
+
if (queue.length >= MAX_BATCH) flush();
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
const flush = () => {
|
|
97
|
+
if (!enabled || queue.length === 0) return;
|
|
98
|
+
const ids = ensureIds();
|
|
99
|
+
if (!ids) return;
|
|
100
|
+
while (queue.length > 0) {
|
|
101
|
+
const events = queue.splice(0, MAX_BATCH);
|
|
102
|
+
const batch = {
|
|
103
|
+
storeCode: options.storeCode,
|
|
104
|
+
...ids,
|
|
105
|
+
events,
|
|
106
|
+
...env.webdriver() ? { context: { webdriver: true } } : {}
|
|
107
|
+
};
|
|
108
|
+
env.send(endpoint, JSON.stringify(batch));
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const occurredAt = () => new Date(env.now()).toISOString();
|
|
112
|
+
/** 지금 페이지의 체류를 한 번 보낸다(이동·숨김·닫힘 중 먼저 오는 때) */
|
|
113
|
+
const endPage = () => {
|
|
114
|
+
if (!pageViewId || engagementSent || !pageAccepted) return;
|
|
115
|
+
if (visibleSince !== null) {
|
|
116
|
+
visibleMs += env.now() - visibleSince;
|
|
117
|
+
visibleSince = env.visible() ? env.now() : null;
|
|
118
|
+
}
|
|
119
|
+
engagementSent = true;
|
|
120
|
+
push({
|
|
121
|
+
name: "page_engagement",
|
|
122
|
+
occurredAt: occurredAt(),
|
|
123
|
+
url: pageHref,
|
|
124
|
+
pageViewId,
|
|
125
|
+
visibleMs: Math.max(0, Math.round(visibleMs))
|
|
126
|
+
}, `engagement:${pageViewId}`);
|
|
127
|
+
};
|
|
128
|
+
const startPage = () => {
|
|
129
|
+
const href = env.href();
|
|
130
|
+
let key = href;
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(href);
|
|
133
|
+
key = `${url.pathname}${url.search}`;
|
|
134
|
+
} catch {}
|
|
135
|
+
if (key === pageKey) return;
|
|
136
|
+
endPage();
|
|
137
|
+
pageKey = key;
|
|
138
|
+
pageHref = href;
|
|
139
|
+
pageViewId = env.randomId();
|
|
140
|
+
visibleMs = 0;
|
|
141
|
+
visibleSince = env.visible() ? env.now() : null;
|
|
142
|
+
engagementSent = false;
|
|
143
|
+
const referrer = firstPage ? env.referrer() : "";
|
|
144
|
+
pageAccepted = push({
|
|
145
|
+
name: "page_view",
|
|
146
|
+
occurredAt: occurredAt(),
|
|
147
|
+
url: href,
|
|
148
|
+
pageViewId,
|
|
149
|
+
...referrer ? { referrer } : {}
|
|
150
|
+
}, `page:${pageViewId}`);
|
|
151
|
+
if (pageAccepted) firstPage = false;
|
|
152
|
+
};
|
|
153
|
+
/** 프리렌더·숨은 탭은 화면에 보이는 순간 첫 페이지뷰를 보낸다 */
|
|
154
|
+
const startWhenVisible = () => {
|
|
155
|
+
if (!env.prerendering() && env.visible()) {
|
|
156
|
+
startPage();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const tryStart = () => {
|
|
160
|
+
if (!pageViewId && !env.prerendering() && env.visible()) startPage();
|
|
161
|
+
};
|
|
162
|
+
cleanups.push(env.on("prerenderingchange", tryStart), env.on("visibilitychange", tryStart));
|
|
163
|
+
};
|
|
164
|
+
cleanups.push(env.on("visibilitychange", () => {
|
|
165
|
+
if (env.visible()) {
|
|
166
|
+
if (pageViewId && visibleSince === null) visibleSince = env.now();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
endPage();
|
|
170
|
+
flush();
|
|
171
|
+
}), env.on("pagehide", () => {
|
|
172
|
+
endPage();
|
|
173
|
+
flush();
|
|
174
|
+
}), env.setInterval(flush, options.flushIntervalMs ?? 5e3));
|
|
175
|
+
if (options.autoPageViews !== false) {
|
|
176
|
+
startWhenVisible();
|
|
177
|
+
cleanups.push(env.onNavigate(startPage));
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
track(input) {
|
|
181
|
+
if (!pageViewId) startPage();
|
|
182
|
+
if (!pageAccepted) return;
|
|
183
|
+
const common = {
|
|
184
|
+
occurredAt: occurredAt(),
|
|
185
|
+
url: env.href(),
|
|
186
|
+
pageViewId
|
|
187
|
+
};
|
|
188
|
+
switch (input.name) {
|
|
189
|
+
case "product_view":
|
|
190
|
+
push({
|
|
191
|
+
...common,
|
|
192
|
+
name: "product_view",
|
|
193
|
+
productId: input.productId,
|
|
194
|
+
...input.variantId ? { variantId: input.variantId } : {}
|
|
195
|
+
}, `product:${pageViewId}:${input.productId}`);
|
|
196
|
+
return;
|
|
197
|
+
case "product_list_view":
|
|
198
|
+
push({
|
|
199
|
+
...common,
|
|
200
|
+
name: "product_list_view",
|
|
201
|
+
listId: input.listId,
|
|
202
|
+
productIds: input.productIds.slice(0, 100)
|
|
203
|
+
}, `list:${pageViewId}:${input.listId}`);
|
|
204
|
+
return;
|
|
205
|
+
case "search": push({
|
|
206
|
+
...common,
|
|
207
|
+
name: "search",
|
|
208
|
+
query: input.query.slice(0, 200),
|
|
209
|
+
resultCount: input.resultCount
|
|
210
|
+
}, `search:${pageViewId}:${input.query.trim().toLowerCase()}`);
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
page: startPage,
|
|
214
|
+
setConsent(next) {
|
|
215
|
+
consent = next;
|
|
216
|
+
if (next === "denied") queue = [];
|
|
217
|
+
if (next === "granted") {
|
|
218
|
+
if (pageViewId && !pageAccepted && options.autoPageViews !== false) {
|
|
219
|
+
pageKey = "";
|
|
220
|
+
startPage();
|
|
221
|
+
}
|
|
222
|
+
flush();
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
ids: () => enabled ? ensureIds() : null,
|
|
226
|
+
flush,
|
|
227
|
+
destroy() {
|
|
228
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const noopAnalytics = {
|
|
233
|
+
track() {},
|
|
234
|
+
page() {},
|
|
235
|
+
setConsent() {},
|
|
236
|
+
ids: () => null,
|
|
237
|
+
flush() {},
|
|
238
|
+
destroy() {}
|
|
239
|
+
};
|
|
240
|
+
function randomId() {
|
|
241
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
242
|
+
crypto.getRandomValues(bytes);
|
|
243
|
+
let text = "";
|
|
244
|
+
for (const byte of bytes) text += String.fromCharCode(byte);
|
|
245
|
+
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
246
|
+
}
|
|
247
|
+
function browserEnv() {
|
|
248
|
+
return {
|
|
249
|
+
now: () => Date.now(),
|
|
250
|
+
href: () => location.href,
|
|
251
|
+
referrer: () => document.referrer,
|
|
252
|
+
hostname: () => location.hostname,
|
|
253
|
+
getCookie(name) {
|
|
254
|
+
for (const part of document.cookie.split(";")) {
|
|
255
|
+
const [key, ...rest] = part.trim().split("=");
|
|
256
|
+
if (key === name) return safeDecode(rest.join("="));
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
},
|
|
260
|
+
setCookie(name, value, maxAgeSec, domain) {
|
|
261
|
+
document.cookie = [
|
|
262
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
263
|
+
"path=/",
|
|
264
|
+
`max-age=${maxAgeSec}`,
|
|
265
|
+
"samesite=lax",
|
|
266
|
+
...domain ? [`domain=${domain}`] : [],
|
|
267
|
+
...location.protocol === "https:" ? ["secure"] : []
|
|
268
|
+
].join("; ");
|
|
269
|
+
},
|
|
270
|
+
send(url, body) {
|
|
271
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
272
|
+
if (typeof navigator.sendBeacon === "function" && navigator.sendBeacon(url, blob)) return;
|
|
273
|
+
fetch(url, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
body,
|
|
276
|
+
keepalive: true,
|
|
277
|
+
credentials: "omit",
|
|
278
|
+
headers: { "content-type": "text/plain;charset=UTF-8" }
|
|
279
|
+
}).catch(() => {});
|
|
280
|
+
},
|
|
281
|
+
randomId,
|
|
282
|
+
visible: () => document.visibilityState !== "hidden",
|
|
283
|
+
prerendering: () => document.prerendering === true,
|
|
284
|
+
webdriver: () => navigator.webdriver === true,
|
|
285
|
+
on(event, fn) {
|
|
286
|
+
const target = event === "pagehide" ? window : document;
|
|
287
|
+
target.addEventListener(event, fn);
|
|
288
|
+
return () => target.removeEventListener(event, fn);
|
|
289
|
+
},
|
|
290
|
+
onNavigate(fn) {
|
|
291
|
+
const push = history.pushState;
|
|
292
|
+
const replace = history.replaceState;
|
|
293
|
+
history.pushState = function(...args) {
|
|
294
|
+
push.apply(this, args);
|
|
295
|
+
queueMicrotask(fn);
|
|
296
|
+
};
|
|
297
|
+
history.replaceState = function(...args) {
|
|
298
|
+
replace.apply(this, args);
|
|
299
|
+
queueMicrotask(fn);
|
|
300
|
+
};
|
|
301
|
+
window.addEventListener("popstate", fn);
|
|
302
|
+
return () => {
|
|
303
|
+
history.pushState = push;
|
|
304
|
+
history.replaceState = replace;
|
|
305
|
+
window.removeEventListener("popstate", fn);
|
|
306
|
+
};
|
|
307
|
+
},
|
|
308
|
+
setInterval(fn, ms) {
|
|
309
|
+
const id = window.setInterval(fn, ms);
|
|
310
|
+
return () => window.clearInterval(id);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
//#endregion
|
|
315
|
+
export { createAnalytics, createAnalyticsWith };
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
//#region src/schemas/analytics.ts
|
|
3
|
+
/**
|
|
4
|
+
* 방문 분석 이벤트 계약 — 브라우저 모듈(`@sayren/storefront-sdk/analytics`)과 수집 엔드포인트
|
|
5
|
+
* (`POST /storefront/v1/events`)가 같은 스키마를 쓴다.
|
|
6
|
+
*
|
|
7
|
+
* 행동 이벤트(페이지뷰·상품 조회·목록 노출·검색·체류)는 브라우저가 보낸다. 거래 이벤트(장바구니·결제 시작·구매)는
|
|
8
|
+
* 스토어프론트 API가 요청을 처리하면서 서버에서 기록하므로 브라우저가 보내지 않는다 — 매출·전환은 조작이나
|
|
9
|
+
* 누락이 없는 서버 데이터로 계산한다.
|
|
10
|
+
*/
|
|
11
|
+
/** 방문자·세션 식별자 — SDK가 만드는 URL-safe 난수 */
|
|
12
|
+
const analyticsIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/);
|
|
13
|
+
/** 방문자 ID 쿠키(1년) · 세션 ID 쿠키(30분 비활동) 이름. SSR 서버도 같은 이름으로 읽는다 */
|
|
14
|
+
const ANALYTICS_VISITOR_COOKIE = "sy_vid";
|
|
15
|
+
const ANALYTICS_SESSION_COOKIE = "sy_sid";
|
|
16
|
+
/** 스토어프론트 API 요청에 실어 서버 거래 이벤트와 방문자·세션을 잇는 헤더 */
|
|
17
|
+
const ANALYTICS_VISITOR_HEADER = "x-sayren-visitor";
|
|
18
|
+
const ANALYTICS_SESSION_HEADER = "x-sayren-session";
|
|
19
|
+
/** 브라우저가 보내는 이벤트 이름 */
|
|
20
|
+
const analyticsClientEventNameSchema = z.enum([
|
|
21
|
+
"page_view",
|
|
22
|
+
"page_engagement",
|
|
23
|
+
"product_view",
|
|
24
|
+
"product_list_view",
|
|
25
|
+
"search"
|
|
26
|
+
]);
|
|
27
|
+
/** 서버가 기록하는 이벤트 이름 */
|
|
28
|
+
const analyticsServerEventNameSchema = z.enum([
|
|
29
|
+
"add_to_cart",
|
|
30
|
+
"remove_from_cart",
|
|
31
|
+
"begin_checkout",
|
|
32
|
+
"purchase"
|
|
33
|
+
]);
|
|
34
|
+
const text = (max) => z.string().trim().min(1).max(max);
|
|
35
|
+
const base = {
|
|
36
|
+
/** 발생 시각(ISO 8601). 서버가 수신 시각 ±24시간으로 보정한다 */
|
|
37
|
+
occurredAt: z.iso.datetime({ offset: true }),
|
|
38
|
+
/** 이벤트가 난 페이지의 전체 URL. 서버가 경로와 utm_*만 남기고 나머지 쿼리는 버린다 */
|
|
39
|
+
url: z.url().max(2048),
|
|
40
|
+
/** 한 페이지뷰 안의 이벤트를 묶는 난수. `page_engagement`가 어느 페이지뷰의 체류인지 가리킨다 */
|
|
41
|
+
pageViewId: analyticsIdSchema
|
|
42
|
+
};
|
|
43
|
+
const analyticsClientEventSchema = z.discriminatedUnion("name", [
|
|
44
|
+
z.object({
|
|
45
|
+
...base,
|
|
46
|
+
name: z.literal("page_view"),
|
|
47
|
+
/** document.referrer. 서버는 호스트만 남긴다 */
|
|
48
|
+
referrer: z.url().max(2048).optional()
|
|
49
|
+
}),
|
|
50
|
+
z.object({
|
|
51
|
+
...base,
|
|
52
|
+
name: z.literal("page_engagement"),
|
|
53
|
+
/** 페이지가 화면에 보인 누적 시간(ms) */
|
|
54
|
+
visibleMs: z.number().int().min(0).max(864e5)
|
|
55
|
+
}),
|
|
56
|
+
z.object({
|
|
57
|
+
...base,
|
|
58
|
+
name: z.literal("product_view"),
|
|
59
|
+
productId: text(64),
|
|
60
|
+
variantId: text(64).optional()
|
|
61
|
+
}),
|
|
62
|
+
z.object({
|
|
63
|
+
...base,
|
|
64
|
+
name: z.literal("product_list_view"),
|
|
65
|
+
/** 목록 식별자 — 예: `category:cat_123`, `search`, `home:new` */
|
|
66
|
+
listId: text(128),
|
|
67
|
+
productIds: z.array(text(64)).max(100)
|
|
68
|
+
}),
|
|
69
|
+
z.object({
|
|
70
|
+
...base,
|
|
71
|
+
name: z.literal("search"),
|
|
72
|
+
query: text(200),
|
|
73
|
+
resultCount: z.number().int().min(0)
|
|
74
|
+
})
|
|
75
|
+
]);
|
|
76
|
+
/** 한 번에 보내는 배치 — sendBeacon이라 헤더를 못 실어 테넌트·식별자를 본문에 싣는다 */
|
|
77
|
+
const analyticsBatchSchema = z.object({
|
|
78
|
+
storeCode: text(64),
|
|
79
|
+
visitorId: analyticsIdSchema,
|
|
80
|
+
sessionId: analyticsIdSchema,
|
|
81
|
+
events: z.array(analyticsClientEventSchema).min(1).max(50),
|
|
82
|
+
/** 봇 판정 보조 신호 */
|
|
83
|
+
context: z.object({
|
|
84
|
+
/** navigator.webdriver — 자동화 브라우저 */
|
|
85
|
+
webdriver: z.boolean().optional() }).optional()
|
|
86
|
+
});
|
|
87
|
+
/** 배치 본문 상한(바이트) — 서버가 넘는 요청을 거부한다 */
|
|
88
|
+
const ANALYTICS_MAX_BATCH_BYTES = 32768;
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/analytics-ids.ts
|
|
91
|
+
/**
|
|
92
|
+
* 요청의 Cookie 헤더에서 방문자·세션 ID를 읽는다 — SSR 서버가 스토어프론트 API를 부를 때 쓴다.
|
|
93
|
+
* 브라우저 모듈(`@sayren/storefront-sdk/analytics`)이 셀러 도메인에 남긴 쿠키다. 형식이 틀린 값은 버린다.
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* const ids = analyticsIdsFromCookie(request.headers.get("cookie"));
|
|
97
|
+
* createStorefrontClient({ baseUrl, storeCode, ...ids });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
function analyticsIdsFromCookie(cookieHeader) {
|
|
101
|
+
if (!cookieHeader) return {};
|
|
102
|
+
const pick = (name) => {
|
|
103
|
+
for (const part of cookieHeader.split(";")) {
|
|
104
|
+
const [key, ...rest] = part.trim().split("=");
|
|
105
|
+
if (key !== name) continue;
|
|
106
|
+
const value = safeDecode(rest.join("="));
|
|
107
|
+
return value !== null && analyticsIdSchema.safeParse(value).success ? value : void 0;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const visitorId = pick(ANALYTICS_VISITOR_COOKIE);
|
|
111
|
+
const sessionId = pick(ANALYTICS_SESSION_COOKIE);
|
|
112
|
+
return {
|
|
113
|
+
...visitorId ? { visitorId } : {},
|
|
114
|
+
...sessionId ? { sessionId } : {}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** 잘못된 퍼센트 인코딩(`%E0%A4`)은 URIError를 던진다 — 쿠키 하나 때문에 SSR 전체가 500이 되지 않게 버린다 */
|
|
118
|
+
function safeDecode(value) {
|
|
119
|
+
try {
|
|
120
|
+
return decodeURIComponent(value);
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
export { ANALYTICS_SESSION_HEADER as a, analyticsBatchSchema as c, analyticsIdSchema as d, analyticsServerEventNameSchema as f, ANALYTICS_SESSION_COOKIE as i, analyticsClientEventNameSchema as l, safeDecode as n, ANALYTICS_VISITOR_COOKIE as o, ANALYTICS_MAX_BATCH_BYTES as r, ANALYTICS_VISITOR_HEADER as s, analyticsIdsFromCookie as t, analyticsClientEventSchema as u };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
//#region src/analytics-ids.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* 요청의 Cookie 헤더에서 방문자·세션 ID를 읽는다 — SSR 서버가 스토어프론트 API를 부를 때 쓴다.
|
|
5
|
+
* 브라우저 모듈(`@sayren/storefront-sdk/analytics`)이 셀러 도메인에 남긴 쿠키다. 형식이 틀린 값은 버린다.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const ids = analyticsIdsFromCookie(request.headers.get("cookie"));
|
|
9
|
+
* createStorefrontClient({ baseUrl, storeCode, ...ids });
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export declare function analyticsIdsFromCookie(cookieHeader: string | null | undefined): {
|
|
13
|
+
visitorId?: string;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
};
|
|
16
|
+
//#endregion
|
|
2
17
|
//#region src/http.d.ts
|
|
3
18
|
export declare class ApiError extends Error {
|
|
4
19
|
readonly status: number;
|
|
@@ -674,6 +689,13 @@ interface StorefrontClientOptions {
|
|
|
674
689
|
};
|
|
675
690
|
/** 비회원 장바구니 토큰 */
|
|
676
691
|
cartToken?: TokenSource;
|
|
692
|
+
/**
|
|
693
|
+
* 방문 분석 식별자 — 방문자·세션 ID(`sy_vid`·`sy_sid` 쿠키 값). 실으면 서버가 장바구니 담기·결제 시작·구매를
|
|
694
|
+
* 이 방문과 이어 기록한다. 브라우저는 `@sayren/storefront-sdk/analytics`의 `analytics.ids()`, SSR은
|
|
695
|
+
* `analyticsIdsFromCookie(request.headers.get("cookie"))`로 얻는다. 함수로 전달하면 매 요청마다 재평가.
|
|
696
|
+
*/
|
|
697
|
+
visitorId?: TokenSource;
|
|
698
|
+
sessionId?: TokenSource;
|
|
677
699
|
/** 서버가 새 X-Cart-Token을 발급했을 때 호출 */
|
|
678
700
|
onCartToken?: (token: string) => void;
|
|
679
701
|
/**
|
|
@@ -1276,6 +1298,123 @@ export declare function createStorefrontClient(options: StorefrontClientOptions)
|
|
|
1276
1298
|
};
|
|
1277
1299
|
type StorefrontClient = ReturnType<typeof createStorefrontClient>;
|
|
1278
1300
|
//#endregion
|
|
1301
|
+
//#region src/schemas/analytics.d.ts
|
|
1302
|
+
/**
|
|
1303
|
+
* 방문 분석 이벤트 계약 — 브라우저 모듈(`@sayren/storefront-sdk/analytics`)과 수집 엔드포인트
|
|
1304
|
+
* (`POST /storefront/v1/events`)가 같은 스키마를 쓴다.
|
|
1305
|
+
*
|
|
1306
|
+
* 행동 이벤트(페이지뷰·상품 조회·목록 노출·검색·체류)는 브라우저가 보낸다. 거래 이벤트(장바구니·결제 시작·구매)는
|
|
1307
|
+
* 스토어프론트 API가 요청을 처리하면서 서버에서 기록하므로 브라우저가 보내지 않는다 — 매출·전환은 조작이나
|
|
1308
|
+
* 누락이 없는 서버 데이터로 계산한다.
|
|
1309
|
+
*/
|
|
1310
|
+
/** 방문자·세션 식별자 — SDK가 만드는 URL-safe 난수 */
|
|
1311
|
+
export declare const analyticsIdSchema: z.ZodString;
|
|
1312
|
+
/** 방문자 ID 쿠키(1년) · 세션 ID 쿠키(30분 비활동) 이름. SSR 서버도 같은 이름으로 읽는다 */
|
|
1313
|
+
export declare const ANALYTICS_VISITOR_COOKIE = "sy_vid";
|
|
1314
|
+
export declare const ANALYTICS_SESSION_COOKIE = "sy_sid";
|
|
1315
|
+
/** 스토어프론트 API 요청에 실어 서버 거래 이벤트와 방문자·세션을 잇는 헤더 */
|
|
1316
|
+
export declare const ANALYTICS_VISITOR_HEADER = "x-sayren-visitor";
|
|
1317
|
+
export declare const ANALYTICS_SESSION_HEADER = "x-sayren-session";
|
|
1318
|
+
/** 브라우저가 보내는 이벤트 이름 */
|
|
1319
|
+
export declare const analyticsClientEventNameSchema: z.ZodEnum<{
|
|
1320
|
+
search: "search";
|
|
1321
|
+
page_view: "page_view";
|
|
1322
|
+
page_engagement: "page_engagement";
|
|
1323
|
+
product_view: "product_view";
|
|
1324
|
+
product_list_view: "product_list_view";
|
|
1325
|
+
}>;
|
|
1326
|
+
export type AnalyticsClientEventName = z.infer<typeof analyticsClientEventNameSchema>;
|
|
1327
|
+
/** 서버가 기록하는 이벤트 이름 */
|
|
1328
|
+
export declare const analyticsServerEventNameSchema: z.ZodEnum<{
|
|
1329
|
+
add_to_cart: "add_to_cart";
|
|
1330
|
+
remove_from_cart: "remove_from_cart";
|
|
1331
|
+
begin_checkout: "begin_checkout";
|
|
1332
|
+
purchase: "purchase";
|
|
1333
|
+
}>;
|
|
1334
|
+
export type AnalyticsServerEventName = z.infer<typeof analyticsServerEventNameSchema>;
|
|
1335
|
+
export type AnalyticsEventName = AnalyticsClientEventName | AnalyticsServerEventName;
|
|
1336
|
+
export declare const analyticsClientEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
1337
|
+
name: z.ZodLiteral<"page_view">;
|
|
1338
|
+
referrer: z.ZodOptional<z.ZodURL>;
|
|
1339
|
+
occurredAt: z.ZodISODateTime;
|
|
1340
|
+
url: z.ZodURL;
|
|
1341
|
+
pageViewId: z.ZodString;
|
|
1342
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1343
|
+
name: z.ZodLiteral<"page_engagement">;
|
|
1344
|
+
visibleMs: z.ZodNumber;
|
|
1345
|
+
occurredAt: z.ZodISODateTime;
|
|
1346
|
+
url: z.ZodURL;
|
|
1347
|
+
pageViewId: z.ZodString;
|
|
1348
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1349
|
+
name: z.ZodLiteral<"product_view">;
|
|
1350
|
+
productId: z.ZodString;
|
|
1351
|
+
variantId: z.ZodOptional<z.ZodString>;
|
|
1352
|
+
occurredAt: z.ZodISODateTime;
|
|
1353
|
+
url: z.ZodURL;
|
|
1354
|
+
pageViewId: z.ZodString;
|
|
1355
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1356
|
+
name: z.ZodLiteral<"product_list_view">;
|
|
1357
|
+
listId: z.ZodString;
|
|
1358
|
+
productIds: z.ZodArray<z.ZodString>;
|
|
1359
|
+
occurredAt: z.ZodISODateTime;
|
|
1360
|
+
url: z.ZodURL;
|
|
1361
|
+
pageViewId: z.ZodString;
|
|
1362
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1363
|
+
name: z.ZodLiteral<"search">;
|
|
1364
|
+
query: z.ZodString;
|
|
1365
|
+
resultCount: z.ZodNumber;
|
|
1366
|
+
occurredAt: z.ZodISODateTime;
|
|
1367
|
+
url: z.ZodURL;
|
|
1368
|
+
pageViewId: z.ZodString;
|
|
1369
|
+
}, z.core.$strip>], "name">;
|
|
1370
|
+
export type AnalyticsClientEvent = z.infer<typeof analyticsClientEventSchema>;
|
|
1371
|
+
/** 한 번에 보내는 배치 — sendBeacon이라 헤더를 못 실어 테넌트·식별자를 본문에 싣는다 */
|
|
1372
|
+
export declare const analyticsBatchSchema: z.ZodObject<{
|
|
1373
|
+
storeCode: z.ZodString;
|
|
1374
|
+
visitorId: z.ZodString;
|
|
1375
|
+
sessionId: z.ZodString;
|
|
1376
|
+
events: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
1377
|
+
name: z.ZodLiteral<"page_view">;
|
|
1378
|
+
referrer: z.ZodOptional<z.ZodURL>;
|
|
1379
|
+
occurredAt: z.ZodISODateTime;
|
|
1380
|
+
url: z.ZodURL;
|
|
1381
|
+
pageViewId: z.ZodString;
|
|
1382
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1383
|
+
name: z.ZodLiteral<"page_engagement">;
|
|
1384
|
+
visibleMs: z.ZodNumber;
|
|
1385
|
+
occurredAt: z.ZodISODateTime;
|
|
1386
|
+
url: z.ZodURL;
|
|
1387
|
+
pageViewId: z.ZodString;
|
|
1388
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1389
|
+
name: z.ZodLiteral<"product_view">;
|
|
1390
|
+
productId: z.ZodString;
|
|
1391
|
+
variantId: z.ZodOptional<z.ZodString>;
|
|
1392
|
+
occurredAt: z.ZodISODateTime;
|
|
1393
|
+
url: z.ZodURL;
|
|
1394
|
+
pageViewId: z.ZodString;
|
|
1395
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1396
|
+
name: z.ZodLiteral<"product_list_view">;
|
|
1397
|
+
listId: z.ZodString;
|
|
1398
|
+
productIds: z.ZodArray<z.ZodString>;
|
|
1399
|
+
occurredAt: z.ZodISODateTime;
|
|
1400
|
+
url: z.ZodURL;
|
|
1401
|
+
pageViewId: z.ZodString;
|
|
1402
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
1403
|
+
name: z.ZodLiteral<"search">;
|
|
1404
|
+
query: z.ZodString;
|
|
1405
|
+
resultCount: z.ZodNumber;
|
|
1406
|
+
occurredAt: z.ZodISODateTime;
|
|
1407
|
+
url: z.ZodURL;
|
|
1408
|
+
pageViewId: z.ZodString;
|
|
1409
|
+
}, z.core.$strip>], "name">>;
|
|
1410
|
+
context: z.ZodOptional<z.ZodObject<{
|
|
1411
|
+
webdriver: z.ZodOptional<z.ZodBoolean>;
|
|
1412
|
+
}, z.core.$strip>>;
|
|
1413
|
+
}, z.core.$strip>;
|
|
1414
|
+
export type AnalyticsBatch = z.infer<typeof analyticsBatchSchema>;
|
|
1415
|
+
/** 배치 본문 상한(바이트) — 서버가 넘는 요청을 거부한다 */
|
|
1416
|
+
export declare const ANALYTICS_MAX_BATCH_BYTES: number;
|
|
1417
|
+
//#endregion
|
|
1279
1418
|
//#region src/schemas/order.d.ts
|
|
1280
1419
|
export declare const myOrderItemSchema: z.ZodObject<{
|
|
1281
1420
|
orderItemId: z.ZodString;
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { a as ANALYTICS_SESSION_HEADER, c as analyticsBatchSchema, d as analyticsIdSchema, f as analyticsServerEventNameSchema, i as ANALYTICS_SESSION_COOKIE, l as analyticsClientEventNameSchema, o as ANALYTICS_VISITOR_COOKIE, r as ANALYTICS_MAX_BATCH_BYTES, s as ANALYTICS_VISITOR_HEADER, t as analyticsIdsFromCookie, u as analyticsClientEventSchema } from "./analytics-ids-o0a07Ohr.mjs";
|
|
1
2
|
import { z } from "zod";
|
|
2
3
|
//#region src/http.ts
|
|
3
4
|
/** 표준 엔벨로프의 $.error 상세 */
|
|
@@ -633,6 +634,10 @@ function createStorefrontClient(options) {
|
|
|
633
634
|
if (accessToken) headers.authorization = `Bearer ${accessToken}`;
|
|
634
635
|
const cartToken = resolveToken(options.cartToken);
|
|
635
636
|
if (cartToken) headers["x-cart-token"] = cartToken;
|
|
637
|
+
const visitorId = resolveToken(options.visitorId);
|
|
638
|
+
if (visitorId) headers[ANALYTICS_VISITOR_HEADER] = visitorId;
|
|
639
|
+
const sessionId = resolveToken(options.sessionId);
|
|
640
|
+
if (sessionId) headers[ANALYTICS_SESSION_HEADER] = sessionId;
|
|
636
641
|
return headers;
|
|
637
642
|
},
|
|
638
643
|
onResponse: (response) => {
|
|
@@ -877,4 +882,4 @@ function createStorefrontSession(options) {
|
|
|
877
882
|
};
|
|
878
883
|
}
|
|
879
884
|
//#endregion
|
|
880
|
-
export { ApiError, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, confirmPaymentRequestSchema, confirmPaymentResultSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, guestInfoSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentMethodSchema, paymentParamsSchema, paymentStatusSchema, pgParamsSchema, productCardSchema, productDetailSchema, productFacetsSchema, productSortSchema, publicInquirySchema, publicReviewSchema, requestPaymentRequestSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, writableReviewSchema };
|
|
885
|
+
export { ANALYTICS_MAX_BATCH_BYTES, ANALYTICS_SESSION_COOKIE, ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_COOKIE, ANALYTICS_VISITOR_HEADER, ApiError, PAYMENT_STATUS_VALUES, STOREFRONT_SESSION_KEYS, addCartItemRequestSchema, analyticsBatchSchema, analyticsClientEventNameSchema, analyticsClientEventSchema, analyticsIdSchema, analyticsIdsFromCookie, analyticsServerEventNameSchema, buildQuery, cartItemSchema, cartSchema, categoryNodeSchema, checkoutSessionSchema, claimReasonSchema, claimStatusSchema, claimTypeSchema, confirmPaymentRequestSchema, confirmPaymentResultSchema, createCheckoutRequestSchema, createClaimRequestSchema, createClaimResultSchema, createCustomerInquiryRequestSchema, createInquiryRequestSchema, createReviewRequestSchema, createReviewResultSchema, createStorefrontClient, createStorefrontSession, customerInquiryCategorySchema, customerInquirySchema, deliveryTrackingSchema, guestInfoSchema, loginRequestSchema, memberAddressRequestSchema, memberAddressSchema, memberSchema, myClaimSchema, myOrderItemSchema, myOrderSchema, orderItemStatusSchema, orderShippingAddressSchema, pageInfoSchema, pageSchema, paymentMethodSchema, paymentParamsSchema, paymentStatusSchema, pgParamsSchema, productCardSchema, productDetailSchema, productFacetsSchema, productSortSchema, publicInquirySchema, publicReviewSchema, requestPaymentRequestSchema, reviewSummarySchema, shippingAddressInputSchema, signupRequestSchema, tokenPairSchema, updateCartItemRequestSchema, updateProfileRequestSchema, updateReviewRequestSchema, writableReviewSchema };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sayren/storefront-sdk",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"main": "./dist/index.mjs",
|
|
5
5
|
"types": "./dist/index.d.mts",
|
|
6
6
|
"exports": {
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
"development": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.mts",
|
|
10
10
|
"default": "./dist/index.mjs"
|
|
11
|
+
},
|
|
12
|
+
"./analytics": {
|
|
13
|
+
"development": "./src/analytics/index.ts",
|
|
14
|
+
"types": "./dist/analytics/index.d.mts",
|
|
15
|
+
"default": "./dist/analytics/index.mjs"
|
|
11
16
|
}
|
|
12
17
|
},
|
|
13
18
|
"dependencies": {
|
|
@@ -26,7 +31,7 @@
|
|
|
26
31
|
"license": "UNLICENSED",
|
|
27
32
|
"repository": {
|
|
28
33
|
"type": "git",
|
|
29
|
-
"url": "git+https://github.com/
|
|
34
|
+
"url": "git+https://github.com/avarcorp/sayren.git"
|
|
30
35
|
},
|
|
31
36
|
"files": [
|
|
32
37
|
"dist",
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { safeDecode } from "../analytics-ids";
|
|
2
|
+
import {
|
|
3
|
+
ANALYTICS_SESSION_COOKIE,
|
|
4
|
+
ANALYTICS_VISITOR_COOKIE,
|
|
5
|
+
type AnalyticsBatch,
|
|
6
|
+
type AnalyticsClientEvent,
|
|
7
|
+
} from "../schemas/analytics";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 방문 분석 브라우저 모듈 — `@sayren/storefront-sdk/analytics`.
|
|
11
|
+
*
|
|
12
|
+
* 페이지뷰·상품 조회·목록 노출·검색·체류를 모아 배치로 보낸다. 장바구니·결제·구매는 스토어프론트 API가
|
|
13
|
+
* 서버에서 기록하므로 여기서 보내지 않는다(클라이언트에 `visitorId`·`sessionId`를 넘기면 이어진다).
|
|
14
|
+
*
|
|
15
|
+
* - 식별자는 셀러 도메인의 1st-party 쿠키(`sy_vid` 1년, `sy_sid` 30분 비활동)다. SSR 서버도 같은 쿠키를 읽는다.
|
|
16
|
+
* - 같은 행동은 한 번만 보낸다(StrictMode 이중 실행·리렌더 흡수). 서버도 결정적 id로 한 번 더 거른다.
|
|
17
|
+
* - 동의 전(`pending`)에는 쿠키를 만들지 않고 큐에만 쌓는다. `denied`면 버린다.
|
|
18
|
+
* - localhost·사설 IP에서는 기본으로 보내지 않는다(`debug: true`로 켠다).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type AnalyticsConsent = "granted" | "denied" | "pending";
|
|
22
|
+
|
|
23
|
+
export interface AnalyticsOptions {
|
|
24
|
+
/** 스토어프론트 API 베이스 — 예: `https://api.sayren.app/storefront/v1` */
|
|
25
|
+
baseUrl: string;
|
|
26
|
+
/** 테넌트 스토어 코드 */
|
|
27
|
+
storeCode: string;
|
|
28
|
+
/** 동의 상태. 기본 `pending` — 셀러의 동의 배너가 `setConsent`로 바꾼다 */
|
|
29
|
+
consent?: AnalyticsConsent;
|
|
30
|
+
/** 페이지뷰를 자동으로 보낸다(첫 페이지 + History API 이동). 기본 true */
|
|
31
|
+
autoPageViews?: boolean;
|
|
32
|
+
/** 개발 호스트(localhost 등)에서도 보낸다. 기본 false */
|
|
33
|
+
debug?: boolean;
|
|
34
|
+
/** 쿠키 도메인 — 서브도메인끼리 방문자를 공유하려면 `.myshop.com` */
|
|
35
|
+
cookieDomain?: string;
|
|
36
|
+
/** 배치 전송 간격(ms). 기본 5000 */
|
|
37
|
+
flushIntervalMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AnalyticsTrackInput =
|
|
41
|
+
| { name: "product_view"; productId: string; variantId?: string }
|
|
42
|
+
| { name: "product_list_view"; listId: string; productIds: string[] }
|
|
43
|
+
| { name: "search"; query: string; resultCount: number };
|
|
44
|
+
|
|
45
|
+
export interface Analytics {
|
|
46
|
+
/** 행동 이벤트 — 같은 페이지뷰 안의 같은 행동은 한 번만 보낸다 */
|
|
47
|
+
track(input: AnalyticsTrackInput): void;
|
|
48
|
+
/** 페이지뷰를 직접 보낸다(`autoPageViews: false`일 때) */
|
|
49
|
+
page(): void;
|
|
50
|
+
setConsent(consent: AnalyticsConsent): void;
|
|
51
|
+
/** 방문자·세션 ID — `createStorefrontClient({ visitorId, sessionId })`에 넘긴다. 동의 전·거부면 null */
|
|
52
|
+
ids(): { visitorId: string; sessionId: string } | null;
|
|
53
|
+
/** 큐를 지금 보낸다 */
|
|
54
|
+
flush(): void;
|
|
55
|
+
/** 리스너·타이머를 걷는다 */
|
|
56
|
+
destroy(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 브라우저 의존부 — 테스트는 가짜 환경을 넣는다 */
|
|
60
|
+
export interface AnalyticsEnv {
|
|
61
|
+
now(): number;
|
|
62
|
+
href(): string;
|
|
63
|
+
referrer(): string;
|
|
64
|
+
hostname(): string;
|
|
65
|
+
getCookie(name: string): string | null;
|
|
66
|
+
setCookie(name: string, value: string, maxAgeSec: number, domain?: string): void;
|
|
67
|
+
/** 배치를 보낸다(sendBeacon, 안 되면 fetch keepalive) */
|
|
68
|
+
send(url: string, body: string): void;
|
|
69
|
+
randomId(): string;
|
|
70
|
+
visible(): boolean;
|
|
71
|
+
prerendering(): boolean;
|
|
72
|
+
webdriver(): boolean;
|
|
73
|
+
/** 이벤트 리스너를 달고 떼는 함수를 돌려준다 */
|
|
74
|
+
on(event: "visibilitychange" | "pagehide" | "prerenderingchange", fn: () => void): () => void;
|
|
75
|
+
/** History API 이동(pushState·replaceState·popstate)을 구독한다 */
|
|
76
|
+
onNavigate(fn: () => void): () => void;
|
|
77
|
+
setInterval(fn: () => void, ms: number): () => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const VISITOR_MAX_AGE_SEC = 365 * 24 * 3600;
|
|
81
|
+
const SESSION_MAX_AGE_SEC = 30 * 60;
|
|
82
|
+
/** 세션을 시작한 캠페인 — 다른 캠페인으로 들어오면 새 세션이다 */
|
|
83
|
+
const SESSION_CAMPAIGN_COOKIE = "sy_scmp";
|
|
84
|
+
const MAX_BATCH = 50;
|
|
85
|
+
/** 이벤트 이름당 분당 상한 — 무한 루프 버그가 트래픽을 만들지 못하게 */
|
|
86
|
+
const PER_NAME_PER_MINUTE = 30;
|
|
87
|
+
/**
|
|
88
|
+
* 전체 분당 상한 — 서버의 방문자당 분당 한도(120)보다 낮게 둬, SDK가 보낸 이벤트가 서버 한도에 걸려
|
|
89
|
+
* 조용히 버려지지 않게 한다(이름당 상한만으로는 이름 5종 × 30 = 150까지 갈 수 있다).
|
|
90
|
+
*/
|
|
91
|
+
const TOTAL_PER_MINUTE = 100;
|
|
92
|
+
const MAX_QUEUE = 500;
|
|
93
|
+
|
|
94
|
+
const DEV_HOST =
|
|
95
|
+
/^(localhost|127\.\d+\.\d+\.\d+|\[?::1\]?|0\.0\.0\.0|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$|\.local$/i;
|
|
96
|
+
|
|
97
|
+
type Pending = AnalyticsClientEvent;
|
|
98
|
+
|
|
99
|
+
export function createAnalytics(options: AnalyticsOptions): Analytics {
|
|
100
|
+
if (typeof window === "undefined" || typeof document === "undefined") return noopAnalytics;
|
|
101
|
+
return createAnalyticsWith(options, browserEnv());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 테스트·비표준 런타임용 — 환경을 직접 넣는다 */
|
|
105
|
+
export function createAnalyticsWith(options: AnalyticsOptions, env: AnalyticsEnv): Analytics {
|
|
106
|
+
const endpoint = `${options.baseUrl.replace(/\/+$/, "")}/events`;
|
|
107
|
+
const enabled = options.debug === true || !DEV_HOST.test(env.hostname());
|
|
108
|
+
let consent: AnalyticsConsent = options.consent ?? "pending";
|
|
109
|
+
let queue: Pending[] = [];
|
|
110
|
+
let pageViewId = "";
|
|
111
|
+
let pageKey = "";
|
|
112
|
+
/** 지금 페이지뷰의 URL — SPA 이동 뒤 이전 페이지의 체류를 보낼 때 새 URL과 섞이지 않게 */
|
|
113
|
+
let pageHref = "";
|
|
114
|
+
let visibleSince: number | null = null;
|
|
115
|
+
let visibleMs = 0;
|
|
116
|
+
let engagementSent = false;
|
|
117
|
+
let firstPage = true;
|
|
118
|
+
/**
|
|
119
|
+
* 지금 페이지뷰를 보냈는가 — 상한으로 페이지뷰가 버려졌으면 그 페이지의 상품 조회·목록·검색·체류도 보내지 않는다.
|
|
120
|
+
* 그러지 않으면 존재하지 않는 페이지뷰를 가리키는 이벤트가 남는다.
|
|
121
|
+
*/
|
|
122
|
+
let pageAccepted = false;
|
|
123
|
+
const seen = new Set<string>();
|
|
124
|
+
let rateMinute = -1;
|
|
125
|
+
const perName = new Map<string, number>();
|
|
126
|
+
let totalThisMinute = 0;
|
|
127
|
+
const cleanups: Array<() => void> = [];
|
|
128
|
+
|
|
129
|
+
const campaignOf = (href: string) => {
|
|
130
|
+
try {
|
|
131
|
+
const params = new URL(href).searchParams;
|
|
132
|
+
const source = params.get("utm_source");
|
|
133
|
+
return source ? `${source}|${params.get("utm_campaign") ?? ""}` : null;
|
|
134
|
+
} catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/** 동의가 있을 때만 쿠키를 읽고 만든다. 세션은 이벤트마다 30분 연장한다 */
|
|
140
|
+
const ensureIds = (): { visitorId: string; sessionId: string } | null => {
|
|
141
|
+
if (consent !== "granted") return null;
|
|
142
|
+
let visitorId = env.getCookie(ANALYTICS_VISITOR_COOKIE);
|
|
143
|
+
if (!visitorId) visitorId = env.randomId();
|
|
144
|
+
let sessionId = env.getCookie(ANALYTICS_SESSION_COOKIE);
|
|
145
|
+
const campaign = campaignOf(env.href());
|
|
146
|
+
if (campaign && campaign !== env.getCookie(SESSION_CAMPAIGN_COOKIE)) {
|
|
147
|
+
sessionId = null; // 새 캠페인으로 들어왔다 — 새 세션
|
|
148
|
+
env.setCookie(SESSION_CAMPAIGN_COOKIE, campaign, SESSION_MAX_AGE_SEC, options.cookieDomain);
|
|
149
|
+
}
|
|
150
|
+
if (!sessionId) sessionId = env.randomId();
|
|
151
|
+
env.setCookie(ANALYTICS_VISITOR_COOKIE, visitorId, VISITOR_MAX_AGE_SEC, options.cookieDomain);
|
|
152
|
+
env.setCookie(ANALYTICS_SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SEC, options.cookieDomain);
|
|
153
|
+
return { visitorId, sessionId };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const allowRate = (name: string) => {
|
|
157
|
+
const minute = Math.floor(env.now() / 60_000);
|
|
158
|
+
if (minute !== rateMinute) {
|
|
159
|
+
rateMinute = minute;
|
|
160
|
+
perName.clear();
|
|
161
|
+
totalThisMinute = 0;
|
|
162
|
+
}
|
|
163
|
+
const used = perName.get(name) ?? 0;
|
|
164
|
+
if (used >= PER_NAME_PER_MINUTE || totalThisMinute >= TOTAL_PER_MINUTE) return false;
|
|
165
|
+
perName.set(name, used + 1);
|
|
166
|
+
totalThisMinute += 1;
|
|
167
|
+
return true;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** 큐에 넣었으면 true — 중복·상한·거부로 버리면 false */
|
|
171
|
+
const push = (event: Pending, dedupeKey: string): boolean => {
|
|
172
|
+
if (!enabled || consent === "denied") return false;
|
|
173
|
+
if (seen.has(dedupeKey) || !allowRate(event.name)) return false;
|
|
174
|
+
seen.add(dedupeKey);
|
|
175
|
+
if (queue.length >= MAX_QUEUE) queue.shift();
|
|
176
|
+
queue.push(event);
|
|
177
|
+
if (queue.length >= MAX_BATCH) flush();
|
|
178
|
+
return true;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const flush = () => {
|
|
182
|
+
if (!enabled || queue.length === 0) return;
|
|
183
|
+
const ids = ensureIds();
|
|
184
|
+
if (!ids) return; // 동의 전 — 큐에 둔다
|
|
185
|
+
while (queue.length > 0) {
|
|
186
|
+
const events = queue.splice(0, MAX_BATCH) as AnalyticsClientEvent[];
|
|
187
|
+
const batch: AnalyticsBatch = {
|
|
188
|
+
storeCode: options.storeCode,
|
|
189
|
+
...ids,
|
|
190
|
+
events,
|
|
191
|
+
...(env.webdriver() ? { context: { webdriver: true } } : {}),
|
|
192
|
+
};
|
|
193
|
+
env.send(endpoint, JSON.stringify(batch));
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const occurredAt = () => new Date(env.now()).toISOString();
|
|
198
|
+
|
|
199
|
+
/** 지금 페이지의 체류를 한 번 보낸다(이동·숨김·닫힘 중 먼저 오는 때) */
|
|
200
|
+
const endPage = () => {
|
|
201
|
+
if (!pageViewId || engagementSent || !pageAccepted) return;
|
|
202
|
+
if (visibleSince !== null) {
|
|
203
|
+
visibleMs += env.now() - visibleSince;
|
|
204
|
+
visibleSince = env.visible() ? env.now() : null;
|
|
205
|
+
}
|
|
206
|
+
engagementSent = true;
|
|
207
|
+
push(
|
|
208
|
+
{
|
|
209
|
+
name: "page_engagement",
|
|
210
|
+
occurredAt: occurredAt(),
|
|
211
|
+
url: pageHref,
|
|
212
|
+
pageViewId,
|
|
213
|
+
visibleMs: Math.max(0, Math.round(visibleMs)),
|
|
214
|
+
},
|
|
215
|
+
`engagement:${pageViewId}`,
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const startPage = () => {
|
|
220
|
+
const href = env.href();
|
|
221
|
+
let key = href;
|
|
222
|
+
try {
|
|
223
|
+
const url = new URL(href);
|
|
224
|
+
key = `${url.pathname}${url.search}`; // 해시만 바뀐 이동은 새 페이지가 아니다
|
|
225
|
+
} catch {
|
|
226
|
+
// 그대로 쓴다
|
|
227
|
+
}
|
|
228
|
+
if (key === pageKey) return;
|
|
229
|
+
endPage();
|
|
230
|
+
pageKey = key;
|
|
231
|
+
pageHref = href;
|
|
232
|
+
pageViewId = env.randomId();
|
|
233
|
+
visibleMs = 0;
|
|
234
|
+
visibleSince = env.visible() ? env.now() : null;
|
|
235
|
+
engagementSent = false;
|
|
236
|
+
const referrer = firstPage ? env.referrer() : "";
|
|
237
|
+
pageAccepted = push(
|
|
238
|
+
{
|
|
239
|
+
name: "page_view",
|
|
240
|
+
occurredAt: occurredAt(),
|
|
241
|
+
url: href,
|
|
242
|
+
pageViewId,
|
|
243
|
+
...(referrer ? { referrer } : {}),
|
|
244
|
+
},
|
|
245
|
+
`page:${pageViewId}`,
|
|
246
|
+
);
|
|
247
|
+
// 외부 유입(리퍼러)은 실제로 보낸 첫 페이지뷰에만 싣는다 — 거부 중 버린 페이지뷰가 가져가지 않게
|
|
248
|
+
if (pageAccepted) firstPage = false;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/** 프리렌더·숨은 탭은 화면에 보이는 순간 첫 페이지뷰를 보낸다 */
|
|
252
|
+
const startWhenVisible = () => {
|
|
253
|
+
if (!env.prerendering() && env.visible()) {
|
|
254
|
+
startPage();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const tryStart = () => {
|
|
258
|
+
if (!pageViewId && !env.prerendering() && env.visible()) startPage();
|
|
259
|
+
};
|
|
260
|
+
cleanups.push(env.on("prerenderingchange", tryStart), env.on("visibilitychange", tryStart));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
cleanups.push(
|
|
264
|
+
env.on("visibilitychange", () => {
|
|
265
|
+
if (env.visible()) {
|
|
266
|
+
if (pageViewId && visibleSince === null) visibleSince = env.now();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
endPage();
|
|
270
|
+
flush();
|
|
271
|
+
}),
|
|
272
|
+
env.on("pagehide", () => {
|
|
273
|
+
endPage();
|
|
274
|
+
flush();
|
|
275
|
+
}),
|
|
276
|
+
env.setInterval(flush, options.flushIntervalMs ?? 5_000),
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
if (options.autoPageViews !== false) {
|
|
280
|
+
startWhenVisible();
|
|
281
|
+
cleanups.push(env.onNavigate(startPage));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
track(input) {
|
|
286
|
+
if (!pageViewId) startPage();
|
|
287
|
+
if (!pageAccepted) return; // 페이지뷰가 버려진 페이지의 행동은 보내지 않는다
|
|
288
|
+
const common = { occurredAt: occurredAt(), url: env.href(), pageViewId };
|
|
289
|
+
switch (input.name) {
|
|
290
|
+
case "product_view":
|
|
291
|
+
push(
|
|
292
|
+
{
|
|
293
|
+
...common,
|
|
294
|
+
name: "product_view",
|
|
295
|
+
productId: input.productId,
|
|
296
|
+
...(input.variantId ? { variantId: input.variantId } : {}),
|
|
297
|
+
},
|
|
298
|
+
`product:${pageViewId}:${input.productId}`,
|
|
299
|
+
);
|
|
300
|
+
return;
|
|
301
|
+
case "product_list_view":
|
|
302
|
+
push(
|
|
303
|
+
{
|
|
304
|
+
...common,
|
|
305
|
+
name: "product_list_view",
|
|
306
|
+
listId: input.listId,
|
|
307
|
+
productIds: input.productIds.slice(0, 100),
|
|
308
|
+
},
|
|
309
|
+
`list:${pageViewId}:${input.listId}`,
|
|
310
|
+
);
|
|
311
|
+
return;
|
|
312
|
+
case "search":
|
|
313
|
+
push(
|
|
314
|
+
{
|
|
315
|
+
...common,
|
|
316
|
+
name: "search",
|
|
317
|
+
query: input.query.slice(0, 200),
|
|
318
|
+
resultCount: input.resultCount,
|
|
319
|
+
},
|
|
320
|
+
`search:${pageViewId}:${input.query.trim().toLowerCase()}`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
page: startPage,
|
|
325
|
+
setConsent(next) {
|
|
326
|
+
consent = next;
|
|
327
|
+
if (next === "denied") queue = [];
|
|
328
|
+
if (next === "granted") {
|
|
329
|
+
// 거부 중에 연 페이지는 페이지뷰가 없다 — 동의한 지금 그 페이지를 새로 센다
|
|
330
|
+
if (pageViewId && !pageAccepted && options.autoPageViews !== false) {
|
|
331
|
+
pageKey = "";
|
|
332
|
+
startPage();
|
|
333
|
+
}
|
|
334
|
+
flush();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
ids: () => (enabled ? ensureIds() : null),
|
|
338
|
+
flush,
|
|
339
|
+
destroy() {
|
|
340
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const noopAnalytics: Analytics = {
|
|
346
|
+
track() {},
|
|
347
|
+
page() {},
|
|
348
|
+
setConsent() {},
|
|
349
|
+
ids: () => null,
|
|
350
|
+
flush() {},
|
|
351
|
+
destroy() {},
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
function randomId(): string {
|
|
355
|
+
const bytes = new Uint8Array(16);
|
|
356
|
+
crypto.getRandomValues(bytes);
|
|
357
|
+
let text = "";
|
|
358
|
+
for (const byte of bytes) text += String.fromCharCode(byte);
|
|
359
|
+
return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function browserEnv(): AnalyticsEnv {
|
|
363
|
+
return {
|
|
364
|
+
now: () => Date.now(),
|
|
365
|
+
href: () => location.href,
|
|
366
|
+
referrer: () => document.referrer,
|
|
367
|
+
hostname: () => location.hostname,
|
|
368
|
+
getCookie(name) {
|
|
369
|
+
for (const part of document.cookie.split(";")) {
|
|
370
|
+
const [key, ...rest] = part.trim().split("=");
|
|
371
|
+
if (key === name) return safeDecode(rest.join("="));
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
},
|
|
375
|
+
setCookie(name, value, maxAgeSec, domain) {
|
|
376
|
+
// biome-ignore lint/suspicious/noDocumentCookie: 방문 식별자는 SSR 서버도 읽어야 해서 1st-party 쿠키에 둔다
|
|
377
|
+
document.cookie = [
|
|
378
|
+
`${name}=${encodeURIComponent(value)}`,
|
|
379
|
+
"path=/",
|
|
380
|
+
`max-age=${maxAgeSec}`,
|
|
381
|
+
"samesite=lax",
|
|
382
|
+
...(domain ? [`domain=${domain}`] : []),
|
|
383
|
+
...(location.protocol === "https:" ? ["secure"] : []),
|
|
384
|
+
].join("; ");
|
|
385
|
+
},
|
|
386
|
+
send(url, body) {
|
|
387
|
+
// text/plain은 CORS 프리플라이트가 없다. 페이지를 떠나는 순간에도 전송된다
|
|
388
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
389
|
+
if (typeof navigator.sendBeacon === "function" && navigator.sendBeacon(url, blob)) return;
|
|
390
|
+
void fetch(url, {
|
|
391
|
+
method: "POST",
|
|
392
|
+
body,
|
|
393
|
+
keepalive: true,
|
|
394
|
+
credentials: "omit",
|
|
395
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
396
|
+
}).catch(() => {});
|
|
397
|
+
},
|
|
398
|
+
randomId,
|
|
399
|
+
visible: () => document.visibilityState !== "hidden",
|
|
400
|
+
prerendering: () => (document as Document & { prerendering?: boolean }).prerendering === true,
|
|
401
|
+
webdriver: () => navigator.webdriver === true,
|
|
402
|
+
on(event, fn) {
|
|
403
|
+
const target: EventTarget = event === "pagehide" ? window : document;
|
|
404
|
+
target.addEventListener(event, fn);
|
|
405
|
+
return () => target.removeEventListener(event, fn);
|
|
406
|
+
},
|
|
407
|
+
onNavigate(fn) {
|
|
408
|
+
// pushState·replaceState는 이벤트가 없어 감싼다. 원래 함수는 해제 때 되돌린다
|
|
409
|
+
const push = history.pushState;
|
|
410
|
+
const replace = history.replaceState;
|
|
411
|
+
history.pushState = function (...args: Parameters<History["pushState"]>) {
|
|
412
|
+
push.apply(this, args);
|
|
413
|
+
queueMicrotask(fn);
|
|
414
|
+
};
|
|
415
|
+
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
|
|
416
|
+
replace.apply(this, args);
|
|
417
|
+
queueMicrotask(fn);
|
|
418
|
+
};
|
|
419
|
+
window.addEventListener("popstate", fn);
|
|
420
|
+
return () => {
|
|
421
|
+
history.pushState = push;
|
|
422
|
+
history.replaceState = replace;
|
|
423
|
+
window.removeEventListener("popstate", fn);
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
setInterval(fn, ms) {
|
|
427
|
+
const id = window.setInterval(fn, ms);
|
|
428
|
+
return () => window.clearInterval(id);
|
|
429
|
+
},
|
|
430
|
+
};
|
|
431
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANALYTICS_SESSION_COOKIE,
|
|
3
|
+
ANALYTICS_VISITOR_COOKIE,
|
|
4
|
+
analyticsIdSchema,
|
|
5
|
+
} from "./schemas/analytics";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 요청의 Cookie 헤더에서 방문자·세션 ID를 읽는다 — SSR 서버가 스토어프론트 API를 부를 때 쓴다.
|
|
9
|
+
* 브라우저 모듈(`@sayren/storefront-sdk/analytics`)이 셀러 도메인에 남긴 쿠키다. 형식이 틀린 값은 버린다.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* const ids = analyticsIdsFromCookie(request.headers.get("cookie"));
|
|
13
|
+
* createStorefrontClient({ baseUrl, storeCode, ...ids });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function analyticsIdsFromCookie(cookieHeader: string | null | undefined): {
|
|
17
|
+
visitorId?: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
} {
|
|
20
|
+
if (!cookieHeader) return {};
|
|
21
|
+
const pick = (name: string) => {
|
|
22
|
+
for (const part of cookieHeader.split(";")) {
|
|
23
|
+
const [key, ...rest] = part.trim().split("=");
|
|
24
|
+
if (key !== name) continue;
|
|
25
|
+
const value = safeDecode(rest.join("="));
|
|
26
|
+
return value !== null && analyticsIdSchema.safeParse(value).success ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
const visitorId = pick(ANALYTICS_VISITOR_COOKIE);
|
|
31
|
+
const sessionId = pick(ANALYTICS_SESSION_COOKIE);
|
|
32
|
+
return { ...(visitorId ? { visitorId } : {}), ...(sessionId ? { sessionId } : {}) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 잘못된 퍼센트 인코딩(`%E0%A4`)은 URIError를 던진다 — 쿠키 하나 때문에 SSR 전체가 500이 되지 않게 버린다 */
|
|
36
|
+
export function safeDecode(value: string): string | null {
|
|
37
|
+
try {
|
|
38
|
+
return decodeURIComponent(value);
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { createHttp, path, type Query, resolveToken, type TokenSource } from "./http";
|
|
3
|
+
import { ANALYTICS_SESSION_HEADER, ANALYTICS_VISITOR_HEADER } from "./schemas/analytics";
|
|
3
4
|
import type { LoginRequest, SignupRequest } from "./schemas/auth";
|
|
4
5
|
import { loginRequestSchema, signupRequestSchema, tokenPairSchema } from "./schemas/auth";
|
|
5
6
|
import {
|
|
@@ -85,6 +86,13 @@ export interface StorefrontClientOptions {
|
|
|
85
86
|
auth?: { accessToken?: string };
|
|
86
87
|
/** 비회원 장바구니 토큰 */
|
|
87
88
|
cartToken?: TokenSource;
|
|
89
|
+
/**
|
|
90
|
+
* 방문 분석 식별자 — 방문자·세션 ID(`sy_vid`·`sy_sid` 쿠키 값). 실으면 서버가 장바구니 담기·결제 시작·구매를
|
|
91
|
+
* 이 방문과 이어 기록한다. 브라우저는 `@sayren/storefront-sdk/analytics`의 `analytics.ids()`, SSR은
|
|
92
|
+
* `analyticsIdsFromCookie(request.headers.get("cookie"))`로 얻는다. 함수로 전달하면 매 요청마다 재평가.
|
|
93
|
+
*/
|
|
94
|
+
visitorId?: TokenSource;
|
|
95
|
+
sessionId?: TokenSource;
|
|
88
96
|
/** 서버가 새 X-Cart-Token을 발급했을 때 호출 */
|
|
89
97
|
onCartToken?: (token: string) => void;
|
|
90
98
|
/**
|
|
@@ -126,6 +134,10 @@ export function createStorefrontClient(options: StorefrontClientOptions) {
|
|
|
126
134
|
if (accessToken) headers.authorization = `Bearer ${accessToken}`;
|
|
127
135
|
const cartToken = resolveToken(options.cartToken);
|
|
128
136
|
if (cartToken) headers["x-cart-token"] = cartToken;
|
|
137
|
+
const visitorId = resolveToken(options.visitorId);
|
|
138
|
+
if (visitorId) headers[ANALYTICS_VISITOR_HEADER] = visitorId;
|
|
139
|
+
const sessionId = resolveToken(options.sessionId);
|
|
140
|
+
if (sessionId) headers[ANALYTICS_SESSION_HEADER] = sessionId;
|
|
129
141
|
return headers;
|
|
130
142
|
},
|
|
131
143
|
onResponse: (response) => {
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export { analyticsIdsFromCookie } from "./analytics-ids";
|
|
1
2
|
export type { StorefrontClient, StorefrontClientOptions } from "./client";
|
|
2
3
|
export { createStorefrontClient } from "./client";
|
|
3
4
|
export type { Query, TokenSource } from "./http";
|
|
4
5
|
export { ApiError, buildQuery } from "./http";
|
|
6
|
+
export * from "./schemas/analytics";
|
|
5
7
|
export * from "./schemas/auth";
|
|
6
8
|
export * from "./schemas/cart";
|
|
7
9
|
export * from "./schemas/catalog";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 방문 분석 이벤트 계약 — 브라우저 모듈(`@sayren/storefront-sdk/analytics`)과 수집 엔드포인트
|
|
5
|
+
* (`POST /storefront/v1/events`)가 같은 스키마를 쓴다.
|
|
6
|
+
*
|
|
7
|
+
* 행동 이벤트(페이지뷰·상품 조회·목록 노출·검색·체류)는 브라우저가 보낸다. 거래 이벤트(장바구니·결제 시작·구매)는
|
|
8
|
+
* 스토어프론트 API가 요청을 처리하면서 서버에서 기록하므로 브라우저가 보내지 않는다 — 매출·전환은 조작이나
|
|
9
|
+
* 누락이 없는 서버 데이터로 계산한다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 방문자·세션 식별자 — SDK가 만드는 URL-safe 난수 */
|
|
13
|
+
export const analyticsIdSchema = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/);
|
|
14
|
+
|
|
15
|
+
/** 방문자 ID 쿠키(1년) · 세션 ID 쿠키(30분 비활동) 이름. SSR 서버도 같은 이름으로 읽는다 */
|
|
16
|
+
export const ANALYTICS_VISITOR_COOKIE = "sy_vid";
|
|
17
|
+
export const ANALYTICS_SESSION_COOKIE = "sy_sid";
|
|
18
|
+
|
|
19
|
+
/** 스토어프론트 API 요청에 실어 서버 거래 이벤트와 방문자·세션을 잇는 헤더 */
|
|
20
|
+
export const ANALYTICS_VISITOR_HEADER = "x-sayren-visitor";
|
|
21
|
+
export const ANALYTICS_SESSION_HEADER = "x-sayren-session";
|
|
22
|
+
|
|
23
|
+
/** 브라우저가 보내는 이벤트 이름 */
|
|
24
|
+
export const analyticsClientEventNameSchema = z.enum([
|
|
25
|
+
"page_view",
|
|
26
|
+
"page_engagement",
|
|
27
|
+
"product_view",
|
|
28
|
+
"product_list_view",
|
|
29
|
+
"search",
|
|
30
|
+
]);
|
|
31
|
+
export type AnalyticsClientEventName = z.infer<typeof analyticsClientEventNameSchema>;
|
|
32
|
+
|
|
33
|
+
/** 서버가 기록하는 이벤트 이름 */
|
|
34
|
+
export const analyticsServerEventNameSchema = z.enum([
|
|
35
|
+
"add_to_cart",
|
|
36
|
+
"remove_from_cart",
|
|
37
|
+
"begin_checkout",
|
|
38
|
+
"purchase",
|
|
39
|
+
]);
|
|
40
|
+
export type AnalyticsServerEventName = z.infer<typeof analyticsServerEventNameSchema>;
|
|
41
|
+
|
|
42
|
+
export type AnalyticsEventName = AnalyticsClientEventName | AnalyticsServerEventName;
|
|
43
|
+
|
|
44
|
+
const text = (max: number) => z.string().trim().min(1).max(max);
|
|
45
|
+
|
|
46
|
+
const base = {
|
|
47
|
+
/** 발생 시각(ISO 8601). 서버가 수신 시각 ±24시간으로 보정한다 */
|
|
48
|
+
occurredAt: z.iso.datetime({ offset: true }),
|
|
49
|
+
/** 이벤트가 난 페이지의 전체 URL. 서버가 경로와 utm_*만 남기고 나머지 쿼리는 버린다 */
|
|
50
|
+
url: z.url().max(2048),
|
|
51
|
+
/** 한 페이지뷰 안의 이벤트를 묶는 난수. `page_engagement`가 어느 페이지뷰의 체류인지 가리킨다 */
|
|
52
|
+
pageViewId: analyticsIdSchema,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const analyticsClientEventSchema = z.discriminatedUnion("name", [
|
|
56
|
+
z.object({
|
|
57
|
+
...base,
|
|
58
|
+
name: z.literal("page_view"),
|
|
59
|
+
/** document.referrer. 서버는 호스트만 남긴다 */
|
|
60
|
+
referrer: z.url().max(2048).optional(),
|
|
61
|
+
}),
|
|
62
|
+
z.object({
|
|
63
|
+
...base,
|
|
64
|
+
name: z.literal("page_engagement"),
|
|
65
|
+
/** 페이지가 화면에 보인 누적 시간(ms) */
|
|
66
|
+
visibleMs: z
|
|
67
|
+
.number()
|
|
68
|
+
.int()
|
|
69
|
+
.min(0)
|
|
70
|
+
.max(24 * 3_600_000),
|
|
71
|
+
}),
|
|
72
|
+
z.object({
|
|
73
|
+
...base,
|
|
74
|
+
name: z.literal("product_view"),
|
|
75
|
+
productId: text(64),
|
|
76
|
+
variantId: text(64).optional(),
|
|
77
|
+
}),
|
|
78
|
+
z.object({
|
|
79
|
+
...base,
|
|
80
|
+
name: z.literal("product_list_view"),
|
|
81
|
+
/** 목록 식별자 — 예: `category:cat_123`, `search`, `home:new` */
|
|
82
|
+
listId: text(128),
|
|
83
|
+
productIds: z.array(text(64)).max(100),
|
|
84
|
+
}),
|
|
85
|
+
z.object({
|
|
86
|
+
...base,
|
|
87
|
+
name: z.literal("search"),
|
|
88
|
+
query: text(200),
|
|
89
|
+
resultCount: z.number().int().min(0),
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
export type AnalyticsClientEvent = z.infer<typeof analyticsClientEventSchema>;
|
|
93
|
+
|
|
94
|
+
/** 한 번에 보내는 배치 — sendBeacon이라 헤더를 못 실어 테넌트·식별자를 본문에 싣는다 */
|
|
95
|
+
export const analyticsBatchSchema = z.object({
|
|
96
|
+
storeCode: text(64),
|
|
97
|
+
visitorId: analyticsIdSchema,
|
|
98
|
+
sessionId: analyticsIdSchema,
|
|
99
|
+
events: z.array(analyticsClientEventSchema).min(1).max(50),
|
|
100
|
+
/** 봇 판정 보조 신호 */
|
|
101
|
+
context: z
|
|
102
|
+
.object({
|
|
103
|
+
/** navigator.webdriver — 자동화 브라우저 */
|
|
104
|
+
webdriver: z.boolean().optional(),
|
|
105
|
+
})
|
|
106
|
+
.optional(),
|
|
107
|
+
});
|
|
108
|
+
export type AnalyticsBatch = z.infer<typeof analyticsBatchSchema>;
|
|
109
|
+
|
|
110
|
+
/** 배치 본문 상한(바이트) — 서버가 넘는 요청을 거부한다 */
|
|
111
|
+
export const ANALYTICS_MAX_BATCH_BYTES = 32 * 1024;
|