@askdialog/dialog-sdk 2.5.0 → 2.6.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/README.md +46 -22
- package/dist/Dialog.d.ts +12 -0
- package/dist/Dialog.d.ts.map +1 -1
- package/dist/Dialog.js +21 -0
- package/dist/EventsHandler.d.ts.map +1 -1
- package/dist/EventsHandler.js +2 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.d.ts +2 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.d.ts.map +1 -0
- package/dist/__tests__/dialogSearchAnalytics.spec.js +90 -0
- package/dist/__tests__/searchController.spec.d.ts +2 -0
- package/dist/__tests__/searchController.spec.d.ts.map +1 -0
- package/dist/__tests__/searchController.spec.js +254 -0
- package/dist/__tests__/searchControllerAttribution.spec.d.ts +2 -0
- package/dist/__tests__/searchControllerAttribution.spec.d.ts.map +1 -0
- package/dist/__tests__/searchControllerAttribution.spec.js +176 -0
- package/dist/__tests__/searchImpressions.spec.d.ts +2 -0
- package/dist/__tests__/searchImpressions.spec.d.ts.map +1 -0
- package/dist/__tests__/searchImpressions.spec.js +218 -0
- package/dist/config/config.development.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/searchController.d.ts +3 -0
- package/dist/searchController.d.ts.map +1 -0
- package/dist/searchController.js +164 -0
- package/dist/types/events.d.ts +5 -2
- package/dist/types/events.d.ts.map +1 -1
- package/dist/types/events.js +2 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +2 -0
- package/dist/types/searchAnalytics.d.ts +30 -0
- package/dist/types/searchAnalytics.d.ts.map +1 -0
- package/dist/types/searchAnalytics.js +10 -0
- package/dist/types/searchController.d.ts +80 -0
- package/dist/types/searchController.d.ts.map +1 -0
- package/dist/types/searchController.js +8 -0
- package/dist/utils/searchControllerAnalytics.d.ts +14 -0
- package/dist/utils/searchControllerAnalytics.d.ts.map +1 -0
- package/dist/utils/searchControllerAnalytics.js +54 -0
- package/dist/utils/searchImpressions.d.ts +21 -0
- package/dist/utils/searchImpressions.d.ts.map +1 -0
- package/dist/utils/searchImpressions.js +126 -0
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const SEARCH_SURFACES: readonly ["autocomplete", "search_page", "smart_discovery", "ai_bar"];
|
|
2
|
+
export type SearchSurface = (typeof SEARCH_SURFACES)[number];
|
|
3
|
+
export interface SearchAnalyticsEnvelope {
|
|
4
|
+
query_id: string;
|
|
5
|
+
/** Where results are displayed — not the integration technology. */
|
|
6
|
+
surface: SearchSurface;
|
|
7
|
+
/** 1-based results page. */
|
|
8
|
+
page: number;
|
|
9
|
+
/** Total results for the query, not for the page. */
|
|
10
|
+
total_hits: number;
|
|
11
|
+
/** Code points of the trimmed query. */
|
|
12
|
+
query_length: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SearchResultItem {
|
|
15
|
+
product_id: string;
|
|
16
|
+
position: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ViewSearchResultsParams extends SearchAnalyticsEnvelope {
|
|
19
|
+
items: SearchResultItem[];
|
|
20
|
+
}
|
|
21
|
+
export interface SelectSearchResultParams extends SearchAnalyticsEnvelope {
|
|
22
|
+
items: [SearchResultItem];
|
|
23
|
+
}
|
|
24
|
+
export interface ViewSearchResultsEventPayload extends ViewSearchResultsParams {
|
|
25
|
+
userId?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SelectSearchResultEventPayload extends SelectSearchResultParams {
|
|
28
|
+
userId?: string;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=searchAnalytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchAnalytics.d.ts","sourceRoot":"","sources":["../../src/types/searchAnalytics.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,eAAe,uEAKlB,CAAC;AAEX,MAAM,MAAM,aAAa,GAAG,CAAC,OAAO,eAAe,CAAC,CAAC,MAAM,CAAC,CAAC;AAQ7D,MAAM,WAAW,uBAAuB;IACtC,QAAQ,EAAE,MAAM,CAAC;IACjB,oEAAoE;IACpE,OAAO,EAAE,aAAa,CAAC;IACvB,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAC;IACnB,wCAAwC;IACxC,YAAY,EAAE,MAAM,CAAC;CACtB;AAID,MAAM,WAAW,gBAAgB;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAMD,MAAM,WAAW,uBAAwB,SAAQ,uBAAuB;IACtE,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAGD,MAAM,WAAW,wBAAyB,SAAQ,uBAAuB;IACvE,KAAK,EAAE,CAAC,gBAAgB,CAAC,CAAC;CAC3B;AAKD,MAAM,WAAW,6BAA8B,SAAQ,uBAAuB;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,8BACf,SAAQ,wBAAwB;IAChC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Storefront-search analytics contract (DEC-2448), snake_case end to end:
|
|
2
|
+
// these property names ARE the canonical PostHog properties — the host
|
|
3
|
+
// bridge forwards them verbatim (no camelCase mapping), so any rename here
|
|
4
|
+
// is a breaking change of the cross-repo contract.
|
|
5
|
+
export const SEARCH_SURFACES = [
|
|
6
|
+
"autocomplete",
|
|
7
|
+
"search_page",
|
|
8
|
+
"smart_discovery",
|
|
9
|
+
"ai_bar",
|
|
10
|
+
];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { SearchHit, SearchOptions, SearchRequest, SearchResponse } from "./search";
|
|
2
|
+
import { SearchSurface, SelectSearchResultParams, ViewSearchResultsParams } from "./searchAnalytics";
|
|
3
|
+
export declare const SearchStatus: {
|
|
4
|
+
readonly IDLE: "idle";
|
|
5
|
+
readonly LOADING: "loading";
|
|
6
|
+
readonly SUCCESS: "success";
|
|
7
|
+
/** The request succeeded with zero hits — distinct from `success` so views render a no-results state. */
|
|
8
|
+
readonly EMPTY: "empty";
|
|
9
|
+
readonly ERROR: "error";
|
|
10
|
+
};
|
|
11
|
+
export type SearchStatus = (typeof SearchStatus)[keyof typeof SearchStatus];
|
|
12
|
+
export interface SearchControllerState {
|
|
13
|
+
status: SearchStatus;
|
|
14
|
+
/** Last committed (run) query — not the text currently being typed. */
|
|
15
|
+
query: string;
|
|
16
|
+
/** Zero-indexed page of the last run. */
|
|
17
|
+
page: number;
|
|
18
|
+
/** Last landed response; kept while the next page loads, cleared on error. */
|
|
19
|
+
response?: SearchResponse;
|
|
20
|
+
error?: unknown;
|
|
21
|
+
}
|
|
22
|
+
export type SearchFunction = (request: SearchRequest, options?: SearchOptions) => Promise<SearchResponse>;
|
|
23
|
+
/**
|
|
24
|
+
* Analytics sinks for the DEC-2448 storefront-search contract — wire the two
|
|
25
|
+
* track methods to the `Dialog` instance. Impressions (viewport batching,
|
|
26
|
+
* dedup, click-forced) and the zero-items no-results event are owned by the
|
|
27
|
+
* controller; integrations only declare where results are displayed.
|
|
28
|
+
*/
|
|
29
|
+
export interface SearchControllerAnalytics {
|
|
30
|
+
surface: SearchSurface;
|
|
31
|
+
trackViewSearchResults: (params: ViewSearchResultsParams) => void;
|
|
32
|
+
trackSelectSearchResult: (params: SelectSearchResultParams) => void;
|
|
33
|
+
}
|
|
34
|
+
export interface SearchControllerOptions {
|
|
35
|
+
/** The bound `dialog.search` function. */
|
|
36
|
+
search: SearchFunction;
|
|
37
|
+
analytics: SearchControllerAnalytics;
|
|
38
|
+
/**
|
|
39
|
+
* Platform navigation adapter, called by `selectResult` AFTER attribution
|
|
40
|
+
* is recorded, with the product URL of the selected hit. Omit it when the
|
|
41
|
+
* rendering layer navigates natively (plain `<a href>`): attribution still
|
|
42
|
+
* fires, the events are designed to survive navigation.
|
|
43
|
+
*/
|
|
44
|
+
navigate?: (url: string, hit: SearchHit) => void;
|
|
45
|
+
debounceMs?: number;
|
|
46
|
+
hitsPerPage?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Framework-agnostic stateful search behavior around the stateless
|
|
50
|
+
* `dialog.search()` transport: debounce, cancellation, stale-response
|
|
51
|
+
* protection, pagination, retry and DEC-2448 attribution. Rendering and
|
|
52
|
+
* routing stay in the integration (raw JavaScript, React, Vue, Shopify).
|
|
53
|
+
*/
|
|
54
|
+
export interface SearchController {
|
|
55
|
+
/** Debounced entry point for keystrokes. */
|
|
56
|
+
setQuery(rawQuery: string): void;
|
|
57
|
+
/** Immediate (non-debounced) submission, e.g. Enter or a search button. */
|
|
58
|
+
submit(rawQuery: string): void;
|
|
59
|
+
/** Immediate pagination of the current results (zero-indexed). */
|
|
60
|
+
setPage(page: number): void;
|
|
61
|
+
/** Re-run the failed request; no-op unless the state is `error`. */
|
|
62
|
+
retry(): void;
|
|
63
|
+
/**
|
|
64
|
+
* Watch the rendered element of `state.response.hits[index]` for a
|
|
65
|
+
* viewport impression. Call it for every result element after rendering.
|
|
66
|
+
*/
|
|
67
|
+
observeResult(element: Element, index: number): void;
|
|
68
|
+
/**
|
|
69
|
+
* Record selection attribution for `state.response.hits[index]` (forced
|
|
70
|
+
* impression + select event), then hand navigation to the `navigate`
|
|
71
|
+
* adapter. Call it on click AND middle-click/cmd+click.
|
|
72
|
+
*/
|
|
73
|
+
selectResult(index: number): void;
|
|
74
|
+
/** Register a state listener; returns its unsubscribe function. */
|
|
75
|
+
subscribe(listener: (state: SearchControllerState) => void): () => void;
|
|
76
|
+
getState(): SearchControllerState;
|
|
77
|
+
/** Cancel everything in flight and detach listeners/observers. */
|
|
78
|
+
dispose(): void;
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=searchController.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchController.d.ts","sourceRoot":"","sources":["../../src/types/searchController.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,aAAa,EACb,cAAc,EACf,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,aAAa,EACb,wBAAwB,EACxB,uBAAuB,EACxB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,YAAY;;;;IAIvB,yGAAyG;;;CAGjG,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,YAAY,CAAC,CAAC,MAAM,OAAO,YAAY,CAAC,CAAC;AAE5E,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,YAAY,CAAC;IACrB,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAC;IACb,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,cAAc,GAAG,CAC3B,OAAO,EAAE,aAAa,EACtB,OAAO,CAAC,EAAE,aAAa,KACpB,OAAO,CAAC,cAAc,CAAC,CAAC;AAE7B;;;;;GAKG;AACH,MAAM,WAAW,yBAAyB;IACxC,OAAO,EAAE,aAAa,CAAC;IACvB,sBAAsB,EAAE,CAAC,MAAM,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAClE,uBAAuB,EAAE,CAAC,MAAM,EAAE,wBAAwB,KAAK,IAAI,CAAC;CACrE;AAED,MAAM,WAAW,uBAAuB;IACtC,0CAA0C;IAC1C,MAAM,EAAE,cAAc,CAAC;IACvB,SAAS,EAAE,yBAAyB,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,KAAK,IAAI,CAAC;IACjD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,MAAM,WAAW,gBAAgB;IAC/B,4CAA4C;IAC5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,2EAA2E;IAC3E,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,kEAAkE;IAClE,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,oEAAoE;IACpE,KAAK,IAAI,IAAI,CAAC;IACd;;;OAGG;IACH,aAAa,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrD;;;;OAIG;IACH,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,mEAAmE;IACnE,SAAS,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC;IACxE,QAAQ,IAAI,qBAAqB,CAAC;IAClC,kEAAkE;IAClE,OAAO,IAAI,IAAI,CAAC;CACjB"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SearchResponse } from "../types/search";
|
|
2
|
+
import { SearchControllerAnalytics } from "../types/searchController";
|
|
3
|
+
/**
|
|
4
|
+
* Internal DEC-2448 wiring of the search controller: envelope computation,
|
|
5
|
+
* viewport impressions and selection attribution. Not part of the public API.
|
|
6
|
+
*/
|
|
7
|
+
export interface ControllerAnalyticsBinding {
|
|
8
|
+
onResponse(response: SearchResponse): void;
|
|
9
|
+
observeResult(element: Element, response: SearchResponse, index: number): void;
|
|
10
|
+
select(response: SearchResponse, index: number): void;
|
|
11
|
+
dispose(): void;
|
|
12
|
+
}
|
|
13
|
+
export declare function createControllerAnalytics(analytics: SearchControllerAnalytics): ControllerAnalyticsBinding;
|
|
14
|
+
//# sourceMappingURL=searchControllerAnalytics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchControllerAnalytics.d.ts","sourceRoot":"","sources":["../../src/utils/searchControllerAnalytics.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAKjD,OAAO,EAAE,yBAAyB,EAAE,MAAM,2BAA2B,CAAC;AAMtE;;;GAGG;AACH,MAAM,WAAW,0BAA0B;IACzC,UAAU,CAAC,QAAQ,EAAE,cAAc,GAAG,IAAI,CAAC;IAC3C,aAAa,CACX,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,cAAc,EACxB,KAAK,EAAE,MAAM,GACZ,IAAI,CAAC;IACR,MAAM,CAAC,QAAQ,EAAE,cAAc,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACtD,OAAO,IAAI,IAAI,CAAC;CACjB;AAWD,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,yBAAyB,GACnC,0BAA0B,CAkD5B"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createSearchImpressionTracker, } from "./searchImpressions";
|
|
2
|
+
const resultItem = (response, index) => ({
|
|
3
|
+
product_id: response.hits[index].product.id,
|
|
4
|
+
// 1-based and absolute across pages (page 2, first item, 20/page → 21).
|
|
5
|
+
position: response.page * response.hitsPerPage + index + 1,
|
|
6
|
+
});
|
|
7
|
+
export function createControllerAnalytics(analytics) {
|
|
8
|
+
let impressions;
|
|
9
|
+
let envelope;
|
|
10
|
+
// Lazy: the tracker needs window/IntersectionObserver, so a controller
|
|
11
|
+
// constructed in a non-browser context stays inert until a response lands.
|
|
12
|
+
const tracker = () => {
|
|
13
|
+
impressions ??= createSearchImpressionTracker({
|
|
14
|
+
emit: analytics.trackViewSearchResults,
|
|
15
|
+
});
|
|
16
|
+
return impressions;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
onResponse(response) {
|
|
20
|
+
envelope = {
|
|
21
|
+
// Straight off the response: the controller resends the previous
|
|
22
|
+
// queryId while the query text is unchanged, so the id is stable
|
|
23
|
+
// across pagination and rotates with the query.
|
|
24
|
+
query_id: response.queryId,
|
|
25
|
+
surface: analytics.surface,
|
|
26
|
+
// Analytics pages are 1-based; the wire response is 0-based.
|
|
27
|
+
page: response.page + 1,
|
|
28
|
+
total_hits: response.nbHits,
|
|
29
|
+
query_length: [...response.query].length,
|
|
30
|
+
};
|
|
31
|
+
tracker().setContext(envelope);
|
|
32
|
+
if (response.nbHits === 0) {
|
|
33
|
+
// A rendered no-results state is the view event with zero items; it
|
|
34
|
+
// shows above the fold, no viewport gating needed.
|
|
35
|
+
analytics.trackViewSearchResults({ ...envelope, items: [] });
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
observeResult(element, response, index) {
|
|
39
|
+
tracker().observe(element, resultItem(response, index));
|
|
40
|
+
},
|
|
41
|
+
select(response, index) {
|
|
42
|
+
if (envelope === undefined) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const item = resultItem(response, index);
|
|
46
|
+
// The click forces the item's impression so CTR by position stays ≤100%.
|
|
47
|
+
tracker().forceImpression(item);
|
|
48
|
+
analytics.trackSelectSearchResult({ ...envelope, items: [item] });
|
|
49
|
+
},
|
|
50
|
+
dispose() {
|
|
51
|
+
impressions?.disconnect();
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { SearchAnalyticsEnvelope, SearchResultItem, ViewSearchResultsParams } from "../types/searchAnalytics";
|
|
2
|
+
export interface SearchImpressionTrackerOptions {
|
|
3
|
+
/** Receives each flushed batch — wire it to `dialog.trackViewSearchResults`. */
|
|
4
|
+
emit: (params: ViewSearchResultsParams) => void;
|
|
5
|
+
visibilityThreshold?: number;
|
|
6
|
+
dwellMs?: number;
|
|
7
|
+
idleFlushMs?: number;
|
|
8
|
+
maxItemsPerEvent?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface SearchImpressionTracker {
|
|
11
|
+
/** Declare the envelope of the response being rendered; flushes the previous batch. */
|
|
12
|
+
setContext(envelope: SearchAnalyticsEnvelope): void;
|
|
13
|
+
/** Watch a rendered result element for viewport impressions. */
|
|
14
|
+
observe(element: Element, item: SearchResultItem): void;
|
|
15
|
+
/** A click counts as an impression, otherwise CTR by position can exceed 100%. */
|
|
16
|
+
forceImpression(item: SearchResultItem): void;
|
|
17
|
+
flush(): void;
|
|
18
|
+
disconnect(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function createSearchImpressionTracker({ emit, visibilityThreshold, dwellMs, idleFlushMs, maxItemsPerEvent, }: SearchImpressionTrackerOptions): SearchImpressionTracker;
|
|
21
|
+
//# sourceMappingURL=searchImpressions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchImpressions.d.ts","sourceRoot":"","sources":["../../src/utils/searchImpressions.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,uBAAuB,EACvB,gBAAgB,EAChB,uBAAuB,EACxB,MAAM,0BAA0B,CAAC;AAWlC,MAAM,WAAW,8BAA8B;IAC7C,gFAAgF;IAChF,IAAI,EAAE,CAAC,MAAM,EAAE,uBAAuB,KAAK,IAAI,CAAC;IAChD,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,uBAAuB;IACtC,uFAAuF;IACvF,UAAU,CAAC,QAAQ,EAAE,uBAAuB,GAAG,IAAI,CAAC;IACpD,gEAAgE;IAChE,OAAO,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACxD,kFAAkF;IAClF,eAAe,CAAC,IAAI,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAC9C,KAAK,IAAI,IAAI,CAAC;IACd,UAAU,IAAI,IAAI,CAAC;CACpB;AAED,wBAAgB,6BAA6B,CAAC,EAC5C,IAAI,EACJ,mBAAkD,EAClD,OAA0B,EAC1B,WAAmC,EACnC,gBAA8C,GAC/C,EAAE,8BAA8B,GAAG,uBAAuB,CAsI1D"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// An item counts as seen after being ≥50% visible for ≥500ms; qualified items
|
|
2
|
+
// are batched and flushed on qualification inactivity, context change,
|
|
3
|
+
// pagehide or the batch cap — in practice 1-3 events per search, not one per
|
|
4
|
+
// item.
|
|
5
|
+
const DEFAULT_VISIBILITY_THRESHOLD = 0.5;
|
|
6
|
+
const DEFAULT_DWELL_MS = 500;
|
|
7
|
+
const DEFAULT_IDLE_FLUSH_MS = 1500;
|
|
8
|
+
const DEFAULT_MAX_ITEMS_PER_EVENT = 100;
|
|
9
|
+
export function createSearchImpressionTracker({ emit, visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD, dwellMs = DEFAULT_DWELL_MS, idleFlushMs = DEFAULT_IDLE_FLUSH_MS, maxItemsPerEvent = DEFAULT_MAX_ITEMS_PER_EVENT, }) {
|
|
10
|
+
let envelope;
|
|
11
|
+
let pending = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const observedItems = new Map();
|
|
14
|
+
const dwellTimers = new Map();
|
|
15
|
+
let idleTimer;
|
|
16
|
+
const flush = () => {
|
|
17
|
+
clearTimeout(idleTimer);
|
|
18
|
+
if (envelope === undefined || pending.length === 0) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
emit({ ...envelope, items: pending });
|
|
22
|
+
pending = [];
|
|
23
|
+
};
|
|
24
|
+
const record = (item) => {
|
|
25
|
+
if (envelope === undefined) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const key = `${envelope.query_id}:${item.product_id}`;
|
|
29
|
+
if (seen.has(key)) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
seen.add(key);
|
|
33
|
+
pending.push(item);
|
|
34
|
+
if (pending.length >= maxItemsPerEvent) {
|
|
35
|
+
flush();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
clearTimeout(idleTimer);
|
|
39
|
+
idleTimer = setTimeout(flush, idleFlushMs);
|
|
40
|
+
};
|
|
41
|
+
const cancelDwell = (element) => {
|
|
42
|
+
const timer = dwellTimers.get(element);
|
|
43
|
+
if (timer !== undefined) {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
dwellTimers.delete(element);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const observer = new IntersectionObserver((entries) => {
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const item = observedItems.get(entry.target);
|
|
51
|
+
if (item === undefined) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (entry.intersectionRatio >= visibilityThreshold) {
|
|
55
|
+
if (!dwellTimers.has(entry.target)) {
|
|
56
|
+
dwellTimers.set(entry.target, setTimeout(() => {
|
|
57
|
+
dwellTimers.delete(entry.target);
|
|
58
|
+
record(item);
|
|
59
|
+
}, dwellMs));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
cancelDwell(entry.target);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}, { threshold: visibilityThreshold });
|
|
67
|
+
const stopObserving = () => {
|
|
68
|
+
observer.disconnect();
|
|
69
|
+
for (const timer of dwellTimers.values()) {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
}
|
|
72
|
+
dwellTimers.clear();
|
|
73
|
+
observedItems.clear();
|
|
74
|
+
};
|
|
75
|
+
// The page can die mid-batch: ship what already qualified. The host bridge
|
|
76
|
+
// captures this last event with a beacon-capable transport.
|
|
77
|
+
const handlePagehide = () => {
|
|
78
|
+
flush();
|
|
79
|
+
};
|
|
80
|
+
// A back/forward-cache restore is a new exposure: re-qualify what is on
|
|
81
|
+
// screen instead of keeping it deduplicated forever. Dwell timers frozen
|
|
82
|
+
// mid-count resume with their remaining delay, so cancel them — the
|
|
83
|
+
// restored exposure must earn its full dwell.
|
|
84
|
+
const handlePageshow = (event) => {
|
|
85
|
+
if (!event.persisted) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
seen.clear();
|
|
89
|
+
for (const element of observedItems.keys()) {
|
|
90
|
+
cancelDwell(element);
|
|
91
|
+
observer.unobserve(element);
|
|
92
|
+
observer.observe(element);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
window.addEventListener("pagehide", handlePagehide);
|
|
96
|
+
window.addEventListener("pageshow", handlePageshow);
|
|
97
|
+
return {
|
|
98
|
+
setContext(nextEnvelope) {
|
|
99
|
+
// Pending impressions belong to the envelope they qualified under.
|
|
100
|
+
flush();
|
|
101
|
+
// Dedup only needs to span one query's lifetime (pagination keeps its
|
|
102
|
+
// query_id): dropping older keys bounds the set across search-as-you-type.
|
|
103
|
+
if (envelope !== undefined &&
|
|
104
|
+
envelope.query_id !== nextEnvelope.query_id) {
|
|
105
|
+
seen.clear();
|
|
106
|
+
}
|
|
107
|
+
envelope = nextEnvelope;
|
|
108
|
+
stopObserving();
|
|
109
|
+
},
|
|
110
|
+
observe(element, item) {
|
|
111
|
+
observedItems.set(element, item);
|
|
112
|
+
observer.observe(element);
|
|
113
|
+
},
|
|
114
|
+
forceImpression(item) {
|
|
115
|
+
record(item);
|
|
116
|
+
flush();
|
|
117
|
+
},
|
|
118
|
+
flush,
|
|
119
|
+
disconnect() {
|
|
120
|
+
flush();
|
|
121
|
+
stopObserving();
|
|
122
|
+
window.removeEventListener("pagehide", handlePagehide);
|
|
123
|
+
window.removeEventListener("pageshow", handlePageshow);
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|