@releval/tracker 1.0.0-bootstrap.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/react.cjs ADDED
@@ -0,0 +1,180 @@
1
+ "use client";
2
+ /*! @releval/tracker | Apache-2.0 | https://github.com/releval/tracker */
3
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
4
+ const require_tracker = require("./tracker.cjs");
5
+ let react = require("react");
6
+ //#region src/react/SearchResults.ts
7
+ const SearchResultsContext = (0, react.createContext)(null);
8
+ /**
9
+ * Declares which search produced the results rendered beneath it - the SPA
10
+ * twin of `data-query-id` on a server-rendered results container. Result
11
+ * components read it with {@link useSearchResults} (and
12
+ * {@link useResultImpression} reads it automatically) instead of threading
13
+ * `queryId` down as a prop.
14
+ *
15
+ * ```tsx
16
+ * <SearchResults queryId={response.query_id} query={q}>
17
+ * {results.map((r, i) => <ResultCard key={r.sku} result={r} ordinal={i + 1} />)}
18
+ * </SearchResults>
19
+ * ```
20
+ */
21
+ function SearchResults({ queryId, query, children }) {
22
+ const value = (0, react.useMemo)(() => ({
23
+ queryId,
24
+ query
25
+ }), [queryId, query]);
26
+ return (0, react.createElement)(SearchResultsContext.Provider, { value }, children);
27
+ }
28
+ /**
29
+ * The current search context, or null outside a {@link SearchResults} (e.g. a
30
+ * card rendered on a home page that did not come from a search).
31
+ */
32
+ function useSearchResults() {
33
+ return (0, react.useContext)(SearchResultsContext);
34
+ }
35
+ //#endregion
36
+ //#region src/react/context.ts
37
+ /**
38
+ * Holds the configured {@link Tracker} for a React tree. Provided by
39
+ * {@link TrackerProvider} and read with {@link useTracker}.
40
+ */
41
+ const TrackerContext = (0, react.createContext)(null);
42
+ //#endregion
43
+ //#region src/react/TrackerProvider.ts
44
+ /**
45
+ * Owns a {@link Tracker}'s lifecycle and exposes it to the tree via context.
46
+ * Starts the tracker on mount and stops it on unmount, so components never
47
+ * import a module singleton and the tracker is torn down cleanly.
48
+ *
49
+ * `options` (and `tracker`) are read ONCE on mount; later prop changes are
50
+ * ignored. Update identity with `useTracker().setUserId(...)` rather than by
51
+ * changing `options.userId`.
52
+ *
53
+ * @example
54
+ * ```tsx
55
+ * <TrackerProvider
56
+ * options={{ application: "my-app", siteId: "...", endpointHost: "..." }}
57
+ * onInit={(t) => t.addEnricher(abTestEnricher)}
58
+ * >
59
+ * <App />
60
+ * </TrackerProvider>
61
+ * ```
62
+ */
63
+ function TrackerProvider(props) {
64
+ const { children, onInit, autoStart = true } = props;
65
+ const [tracker] = (0, react.useState)(() => "tracker" in props && props.tracker ? props.tracker : new require_tracker.Tracker(props.options));
66
+ const onInitRef = (0, react.useRef)(onInit);
67
+ const initialized = (0, react.useRef)(false);
68
+ (0, react.useEffect)(() => {
69
+ if (!autoStart) return;
70
+ if (!initialized.current) {
71
+ var _onInitRef$current;
72
+ initialized.current = true;
73
+ (_onInitRef$current = onInitRef.current) === null || _onInitRef$current === void 0 || _onInitRef$current.call(onInitRef, tracker);
74
+ }
75
+ tracker.start();
76
+ return () => {
77
+ tracker.stop();
78
+ };
79
+ }, [tracker, autoStart]);
80
+ return (0, react.createElement)(TrackerContext.Provider, { value: tracker }, children);
81
+ }
82
+ /**
83
+ * Returns the {@link Tracker} provided by the nearest {@link TrackerProvider}.
84
+ * Throws when called outside a provider.
85
+ */
86
+ function useTracker() {
87
+ const tracker = (0, react.useContext)(TrackerContext);
88
+ if (!tracker) throw new Error("useTracker must be used within a <TrackerProvider>. Wrap your app in <TrackerProvider options={{ application: '...' }}> ... </TrackerProvider>.");
89
+ return tracker;
90
+ }
91
+ //#endregion
92
+ //#region src/react/useResultImpression.ts
93
+ /**
94
+ * Returns a callback ref that reports ONE canonical impression for the result
95
+ * the first time its element becomes visible, attributed to the surrounding
96
+ * {@link SearchResults} context.
97
+ *
98
+ * Fires once per query, not once per component instance: a new `queryId`
99
+ * re-arms the hook, so a card that stays mounted across consecutive searches
100
+ * is counted for each query. (A per-instance one-shot undercounts exactly the
101
+ * products that are returned most often, inflating their CTR.) The Tracker
102
+ * additionally dedupes impressions per `(queryId, objectId)` for its
103
+ * lifetime, so remounted instances - virtualized lists, route re-entry -
104
+ * cannot re-fire a pair this page already reported.
105
+ *
106
+ * Extra keys on `result` are persisted under the event's `event_attributes`
107
+ * (see {@link ResultRef}). Changing only extras does not re-arm the
108
+ * observer; the values current when the impression fires are sent.
109
+ *
110
+ * No-ops when there is no search context (nothing joinable to report), when
111
+ * `disabled`, or where `IntersectionObserver` is unavailable.
112
+ *
113
+ * StrictMode-safe by construction: cleanup is driven by the callback ref
114
+ * itself - React calls it with `null` on detach and re-invokes it when its
115
+ * identity changes - not by an effect whose simulated unmount would
116
+ * disconnect the observer without re-arming it.
117
+ *
118
+ * ```tsx
119
+ * const ref = useResultImpression({ objectId: r.sku, ordinal });
120
+ * return <article ref={ref}>...</article>;
121
+ * ```
122
+ */
123
+ function useResultImpression(result, options) {
124
+ var _options$disabled;
125
+ const tracker = useTracker();
126
+ const search = useSearchResults();
127
+ const firedForQuery = (0, react.useRef)(null);
128
+ const observerRef = (0, react.useRef)(null);
129
+ const latestResult = (0, react.useRef)(result);
130
+ latestResult.current = result;
131
+ const disabled = (_options$disabled = options === null || options === void 0 ? void 0 : options.disabled) !== null && _options$disabled !== void 0 ? _options$disabled : false;
132
+ const { objectId, ordinal } = result;
133
+ const queryId = search === null || search === void 0 ? void 0 : search.queryId;
134
+ const query = search === null || search === void 0 ? void 0 : search.query;
135
+ return (0, react.useCallback)((node) => {
136
+ var _observerRef$current;
137
+ (_observerRef$current = observerRef.current) === null || _observerRef$current === void 0 || _observerRef$current.disconnect();
138
+ observerRef.current = null;
139
+ if (!node || disabled || !queryId) return;
140
+ if (firedForQuery.current === queryId) return;
141
+ if (typeof IntersectionObserver === "undefined") return;
142
+ const observer = new IntersectionObserver((entries) => {
143
+ for (const entry of entries) {
144
+ if (!entry.isIntersecting) continue;
145
+ if (firedForQuery.current === queryId) continue;
146
+ firedForQuery.current = queryId;
147
+ const item = {
148
+ ...latestResult.current,
149
+ objectId,
150
+ ordinal
151
+ };
152
+ tracker.trackResultImpression({
153
+ items: [item],
154
+ queryId,
155
+ query
156
+ });
157
+ observer.disconnect();
158
+ observerRef.current = null;
159
+ }
160
+ }, { threshold: 0 });
161
+ observer.observe(node);
162
+ observerRef.current = observer;
163
+ }, [
164
+ tracker,
165
+ objectId,
166
+ ordinal,
167
+ queryId,
168
+ query,
169
+ disabled
170
+ ]);
171
+ }
172
+ //#endregion
173
+ exports.SearchResults = SearchResults;
174
+ exports.Tracker = require_tracker.Tracker;
175
+ exports.TrackerProvider = TrackerProvider;
176
+ exports.useResultImpression = useResultImpression;
177
+ exports.useSearchResults = useSearchResults;
178
+ exports.useTracker = useTracker;
179
+
180
+ //# sourceMappingURL=react.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react.cjs","names":["createContext","useMemo","createElement","useContext","createContext","useState","Tracker","useRef","createElement","useContext","useRef","useCallback"],"sources":["../src/react/SearchResults.ts","../src/react/context.ts","../src/react/TrackerProvider.ts","../src/react/useResultImpression.ts"],"sourcesContent":["import {\n createContext,\n createElement,\n type ReactElement,\n type ReactNode,\n useContext,\n useMemo,\n} from \"react\";\n\n/** The search that produced the results rendered beneath a {@link SearchResults}. */\nexport type SearchResultsValue = {\n /** The server-issued `query_id` for the search. */\n queryId: string;\n /** The query text as the user entered it, if known. */\n query?: string;\n};\n\nconst SearchResultsContext = createContext<SearchResultsValue | null>(null);\n\n/** Props for {@link SearchResults}. */\nexport type SearchResultsProps = SearchResultsValue & {\n /** The results subtree this search context applies to. */\n children?: ReactNode;\n};\n\n/**\n * Declares which search produced the results rendered beneath it - the SPA\n * twin of `data-query-id` on a server-rendered results container. Result\n * components read it with {@link useSearchResults} (and\n * {@link useResultImpression} reads it automatically) instead of threading\n * `queryId` down as a prop.\n *\n * ```tsx\n * <SearchResults queryId={response.query_id} query={q}>\n * {results.map((r, i) => <ResultCard key={r.sku} result={r} ordinal={i + 1} />)}\n * </SearchResults>\n * ```\n */\nexport function SearchResults({\n queryId,\n query,\n children,\n}: SearchResultsProps): ReactElement {\n const value = useMemo(() => ({ queryId, query }), [queryId, query]);\n return createElement(SearchResultsContext.Provider, { value }, children);\n}\n\n/**\n * The current search context, or null outside a {@link SearchResults} (e.g. a\n * card rendered on a home page that did not come from a search).\n */\nexport function useSearchResults(): SearchResultsValue | null {\n return useContext(SearchResultsContext);\n}\n","import { createContext } from \"react\";\nimport type { Tracker } from \"../tracker\";\n\n/**\n * Holds the configured {@link Tracker} for a React tree. Provided by\n * {@link TrackerProvider} and read with {@link useTracker}.\n */\nexport const TrackerContext = createContext<Tracker | null>(null);\n","import {\n createElement,\n type ReactElement,\n type ReactNode,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport { Tracker, type TrackerOptions } from \"../tracker\";\nimport { TrackerContext } from \"./context\";\n\n/**\n * Props shared by both {@link TrackerProviderProps} variants (provide\n * `options` OR a pre-built `tracker`).\n */\nexport interface TrackerProviderBaseProps {\n /**\n * Runs exactly once, before the first `start()`. Do one-time wiring here:\n * `addEnricher()`, `addSink()`. It is guarded so React StrictMode's\n * double-mount cannot run the wiring twice.\n */\n onInit?: (tracker: Tracker) => void;\n /**\n * Call `start()` on mount and `stop()` on unmount. Defaults to `true`. Set\n * `false` when an external owner controls the tracker lifecycle and the\n * provider is only used to expose the instance via context.\n */\n autoStart?: boolean;\n /** The tree that can read the tracker via {@link useTracker}. */\n children?: ReactNode;\n}\n\n/**\n * Provide the tracker either by handing over an already-constructed instance\n * (`tracker`) or by passing `options` for the provider to construct. Exactly\n * one of the two is required.\n */\nexport type TrackerProviderProps =\n | (TrackerProviderBaseProps & {\n /** Options the provider constructs its own {@link Tracker} from. */\n options: TrackerOptions;\n /** Not allowed together with `options`. */\n tracker?: never;\n })\n | (TrackerProviderBaseProps & {\n /** A pre-built instance owned outside React, exposed via context. */\n tracker: Tracker;\n /** Not allowed together with `tracker`. */\n options?: never;\n });\n\n/**\n * Owns a {@link Tracker}'s lifecycle and exposes it to the tree via context.\n * Starts the tracker on mount and stops it on unmount, so components never\n * import a module singleton and the tracker is torn down cleanly.\n *\n * `options` (and `tracker`) are read ONCE on mount; later prop changes are\n * ignored. Update identity with `useTracker().setUserId(...)` rather than by\n * changing `options.userId`.\n *\n * @example\n * ```tsx\n * <TrackerProvider\n * options={{ application: \"my-app\", siteId: \"...\", endpointHost: \"...\" }}\n * onInit={(t) => t.addEnricher(abTestEnricher)}\n * >\n * <App />\n * </TrackerProvider>\n * ```\n */\nexport function TrackerProvider(props: TrackerProviderProps): ReactElement {\n const { children, onInit, autoStart = true } = props;\n\n // Own the instance once. The lazy initializer keeps its identity stable across\n // renders, so the context value never changes and consumers do not re-render.\n const [tracker] = useState<Tracker>(() =>\n \"tracker\" in props && props.tracker\n ? props.tracker\n : new Tracker((props as { options: TrackerOptions }).options),\n );\n\n // Capture the mount-time onInit; it runs once, so later identity changes are\n // irrelevant and it never needs to be an effect dependency.\n const onInitRef = useRef(onInit);\n const initialized = useRef(false);\n\n useEffect(() => {\n if (!autoStart) return;\n // StrictMode runs mount -> cleanup -> mount. onInit is ref-guarded to run\n // once (one-time wiring such as addSink/addEnricher would otherwise run\n // twice), while start()/stop() are restart-safe and idempotent - so the\n // tracker ends started with the wiring applied exactly once.\n if (!initialized.current) {\n initialized.current = true;\n onInitRef.current?.(tracker);\n }\n tracker.start();\n return () => {\n tracker.stop();\n };\n }, [tracker, autoStart]);\n\n return createElement(TrackerContext.Provider, { value: tracker }, children);\n}\n\n/**\n * Returns the {@link Tracker} provided by the nearest {@link TrackerProvider}.\n * Throws when called outside a provider.\n */\nexport function useTracker(): Tracker {\n const tracker = useContext(TrackerContext);\n if (!tracker) {\n throw new Error(\n \"useTracker must be used within a <TrackerProvider>. Wrap your app in \" +\n \"<TrackerProvider options={{ application: '...' }}> ... </TrackerProvider>.\",\n );\n }\n return tracker;\n}\n","import { useCallback, useRef } from \"react\";\nimport type { ResultRef } from \"../tracker\";\nimport { useSearchResults } from \"./SearchResults\";\nimport { useTracker } from \"./TrackerProvider\";\n\n/** Options for {@link useResultImpression}. */\nexport type UseResultImpressionOptions = {\n /** When true, no impression is observed or reported. */\n disabled?: boolean;\n};\n\n/**\n * Returns a callback ref that reports ONE canonical impression for the result\n * the first time its element becomes visible, attributed to the surrounding\n * {@link SearchResults} context.\n *\n * Fires once per query, not once per component instance: a new `queryId`\n * re-arms the hook, so a card that stays mounted across consecutive searches\n * is counted for each query. (A per-instance one-shot undercounts exactly the\n * products that are returned most often, inflating their CTR.) The Tracker\n * additionally dedupes impressions per `(queryId, objectId)` for its\n * lifetime, so remounted instances - virtualized lists, route re-entry -\n * cannot re-fire a pair this page already reported.\n *\n * Extra keys on `result` are persisted under the event's `event_attributes`\n * (see {@link ResultRef}). Changing only extras does not re-arm the\n * observer; the values current when the impression fires are sent.\n *\n * No-ops when there is no search context (nothing joinable to report), when\n * `disabled`, or where `IntersectionObserver` is unavailable.\n *\n * StrictMode-safe by construction: cleanup is driven by the callback ref\n * itself - React calls it with `null` on detach and re-invokes it when its\n * identity changes - not by an effect whose simulated unmount would\n * disconnect the observer without re-arming it.\n *\n * ```tsx\n * const ref = useResultImpression({ objectId: r.sku, ordinal });\n * return <article ref={ref}>...</article>;\n * ```\n */\nexport function useResultImpression(\n result: ResultRef,\n options?: UseResultImpressionOptions,\n): (node: Element | null) => void {\n const tracker = useTracker();\n const search = useSearchResults();\n const firedForQuery = useRef<string | null>(null);\n const observerRef = useRef<IntersectionObserver | null>(null);\n // Latest-value ref: extra keys on `result` ride into the fired impression\n // without being callback deps (an inline extras literal would otherwise\n // re-arm the observer every render). Identity-forming fields stay as deps.\n const latestResult = useRef(result);\n latestResult.current = result;\n\n const disabled = options?.disabled ?? false;\n const { objectId, ordinal } = result;\n const queryId = search?.queryId;\n const query = search?.query;\n\n return useCallback(\n (node: Element | null) => {\n // Called with the node on attach and null on detach; also re-invoked\n // when the callback identity changes (e.g. a new queryId arrives).\n observerRef.current?.disconnect();\n observerRef.current = null;\n\n if (!node || disabled || !queryId) return;\n if (firedForQuery.current === queryId) return;\n if (typeof IntersectionObserver === \"undefined\") return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue;\n if (firedForQuery.current === queryId) continue;\n firedForQuery.current = queryId;\n\n const item: ResultRef = {\n ...latestResult.current,\n objectId,\n ordinal,\n };\n tracker.trackResultImpression({ items: [item], queryId, query });\n\n observer.disconnect();\n observerRef.current = null;\n }\n },\n { threshold: 0 },\n );\n observer.observe(node);\n observerRef.current = observer;\n },\n [tracker, objectId, ordinal, queryId, query, disabled],\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,139 @@
1
+ /*! @releval/tracker | Apache-2.0 | https://github.com/releval/tracker */
2
+ import { S as EventPosition, _ as Sink, a as TrackResultClickOptions, b as EventAttributes, c as TrackSearchOptions, d as TrackerOptions, i as ResultRef, l as Tracker, o as TrackResultEventOptions, s as TrackResultImpressionOptions, u as TrackerBaseOptions, v as Enricher, w as Logger, x as EventObject, y as Event } from "./ResultImpressionCollector.cjs";
3
+ import { ReactElement, ReactNode } from "react";
4
+ //#region src/react/SearchResults.d.ts
5
+ /** The search that produced the results rendered beneath a {@link SearchResults}. */
6
+ type SearchResultsValue = {
7
+ /** The server-issued `query_id` for the search. */
8
+ queryId: string;
9
+ /** The query text as the user entered it, if known. */
10
+ query?: string;
11
+ };
12
+ /** Props for {@link SearchResults}. */
13
+ type SearchResultsProps = SearchResultsValue & {
14
+ /** The results subtree this search context applies to. */
15
+ children?: ReactNode;
16
+ };
17
+ /**
18
+ * Declares which search produced the results rendered beneath it - the SPA
19
+ * twin of `data-query-id` on a server-rendered results container. Result
20
+ * components read it with {@link useSearchResults} (and
21
+ * {@link useResultImpression} reads it automatically) instead of threading
22
+ * `queryId` down as a prop.
23
+ *
24
+ * ```tsx
25
+ * <SearchResults queryId={response.query_id} query={q}>
26
+ * {results.map((r, i) => <ResultCard key={r.sku} result={r} ordinal={i + 1} />)}
27
+ * </SearchResults>
28
+ * ```
29
+ */
30
+ declare function SearchResults({ queryId, query, children }: SearchResultsProps): ReactElement;
31
+ /**
32
+ * The current search context, or null outside a {@link SearchResults} (e.g. a
33
+ * card rendered on a home page that did not come from a search).
34
+ */
35
+ declare function useSearchResults(): SearchResultsValue | null;
36
+ //#endregion
37
+ //#region src/react/TrackerProvider.d.ts
38
+ /**
39
+ * Props shared by both {@link TrackerProviderProps} variants (provide
40
+ * `options` OR a pre-built `tracker`).
41
+ */
42
+ interface TrackerProviderBaseProps {
43
+ /**
44
+ * Runs exactly once, before the first `start()`. Do one-time wiring here:
45
+ * `addEnricher()`, `addSink()`. It is guarded so React StrictMode's
46
+ * double-mount cannot run the wiring twice.
47
+ */
48
+ onInit?: (tracker: Tracker) => void;
49
+ /**
50
+ * Call `start()` on mount and `stop()` on unmount. Defaults to `true`. Set
51
+ * `false` when an external owner controls the tracker lifecycle and the
52
+ * provider is only used to expose the instance via context.
53
+ */
54
+ autoStart?: boolean;
55
+ /** The tree that can read the tracker via {@link useTracker}. */
56
+ children?: ReactNode;
57
+ }
58
+ /**
59
+ * Provide the tracker either by handing over an already-constructed instance
60
+ * (`tracker`) or by passing `options` for the provider to construct. Exactly
61
+ * one of the two is required.
62
+ */
63
+ type TrackerProviderProps = (TrackerProviderBaseProps & {
64
+ /** Options the provider constructs its own {@link Tracker} from. */
65
+ options: TrackerOptions;
66
+ /** Not allowed together with `options`. */
67
+ tracker?: never;
68
+ }) | (TrackerProviderBaseProps & {
69
+ /** A pre-built instance owned outside React, exposed via context. */
70
+ tracker: Tracker;
71
+ /** Not allowed together with `tracker`. */
72
+ options?: never;
73
+ });
74
+ /**
75
+ * Owns a {@link Tracker}'s lifecycle and exposes it to the tree via context.
76
+ * Starts the tracker on mount and stops it on unmount, so components never
77
+ * import a module singleton and the tracker is torn down cleanly.
78
+ *
79
+ * `options` (and `tracker`) are read ONCE on mount; later prop changes are
80
+ * ignored. Update identity with `useTracker().setUserId(...)` rather than by
81
+ * changing `options.userId`.
82
+ *
83
+ * @example
84
+ * ```tsx
85
+ * <TrackerProvider
86
+ * options={{ application: "my-app", siteId: "...", endpointHost: "..." }}
87
+ * onInit={(t) => t.addEnricher(abTestEnricher)}
88
+ * >
89
+ * <App />
90
+ * </TrackerProvider>
91
+ * ```
92
+ */
93
+ declare function TrackerProvider(props: TrackerProviderProps): ReactElement;
94
+ /**
95
+ * Returns the {@link Tracker} provided by the nearest {@link TrackerProvider}.
96
+ * Throws when called outside a provider.
97
+ */
98
+ declare function useTracker(): Tracker;
99
+ //#endregion
100
+ //#region src/react/useResultImpression.d.ts
101
+ /** Options for {@link useResultImpression}. */
102
+ type UseResultImpressionOptions = {
103
+ /** When true, no impression is observed or reported. */
104
+ disabled?: boolean;
105
+ };
106
+ /**
107
+ * Returns a callback ref that reports ONE canonical impression for the result
108
+ * the first time its element becomes visible, attributed to the surrounding
109
+ * {@link SearchResults} context.
110
+ *
111
+ * Fires once per query, not once per component instance: a new `queryId`
112
+ * re-arms the hook, so a card that stays mounted across consecutive searches
113
+ * is counted for each query. (A per-instance one-shot undercounts exactly the
114
+ * products that are returned most often, inflating their CTR.) The Tracker
115
+ * additionally dedupes impressions per `(queryId, objectId)` for its
116
+ * lifetime, so remounted instances - virtualized lists, route re-entry -
117
+ * cannot re-fire a pair this page already reported.
118
+ *
119
+ * Extra keys on `result` are persisted under the event's `event_attributes`
120
+ * (see {@link ResultRef}). Changing only extras does not re-arm the
121
+ * observer; the values current when the impression fires are sent.
122
+ *
123
+ * No-ops when there is no search context (nothing joinable to report), when
124
+ * `disabled`, or where `IntersectionObserver` is unavailable.
125
+ *
126
+ * StrictMode-safe by construction: cleanup is driven by the callback ref
127
+ * itself - React calls it with `null` on detach and re-invokes it when its
128
+ * identity changes - not by an effect whose simulated unmount would
129
+ * disconnect the observer without re-arming it.
130
+ *
131
+ * ```tsx
132
+ * const ref = useResultImpression({ objectId: r.sku, ordinal });
133
+ * return <article ref={ref}>...</article>;
134
+ * ```
135
+ */
136
+ declare function useResultImpression(result: ResultRef, options?: UseResultImpressionOptions): (node: Element | null) => void;
137
+ //#endregion
138
+ export { type Enricher, type Event, type EventAttributes, type EventObject, type EventPosition, type Logger, type ResultRef, SearchResults, type SearchResultsProps, type SearchResultsValue, type Sink, type TrackResultClickOptions, type TrackResultEventOptions, type TrackResultImpressionOptions, type TrackSearchOptions, Tracker, type TrackerBaseOptions, type TrackerOptions, TrackerProvider, type TrackerProviderBaseProps, type TrackerProviderProps, type UseResultImpressionOptions, useResultImpression, useSearchResults, useTracker };
139
+ //# sourceMappingURL=react.d.cts.map
@@ -0,0 +1,139 @@
1
+ /*! @releval/tracker | Apache-2.0 | https://github.com/releval/tracker */
2
+ import { S as EventPosition, _ as Sink, a as TrackResultClickOptions, b as EventAttributes, c as TrackSearchOptions, d as TrackerOptions, i as ResultRef, l as Tracker, o as TrackResultEventOptions, s as TrackResultImpressionOptions, u as TrackerBaseOptions, v as Enricher, w as Logger, x as EventObject, y as Event } from "./ResultImpressionCollector.mjs";
3
+ import { ReactElement, ReactNode } from "react";
4
+ //#region src/react/SearchResults.d.ts
5
+ /** The search that produced the results rendered beneath a {@link SearchResults}. */
6
+ type SearchResultsValue = {
7
+ /** The server-issued `query_id` for the search. */
8
+ queryId: string;
9
+ /** The query text as the user entered it, if known. */
10
+ query?: string;
11
+ };
12
+ /** Props for {@link SearchResults}. */
13
+ type SearchResultsProps = SearchResultsValue & {
14
+ /** The results subtree this search context applies to. */
15
+ children?: ReactNode;
16
+ };
17
+ /**
18
+ * Declares which search produced the results rendered beneath it - the SPA
19
+ * twin of `data-query-id` on a server-rendered results container. Result
20
+ * components read it with {@link useSearchResults} (and
21
+ * {@link useResultImpression} reads it automatically) instead of threading
22
+ * `queryId` down as a prop.
23
+ *
24
+ * ```tsx
25
+ * <SearchResults queryId={response.query_id} query={q}>
26
+ * {results.map((r, i) => <ResultCard key={r.sku} result={r} ordinal={i + 1} />)}
27
+ * </SearchResults>
28
+ * ```
29
+ */
30
+ declare function SearchResults({ queryId, query, children }: SearchResultsProps): ReactElement;
31
+ /**
32
+ * The current search context, or null outside a {@link SearchResults} (e.g. a
33
+ * card rendered on a home page that did not come from a search).
34
+ */
35
+ declare function useSearchResults(): SearchResultsValue | null;
36
+ //#endregion
37
+ //#region src/react/TrackerProvider.d.ts
38
+ /**
39
+ * Props shared by both {@link TrackerProviderProps} variants (provide
40
+ * `options` OR a pre-built `tracker`).
41
+ */
42
+ interface TrackerProviderBaseProps {
43
+ /**
44
+ * Runs exactly once, before the first `start()`. Do one-time wiring here:
45
+ * `addEnricher()`, `addSink()`. It is guarded so React StrictMode's
46
+ * double-mount cannot run the wiring twice.
47
+ */
48
+ onInit?: (tracker: Tracker) => void;
49
+ /**
50
+ * Call `start()` on mount and `stop()` on unmount. Defaults to `true`. Set
51
+ * `false` when an external owner controls the tracker lifecycle and the
52
+ * provider is only used to expose the instance via context.
53
+ */
54
+ autoStart?: boolean;
55
+ /** The tree that can read the tracker via {@link useTracker}. */
56
+ children?: ReactNode;
57
+ }
58
+ /**
59
+ * Provide the tracker either by handing over an already-constructed instance
60
+ * (`tracker`) or by passing `options` for the provider to construct. Exactly
61
+ * one of the two is required.
62
+ */
63
+ type TrackerProviderProps = (TrackerProviderBaseProps & {
64
+ /** Options the provider constructs its own {@link Tracker} from. */
65
+ options: TrackerOptions;
66
+ /** Not allowed together with `options`. */
67
+ tracker?: never;
68
+ }) | (TrackerProviderBaseProps & {
69
+ /** A pre-built instance owned outside React, exposed via context. */
70
+ tracker: Tracker;
71
+ /** Not allowed together with `tracker`. */
72
+ options?: never;
73
+ });
74
+ /**
75
+ * Owns a {@link Tracker}'s lifecycle and exposes it to the tree via context.
76
+ * Starts the tracker on mount and stops it on unmount, so components never
77
+ * import a module singleton and the tracker is torn down cleanly.
78
+ *
79
+ * `options` (and `tracker`) are read ONCE on mount; later prop changes are
80
+ * ignored. Update identity with `useTracker().setUserId(...)` rather than by
81
+ * changing `options.userId`.
82
+ *
83
+ * @example
84
+ * ```tsx
85
+ * <TrackerProvider
86
+ * options={{ application: "my-app", siteId: "...", endpointHost: "..." }}
87
+ * onInit={(t) => t.addEnricher(abTestEnricher)}
88
+ * >
89
+ * <App />
90
+ * </TrackerProvider>
91
+ * ```
92
+ */
93
+ declare function TrackerProvider(props: TrackerProviderProps): ReactElement;
94
+ /**
95
+ * Returns the {@link Tracker} provided by the nearest {@link TrackerProvider}.
96
+ * Throws when called outside a provider.
97
+ */
98
+ declare function useTracker(): Tracker;
99
+ //#endregion
100
+ //#region src/react/useResultImpression.d.ts
101
+ /** Options for {@link useResultImpression}. */
102
+ type UseResultImpressionOptions = {
103
+ /** When true, no impression is observed or reported. */
104
+ disabled?: boolean;
105
+ };
106
+ /**
107
+ * Returns a callback ref that reports ONE canonical impression for the result
108
+ * the first time its element becomes visible, attributed to the surrounding
109
+ * {@link SearchResults} context.
110
+ *
111
+ * Fires once per query, not once per component instance: a new `queryId`
112
+ * re-arms the hook, so a card that stays mounted across consecutive searches
113
+ * is counted for each query. (A per-instance one-shot undercounts exactly the
114
+ * products that are returned most often, inflating their CTR.) The Tracker
115
+ * additionally dedupes impressions per `(queryId, objectId)` for its
116
+ * lifetime, so remounted instances - virtualized lists, route re-entry -
117
+ * cannot re-fire a pair this page already reported.
118
+ *
119
+ * Extra keys on `result` are persisted under the event's `event_attributes`
120
+ * (see {@link ResultRef}). Changing only extras does not re-arm the
121
+ * observer; the values current when the impression fires are sent.
122
+ *
123
+ * No-ops when there is no search context (nothing joinable to report), when
124
+ * `disabled`, or where `IntersectionObserver` is unavailable.
125
+ *
126
+ * StrictMode-safe by construction: cleanup is driven by the callback ref
127
+ * itself - React calls it with `null` on detach and re-invokes it when its
128
+ * identity changes - not by an effect whose simulated unmount would
129
+ * disconnect the observer without re-arming it.
130
+ *
131
+ * ```tsx
132
+ * const ref = useResultImpression({ objectId: r.sku, ordinal });
133
+ * return <article ref={ref}>...</article>;
134
+ * ```
135
+ */
136
+ declare function useResultImpression(result: ResultRef, options?: UseResultImpressionOptions): (node: Element | null) => void;
137
+ //#endregion
138
+ export { type Enricher, type Event, type EventAttributes, type EventObject, type EventPosition, type Logger, type ResultRef, SearchResults, type SearchResultsProps, type SearchResultsValue, type Sink, type TrackResultClickOptions, type TrackResultEventOptions, type TrackResultImpressionOptions, type TrackSearchOptions, Tracker, type TrackerBaseOptions, type TrackerOptions, TrackerProvider, type TrackerProviderBaseProps, type TrackerProviderProps, type UseResultImpressionOptions, useResultImpression, useSearchResults, useTracker };
139
+ //# sourceMappingURL=react.d.mts.map
package/dist/react.mjs ADDED
@@ -0,0 +1,174 @@
1
+ "use client";
2
+ /*! @releval/tracker | Apache-2.0 | https://github.com/releval/tracker */
3
+ import { t as Tracker } from "./tracker.mjs";
4
+ import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
+ //#region src/react/SearchResults.ts
6
+ const SearchResultsContext = createContext(null);
7
+ /**
8
+ * Declares which search produced the results rendered beneath it - the SPA
9
+ * twin of `data-query-id` on a server-rendered results container. Result
10
+ * components read it with {@link useSearchResults} (and
11
+ * {@link useResultImpression} reads it automatically) instead of threading
12
+ * `queryId` down as a prop.
13
+ *
14
+ * ```tsx
15
+ * <SearchResults queryId={response.query_id} query={q}>
16
+ * {results.map((r, i) => <ResultCard key={r.sku} result={r} ordinal={i + 1} />)}
17
+ * </SearchResults>
18
+ * ```
19
+ */
20
+ function SearchResults({ queryId, query, children }) {
21
+ const value = useMemo(() => ({
22
+ queryId,
23
+ query
24
+ }), [queryId, query]);
25
+ return createElement(SearchResultsContext.Provider, { value }, children);
26
+ }
27
+ /**
28
+ * The current search context, or null outside a {@link SearchResults} (e.g. a
29
+ * card rendered on a home page that did not come from a search).
30
+ */
31
+ function useSearchResults() {
32
+ return useContext(SearchResultsContext);
33
+ }
34
+ //#endregion
35
+ //#region src/react/context.ts
36
+ /**
37
+ * Holds the configured {@link Tracker} for a React tree. Provided by
38
+ * {@link TrackerProvider} and read with {@link useTracker}.
39
+ */
40
+ const TrackerContext = createContext(null);
41
+ //#endregion
42
+ //#region src/react/TrackerProvider.ts
43
+ /**
44
+ * Owns a {@link Tracker}'s lifecycle and exposes it to the tree via context.
45
+ * Starts the tracker on mount and stops it on unmount, so components never
46
+ * import a module singleton and the tracker is torn down cleanly.
47
+ *
48
+ * `options` (and `tracker`) are read ONCE on mount; later prop changes are
49
+ * ignored. Update identity with `useTracker().setUserId(...)` rather than by
50
+ * changing `options.userId`.
51
+ *
52
+ * @example
53
+ * ```tsx
54
+ * <TrackerProvider
55
+ * options={{ application: "my-app", siteId: "...", endpointHost: "..." }}
56
+ * onInit={(t) => t.addEnricher(abTestEnricher)}
57
+ * >
58
+ * <App />
59
+ * </TrackerProvider>
60
+ * ```
61
+ */
62
+ function TrackerProvider(props) {
63
+ const { children, onInit, autoStart = true } = props;
64
+ const [tracker] = useState(() => "tracker" in props && props.tracker ? props.tracker : new Tracker(props.options));
65
+ const onInitRef = useRef(onInit);
66
+ const initialized = useRef(false);
67
+ useEffect(() => {
68
+ if (!autoStart) return;
69
+ if (!initialized.current) {
70
+ var _onInitRef$current;
71
+ initialized.current = true;
72
+ (_onInitRef$current = onInitRef.current) === null || _onInitRef$current === void 0 || _onInitRef$current.call(onInitRef, tracker);
73
+ }
74
+ tracker.start();
75
+ return () => {
76
+ tracker.stop();
77
+ };
78
+ }, [tracker, autoStart]);
79
+ return createElement(TrackerContext.Provider, { value: tracker }, children);
80
+ }
81
+ /**
82
+ * Returns the {@link Tracker} provided by the nearest {@link TrackerProvider}.
83
+ * Throws when called outside a provider.
84
+ */
85
+ function useTracker() {
86
+ const tracker = useContext(TrackerContext);
87
+ if (!tracker) throw new Error("useTracker must be used within a <TrackerProvider>. Wrap your app in <TrackerProvider options={{ application: '...' }}> ... </TrackerProvider>.");
88
+ return tracker;
89
+ }
90
+ //#endregion
91
+ //#region src/react/useResultImpression.ts
92
+ /**
93
+ * Returns a callback ref that reports ONE canonical impression for the result
94
+ * the first time its element becomes visible, attributed to the surrounding
95
+ * {@link SearchResults} context.
96
+ *
97
+ * Fires once per query, not once per component instance: a new `queryId`
98
+ * re-arms the hook, so a card that stays mounted across consecutive searches
99
+ * is counted for each query. (A per-instance one-shot undercounts exactly the
100
+ * products that are returned most often, inflating their CTR.) The Tracker
101
+ * additionally dedupes impressions per `(queryId, objectId)` for its
102
+ * lifetime, so remounted instances - virtualized lists, route re-entry -
103
+ * cannot re-fire a pair this page already reported.
104
+ *
105
+ * Extra keys on `result` are persisted under the event's `event_attributes`
106
+ * (see {@link ResultRef}). Changing only extras does not re-arm the
107
+ * observer; the values current when the impression fires are sent.
108
+ *
109
+ * No-ops when there is no search context (nothing joinable to report), when
110
+ * `disabled`, or where `IntersectionObserver` is unavailable.
111
+ *
112
+ * StrictMode-safe by construction: cleanup is driven by the callback ref
113
+ * itself - React calls it with `null` on detach and re-invokes it when its
114
+ * identity changes - not by an effect whose simulated unmount would
115
+ * disconnect the observer without re-arming it.
116
+ *
117
+ * ```tsx
118
+ * const ref = useResultImpression({ objectId: r.sku, ordinal });
119
+ * return <article ref={ref}>...</article>;
120
+ * ```
121
+ */
122
+ function useResultImpression(result, options) {
123
+ var _options$disabled;
124
+ const tracker = useTracker();
125
+ const search = useSearchResults();
126
+ const firedForQuery = useRef(null);
127
+ const observerRef = useRef(null);
128
+ const latestResult = useRef(result);
129
+ latestResult.current = result;
130
+ const disabled = (_options$disabled = options === null || options === void 0 ? void 0 : options.disabled) !== null && _options$disabled !== void 0 ? _options$disabled : false;
131
+ const { objectId, ordinal } = result;
132
+ const queryId = search === null || search === void 0 ? void 0 : search.queryId;
133
+ const query = search === null || search === void 0 ? void 0 : search.query;
134
+ return useCallback((node) => {
135
+ var _observerRef$current;
136
+ (_observerRef$current = observerRef.current) === null || _observerRef$current === void 0 || _observerRef$current.disconnect();
137
+ observerRef.current = null;
138
+ if (!node || disabled || !queryId) return;
139
+ if (firedForQuery.current === queryId) return;
140
+ if (typeof IntersectionObserver === "undefined") return;
141
+ const observer = new IntersectionObserver((entries) => {
142
+ for (const entry of entries) {
143
+ if (!entry.isIntersecting) continue;
144
+ if (firedForQuery.current === queryId) continue;
145
+ firedForQuery.current = queryId;
146
+ const item = {
147
+ ...latestResult.current,
148
+ objectId,
149
+ ordinal
150
+ };
151
+ tracker.trackResultImpression({
152
+ items: [item],
153
+ queryId,
154
+ query
155
+ });
156
+ observer.disconnect();
157
+ observerRef.current = null;
158
+ }
159
+ }, { threshold: 0 });
160
+ observer.observe(node);
161
+ observerRef.current = observer;
162
+ }, [
163
+ tracker,
164
+ objectId,
165
+ ordinal,
166
+ queryId,
167
+ query,
168
+ disabled
169
+ ]);
170
+ }
171
+ //#endregion
172
+ export { SearchResults, Tracker, TrackerProvider, useResultImpression, useSearchResults, useTracker };
173
+
174
+ //# sourceMappingURL=react.mjs.map