@headband/react 0.2.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/README.md CHANGED
@@ -167,6 +167,42 @@ Hosted assignment only returns a variant when the rule is `active`, the kill swi
167
167
 
168
168
  ---
169
169
 
170
+ ## Click feedback
171
+
172
+ When click feedback is on for your organization, every search response carries a `queryId`. Send clicks and conversions with it, and Headband counts them per query. Turn on "Use click feedback in ranking" for an index (dashboard or `PATCH /v1/indexes/{uid}/click-feedback`) and results people pick for a query move up for that query. No experiment is needed.
173
+
174
+ ```tsx
175
+ import { useClickTracking, useHits } from "@headband/react";
176
+
177
+ function Results() {
178
+ const { hits } = useHits<{ id: string; title: string; url: string }>();
179
+ const { trackClick } = useClickTracking();
180
+ return (
181
+ <ul>
182
+ {hits.map((hit, i) => (
183
+ <li key={hit.__data.id}>
184
+ <a href={hit.__data.url} onClick={() => trackClick(hit, i)}>
185
+ {hit.__data.title}
186
+ </a>
187
+ </li>
188
+ ))}
189
+ </ul>
190
+ );
191
+ }
192
+
193
+ // Later, on the order page:
194
+ const { trackConversion } = useClickTracking();
195
+ trackConversion(productId, { value: 1999 });
196
+ ```
197
+
198
+ - `trackClick(hit, positionOnPage)` sends the hit's id, the search's `queryId`, its position in the whole result list and a session id. Events are sent with `keepalive`, so a click that opens a new page still arrives.
199
+ - `trackConversion(docId)` names the search the document was last clicked from in this tab.
200
+ - Ids are read from `id`. Set `idField` on `<HeadbandProvider>` if your documents use another field. Set `sessionId` to your own visitor session id; without it, the experiment's session id is used, then an id kept for the browser tab.
201
+ - Outside React: `client.trackClick({ index, docId, queryId, position, sessionId })`, `client.trackConversion({ index, docId, value })` and `client.sendEvents([...])` (up to 100 events).
202
+ - Tracking never throws from the hook. The client methods reject with a `HeadbandError`, as the other calls do.
203
+
204
+ ---
205
+
170
206
  ## Provider
171
207
 
172
208
  ### `<HeadbandProvider>`
@@ -179,14 +215,15 @@ Wraps your search UI and manages all search state.
179
215
  </HeadbandProvider>
180
216
  ```
181
217
 
182
- | Prop | Type | Default | Description |
183
- | -------------- | ---------------------------------- | ------- | ------------------------------------------------------ |
184
- | `client` | `HeadbandClient` | -- | Client returned by `headband()` |
185
- | `index` | `string` | -- | Index to search |
186
- | `initialQuery` | `string` | `""` | Starting query value |
187
- | `experiment` | `ExperimentSearchMetadata \| null` | `null` | Optional experiment telemetry attached to search calls |
188
- | `mode` | `"instant" \| "retrieval"` | -- | Visor's ranking mode; other engines ignore it |
189
- | `children` | `ReactNode` | -- | Child components |
218
+ | Prop | Type | Default | Description |
219
+ | ------------------- | ---------------------------------- | ------- | ---------------------------------------------------------------- |
220
+ | `client` | `HeadbandClient` | -- | Client returned by `headband()` |
221
+ | `index` | `string` | -- | Index to search |
222
+ | `initialQuery` | `string` | `""` | Starting query value |
223
+ | `experiment` | `ExperimentSearchMetadata \| null` | `null` | Optional experiment telemetry attached to search calls |
224
+ | `mode` | `"instant" \| "retrieval"` | -- | Visor's ranking mode; other engines ignore it |
225
+ | `disjunctiveFacets` | `boolean` | `true` | Facet lists keep counting their other values after one is ticked |
226
+ | `children` | `ReactNode` | -- | Child components |
190
227
 
191
228
  `mode` is also a field of `client.search()` params and of each `client.multiSearch()` query.
192
229
  On a Visor index, `instant` (the default) is typo-tolerant, as-you-type search, and
@@ -257,6 +294,12 @@ const { items, refine, canToggle, isLoading } = useRefinementList({
257
294
 
258
295
  Each `RefinementListItem` has `{ value: string; count: number; isRefined: boolean }`.
259
296
 
297
+ Ticking a value does not hide the other values. The provider runs one more search for each facet
298
+ with a ticked value, filtered by everything except that facet's own values, and uses its counts
299
+ for the list. Each of those searches counts as a search on your plan. Pass
300
+ `disjunctiveFacets={false}` to `<HeadbandProvider>` for one search per request; the list then
301
+ shows only the values left in the results. New in 0.2.1: in 0.1.1 and 0.2.0 the list always narrows.
302
+
260
303
  ---
261
304
 
262
305
  ### `usePagination()`
@@ -410,9 +453,23 @@ const {
410
453
 
411
454
  All components accept a `styled` prop (default `true`) that toggles built-in Tailwind CSS classes. Set `styled={false}` to bring your own styles.
412
455
 
456
+ The built-in styles are Tailwind classes inside this package. Tailwind v4 does not scan
457
+ `node_modules`, so add an `@source` line to the CSS file that imports Tailwind. The path is
458
+ relative to that CSS file. For a Next.js `app/globals.css`:
459
+
460
+ ```css
461
+ @import "tailwindcss";
462
+ @source "../node_modules/@headband/react";
463
+ ```
464
+
465
+ For `src/app/globals.css`, use `"../../node_modules/@headband/react"`.
466
+
413
467
  ### `<SearchBox>`
414
468
 
415
- Search input with built-in clear button and search icon.
469
+ Search input with built-in clear button and search icon. From 0.2.1 it is a text input with
470
+ `role="searchbox"`, so Chrome and Safari add no second clear button. On 0.1.1 and 0.2.0, hide the browser's
471
+ own button with
472
+ `input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }`.
416
473
 
417
474
  ```tsx
418
475
  <SearchBox placeholder="Search products..." autoFocus onQueryChange={(q) => console.log(q)} />
package/dist/index.d.mts CHANGED
@@ -91,10 +91,50 @@ declare function assignExperiment(config: ExperimentConfig, subjectId: string, o
91
91
  }): ExperimentAssignment | null;
92
92
  declare function experimentSearchMetadata(assignment: ExperimentAssignment | null | undefined): ExperimentSearchMetadata | undefined;
93
93
 
94
+ /**
95
+ * Search events for click feedback: `search_result_click` and `conversion`.
96
+ *
97
+ * A search response carries a `queryId` when click feedback is on for your organization. Send it
98
+ * with a click so Headband knows which search showed the result. Clicks and conversions are
99
+ * counted per query; when an index has "Use click feedback in ranking" on, results people pick
100
+ * for a query move up for that query. No experiment is needed.
101
+ */
102
+ type SearchEventType = "search_result_click" | "conversion";
103
+ interface SearchEvent {
104
+ type: SearchEventType;
105
+ /** The index the result came from. */
106
+ index: string;
107
+ /** The clicked or converted document's id (its primary key). */
108
+ docId: string | number;
109
+ /** The `queryId` of the search that showed the result. */
110
+ queryId?: string;
111
+ /** 1-based position of the result in the whole result list, not just the page. */
112
+ position?: number;
113
+ /** A stable id for the visitor's session. Strongly recommended: events without one count half. */
114
+ sessionId?: string;
115
+ /** Optional, non-negative: an order value for a conversion. */
116
+ value?: number;
117
+ }
118
+ type SearchEventRejectReason = "invalid_event" | "invalid_type" | "invalid_index" | "invalid_doc_id" | "invalid_query_id" | "invalid_position" | "invalid_session_id" | "invalid_value" | "index_not_found" | "bot";
119
+ interface SendEventsResponse {
120
+ accepted: number;
121
+ /** `index` is the event's position in the array you sent. */
122
+ rejected: Array<{
123
+ index: number;
124
+ reason: SearchEventRejectReason;
125
+ }>;
126
+ }
127
+ /**
128
+ * A session id for this browser tab, kept in sessionStorage when it is available and in memory
129
+ * otherwise. It identifies nothing about the visitor; Headband stores it only as a keyed hash.
130
+ */
131
+ declare function browserSessionId(): string;
132
+
94
133
  /**
95
134
  * Headband search client.
96
135
  *
97
- * Makes requests to the Headband `/v1/search`, `/v1/multi-search` and `/v1/warm` endpoints.
136
+ * Makes requests to the Headband `/v1/search`, `/v1/multi-search`, `/v1/warm` and
137
+ * `/v1/relevance/events` endpoints.
98
138
  */
99
139
 
100
140
  /**
@@ -124,6 +164,11 @@ interface SearchParams {
124
164
  }
125
165
  interface SearchResponse {
126
166
  hits: Record<string, unknown>[];
167
+ /**
168
+ * The id of this search, `q_` and 16 characters, when click feedback is on for your
169
+ * organization. Send it with clicks (`trackClick`) so Headband knows which search showed them.
170
+ */
171
+ queryId?: string;
127
172
  estimatedTotalHits?: number;
128
173
  totalHits?: number;
129
174
  processingTimeMs: number;
@@ -182,6 +227,15 @@ interface HeadbandClient {
182
227
  warm(target: WarmInput, opts?: {
183
228
  level?: WarmLevel;
184
229
  }): Promise<WarmResponse>;
230
+ /** Sends up to 100 click and conversion events (POST /v1/relevance/events). */
231
+ sendEvents(events: SearchEvent[]): Promise<SendEventsResponse>;
232
+ /**
233
+ * Records a click on a search result. Pass the search's `queryId`; the client remembers it for
234
+ * the document so a later `trackConversion` names the same search.
235
+ */
236
+ trackClick(event: Omit<SearchEvent, "type">): Promise<SendEventsResponse>;
237
+ /** Records a conversion (a purchase, a sign-up) of a document found by search. */
238
+ trackConversion(event: Omit<SearchEvent, "type" | "position">): Promise<SendEventsResponse>;
185
239
  }
186
240
  declare class HeadbandError extends Error {
187
241
  readonly status: number;
@@ -243,11 +297,19 @@ interface SearchActions {
243
297
  unregisterFacet: (attribute: string) => void;
244
298
  refresh: () => void;
245
299
  }
300
+ /** How useClickTracking finds a hit's id and the visitor's session. */
301
+ interface ClickTrackingConfig {
302
+ /** The field hits carry the document id in. Default `id`. */
303
+ idField: string;
304
+ /** The session id events carry; a per-tab id when absent. */
305
+ sessionId?: string;
306
+ }
246
307
  interface HeadbandContextValue {
247
308
  client: HeadbandClient;
248
309
  index: string;
249
310
  state: SearchState;
250
311
  actions: SearchActions;
312
+ tracking: ClickTrackingConfig;
251
313
  }
252
314
  declare function useHeadbandContext(): HeadbandContextValue;
253
315
  interface HeadbandProviderProps {
@@ -266,9 +328,25 @@ interface HeadbandProviderProps {
266
328
  * for longer queries). Indexes on the other engines ignore it.
267
329
  */
268
330
  mode?: SearchMode;
331
+ /**
332
+ * Store-style facet counts (default true). With a value ticked in a facet list, that list still
333
+ * counts its other values, so a shopper can tick a second one. Each facet with a ticked value
334
+ * costs one more search per request. Set false for one search per request, and lists that narrow
335
+ * to the ticked values.
336
+ */
337
+ disjunctiveFacets?: boolean;
338
+ /**
339
+ * The field your documents' ids are in, for click feedback (`useClickTracking`). Default `id`.
340
+ */
341
+ idField?: string;
342
+ /**
343
+ * A stable id for the visitor's session, sent with click and conversion events. Without it the
344
+ * experiment's session id is used, else an id kept for this browser tab.
345
+ */
346
+ sessionId?: string;
269
347
  children: ReactNode;
270
348
  }
271
- declare function HeadbandProvider({ client, index, initialQuery, experiment, baseFilter, mode, children, }: HeadbandProviderProps): react_jsx_runtime.JSX.Element;
349
+ declare function HeadbandProvider({ client, index, initialQuery, experiment, baseFilter, mode, disjunctiveFacets, idField, sessionId, children, }: HeadbandProviderProps): react_jsx_runtime.JSX.Element;
272
350
 
273
351
  /**
274
352
  * Core hook that exposes the full search state and actions.
@@ -436,6 +514,31 @@ interface UseHighlightReturn {
436
514
  */
437
515
  declare function useHighlight({ hit, attribute, }: UseHighlightProps): UseHighlightReturn;
438
516
 
517
+ interface UseClickTrackingReturn {
518
+ /** The current search's id, when click feedback is on for your organization. */
519
+ queryId: string | undefined;
520
+ /**
521
+ * Records a click on a hit. `positionOnPage` is the hit's 0-based place in the current page;
522
+ * the absolute position is worked out from the page. Never throws: tracking must not break a
523
+ * results page, so a failed send resolves to null.
524
+ */
525
+ trackClick: (hit: Record<string, unknown>, positionOnPage?: number) => Promise<SendEventsResponse | null>;
526
+ /**
527
+ * Records a conversion of a document. It names the search the document was last clicked from
528
+ * in this tab, unless you pass a `queryId`. Never throws.
529
+ */
530
+ trackConversion: (docId: string | number, opts?: {
531
+ value?: number;
532
+ queryId?: string;
533
+ }) => Promise<SendEventsResponse | null>;
534
+ }
535
+ /**
536
+ * Click feedback events for the provider's index: `search_result_click` and `conversion`. When the
537
+ * index has "Use click feedback in ranking" on, results people pick for a query move up for that
538
+ * query.
539
+ */
540
+ declare function useClickTracking(): UseClickTrackingReturn;
541
+
439
542
  interface SearchBoxProps {
440
543
  /** Placeholder text. */
441
544
  placeholder?: string;
@@ -576,4 +679,4 @@ interface PoweredByProps {
576
679
  */
577
680
  declare function PoweredBy({ className, styled, href, }: PoweredByProps): react_jsx_runtime.JSX.Element;
578
681
 
579
- export { type ExperimentAssignment, type ExperimentConfig, type ExperimentConfigQuery, type ExperimentConfigResponse, type ExperimentConfigStatus, type ExperimentConfigsResponse, type ExperimentEventInput, type ExperimentEventResponse, type ExperimentEventType, type ExperimentSearchMetadata, type ExperimentVariantConfig, type FilterInput, type FilterValue, type HeadbandClient, type HeadbandContextValue, HeadbandError, HeadbandProvider, type HeadbandProviderProps, Highlight, type HighlightProps, type Hit, Hits, type HitsProps, type HostedExperimentAssignmentQuery, type HostedExperimentAssignmentResponse, type MultiSearchQuery, type MultiSearchResponse, Pagination, type PaginationProps, PoweredBy, type PoweredByProps, RangeInput, type RangeInputProps, type RangeMap, RefinementList, type RefinementListItem, type RefinementListProps, type RefinementMap, type SearchActions, SearchBox, type SearchBoxProps, type SearchMode, type SearchParams, type SearchResponse, type SearchState, SortBy, type SortByItem, type SortByProps, Stats, type StatsProps, type UseHighlightProps, type UseHighlightReturn, type UseHitsReturn, type UsePaginationReturn, type UseRangeProps, type UseRangeReturn, type UseRefinementListProps, type UseRefinementListReturn, type UseSearchBoxReturn, type UseSortByProps, type UseSortByReturn, type UseStatsReturn, type WarmInput, type WarmLevel, type WarmResponse, andFilters, assignExperiment, buildExperimentConfigPath, chooseExperimentVariant, combineFilters, experimentSearchMetadata, filterEq, filterExists, filterIn, filterNotEq, filterRange, headband, isRunnableExperiment, orFilters, quoteFilterValue, useHeadbandContext, useHighlight, useHits, usePagination, useRange, useRefinementList, useSearch, useSearchBox, useSortBy, useStats };
682
+ export { type ClickTrackingConfig, type ExperimentAssignment, type ExperimentConfig, type ExperimentConfigQuery, type ExperimentConfigResponse, type ExperimentConfigStatus, type ExperimentConfigsResponse, type ExperimentEventInput, type ExperimentEventResponse, type ExperimentEventType, type ExperimentSearchMetadata, type ExperimentVariantConfig, type FilterInput, type FilterValue, type HeadbandClient, type HeadbandContextValue, HeadbandError, HeadbandProvider, type HeadbandProviderProps, Highlight, type HighlightProps, type Hit, Hits, type HitsProps, type HostedExperimentAssignmentQuery, type HostedExperimentAssignmentResponse, type MultiSearchQuery, type MultiSearchResponse, Pagination, type PaginationProps, PoweredBy, type PoweredByProps, RangeInput, type RangeInputProps, type RangeMap, RefinementList, type RefinementListItem, type RefinementListProps, type RefinementMap, type SearchActions, SearchBox, type SearchBoxProps, type SearchEvent, type SearchEventRejectReason, type SearchEventType, type SearchMode, type SearchParams, type SearchResponse, type SearchState, type SendEventsResponse, SortBy, type SortByItem, type SortByProps, Stats, type StatsProps, type UseClickTrackingReturn, type UseHighlightProps, type UseHighlightReturn, type UseHitsReturn, type UsePaginationReturn, type UseRangeProps, type UseRangeReturn, type UseRefinementListProps, type UseRefinementListReturn, type UseSearchBoxReturn, type UseSortByProps, type UseSortByReturn, type UseStatsReturn, type WarmInput, type WarmLevel, type WarmResponse, andFilters, assignExperiment, browserSessionId, buildExperimentConfigPath, chooseExperimentVariant, combineFilters, experimentSearchMetadata, filterEq, filterExists, filterIn, filterNotEq, filterRange, headband, isRunnableExperiment, orFilters, quoteFilterValue, useClickTracking, useHeadbandContext, useHighlight, useHits, usePagination, useRange, useRefinementList, useSearch, useSearchBox, useSortBy, useStats };
package/dist/index.d.ts CHANGED
@@ -91,10 +91,50 @@ declare function assignExperiment(config: ExperimentConfig, subjectId: string, o
91
91
  }): ExperimentAssignment | null;
92
92
  declare function experimentSearchMetadata(assignment: ExperimentAssignment | null | undefined): ExperimentSearchMetadata | undefined;
93
93
 
94
+ /**
95
+ * Search events for click feedback: `search_result_click` and `conversion`.
96
+ *
97
+ * A search response carries a `queryId` when click feedback is on for your organization. Send it
98
+ * with a click so Headband knows which search showed the result. Clicks and conversions are
99
+ * counted per query; when an index has "Use click feedback in ranking" on, results people pick
100
+ * for a query move up for that query. No experiment is needed.
101
+ */
102
+ type SearchEventType = "search_result_click" | "conversion";
103
+ interface SearchEvent {
104
+ type: SearchEventType;
105
+ /** The index the result came from. */
106
+ index: string;
107
+ /** The clicked or converted document's id (its primary key). */
108
+ docId: string | number;
109
+ /** The `queryId` of the search that showed the result. */
110
+ queryId?: string;
111
+ /** 1-based position of the result in the whole result list, not just the page. */
112
+ position?: number;
113
+ /** A stable id for the visitor's session. Strongly recommended: events without one count half. */
114
+ sessionId?: string;
115
+ /** Optional, non-negative: an order value for a conversion. */
116
+ value?: number;
117
+ }
118
+ type SearchEventRejectReason = "invalid_event" | "invalid_type" | "invalid_index" | "invalid_doc_id" | "invalid_query_id" | "invalid_position" | "invalid_session_id" | "invalid_value" | "index_not_found" | "bot";
119
+ interface SendEventsResponse {
120
+ accepted: number;
121
+ /** `index` is the event's position in the array you sent. */
122
+ rejected: Array<{
123
+ index: number;
124
+ reason: SearchEventRejectReason;
125
+ }>;
126
+ }
127
+ /**
128
+ * A session id for this browser tab, kept in sessionStorage when it is available and in memory
129
+ * otherwise. It identifies nothing about the visitor; Headband stores it only as a keyed hash.
130
+ */
131
+ declare function browserSessionId(): string;
132
+
94
133
  /**
95
134
  * Headband search client.
96
135
  *
97
- * Makes requests to the Headband `/v1/search`, `/v1/multi-search` and `/v1/warm` endpoints.
136
+ * Makes requests to the Headband `/v1/search`, `/v1/multi-search`, `/v1/warm` and
137
+ * `/v1/relevance/events` endpoints.
98
138
  */
99
139
 
100
140
  /**
@@ -124,6 +164,11 @@ interface SearchParams {
124
164
  }
125
165
  interface SearchResponse {
126
166
  hits: Record<string, unknown>[];
167
+ /**
168
+ * The id of this search, `q_` and 16 characters, when click feedback is on for your
169
+ * organization. Send it with clicks (`trackClick`) so Headband knows which search showed them.
170
+ */
171
+ queryId?: string;
127
172
  estimatedTotalHits?: number;
128
173
  totalHits?: number;
129
174
  processingTimeMs: number;
@@ -182,6 +227,15 @@ interface HeadbandClient {
182
227
  warm(target: WarmInput, opts?: {
183
228
  level?: WarmLevel;
184
229
  }): Promise<WarmResponse>;
230
+ /** Sends up to 100 click and conversion events (POST /v1/relevance/events). */
231
+ sendEvents(events: SearchEvent[]): Promise<SendEventsResponse>;
232
+ /**
233
+ * Records a click on a search result. Pass the search's `queryId`; the client remembers it for
234
+ * the document so a later `trackConversion` names the same search.
235
+ */
236
+ trackClick(event: Omit<SearchEvent, "type">): Promise<SendEventsResponse>;
237
+ /** Records a conversion (a purchase, a sign-up) of a document found by search. */
238
+ trackConversion(event: Omit<SearchEvent, "type" | "position">): Promise<SendEventsResponse>;
185
239
  }
186
240
  declare class HeadbandError extends Error {
187
241
  readonly status: number;
@@ -243,11 +297,19 @@ interface SearchActions {
243
297
  unregisterFacet: (attribute: string) => void;
244
298
  refresh: () => void;
245
299
  }
300
+ /** How useClickTracking finds a hit's id and the visitor's session. */
301
+ interface ClickTrackingConfig {
302
+ /** The field hits carry the document id in. Default `id`. */
303
+ idField: string;
304
+ /** The session id events carry; a per-tab id when absent. */
305
+ sessionId?: string;
306
+ }
246
307
  interface HeadbandContextValue {
247
308
  client: HeadbandClient;
248
309
  index: string;
249
310
  state: SearchState;
250
311
  actions: SearchActions;
312
+ tracking: ClickTrackingConfig;
251
313
  }
252
314
  declare function useHeadbandContext(): HeadbandContextValue;
253
315
  interface HeadbandProviderProps {
@@ -266,9 +328,25 @@ interface HeadbandProviderProps {
266
328
  * for longer queries). Indexes on the other engines ignore it.
267
329
  */
268
330
  mode?: SearchMode;
331
+ /**
332
+ * Store-style facet counts (default true). With a value ticked in a facet list, that list still
333
+ * counts its other values, so a shopper can tick a second one. Each facet with a ticked value
334
+ * costs one more search per request. Set false for one search per request, and lists that narrow
335
+ * to the ticked values.
336
+ */
337
+ disjunctiveFacets?: boolean;
338
+ /**
339
+ * The field your documents' ids are in, for click feedback (`useClickTracking`). Default `id`.
340
+ */
341
+ idField?: string;
342
+ /**
343
+ * A stable id for the visitor's session, sent with click and conversion events. Without it the
344
+ * experiment's session id is used, else an id kept for this browser tab.
345
+ */
346
+ sessionId?: string;
269
347
  children: ReactNode;
270
348
  }
271
- declare function HeadbandProvider({ client, index, initialQuery, experiment, baseFilter, mode, children, }: HeadbandProviderProps): react_jsx_runtime.JSX.Element;
349
+ declare function HeadbandProvider({ client, index, initialQuery, experiment, baseFilter, mode, disjunctiveFacets, idField, sessionId, children, }: HeadbandProviderProps): react_jsx_runtime.JSX.Element;
272
350
 
273
351
  /**
274
352
  * Core hook that exposes the full search state and actions.
@@ -436,6 +514,31 @@ interface UseHighlightReturn {
436
514
  */
437
515
  declare function useHighlight({ hit, attribute, }: UseHighlightProps): UseHighlightReturn;
438
516
 
517
+ interface UseClickTrackingReturn {
518
+ /** The current search's id, when click feedback is on for your organization. */
519
+ queryId: string | undefined;
520
+ /**
521
+ * Records a click on a hit. `positionOnPage` is the hit's 0-based place in the current page;
522
+ * the absolute position is worked out from the page. Never throws: tracking must not break a
523
+ * results page, so a failed send resolves to null.
524
+ */
525
+ trackClick: (hit: Record<string, unknown>, positionOnPage?: number) => Promise<SendEventsResponse | null>;
526
+ /**
527
+ * Records a conversion of a document. It names the search the document was last clicked from
528
+ * in this tab, unless you pass a `queryId`. Never throws.
529
+ */
530
+ trackConversion: (docId: string | number, opts?: {
531
+ value?: number;
532
+ queryId?: string;
533
+ }) => Promise<SendEventsResponse | null>;
534
+ }
535
+ /**
536
+ * Click feedback events for the provider's index: `search_result_click` and `conversion`. When the
537
+ * index has "Use click feedback in ranking" on, results people pick for a query move up for that
538
+ * query.
539
+ */
540
+ declare function useClickTracking(): UseClickTrackingReturn;
541
+
439
542
  interface SearchBoxProps {
440
543
  /** Placeholder text. */
441
544
  placeholder?: string;
@@ -576,4 +679,4 @@ interface PoweredByProps {
576
679
  */
577
680
  declare function PoweredBy({ className, styled, href, }: PoweredByProps): react_jsx_runtime.JSX.Element;
578
681
 
579
- export { type ExperimentAssignment, type ExperimentConfig, type ExperimentConfigQuery, type ExperimentConfigResponse, type ExperimentConfigStatus, type ExperimentConfigsResponse, type ExperimentEventInput, type ExperimentEventResponse, type ExperimentEventType, type ExperimentSearchMetadata, type ExperimentVariantConfig, type FilterInput, type FilterValue, type HeadbandClient, type HeadbandContextValue, HeadbandError, HeadbandProvider, type HeadbandProviderProps, Highlight, type HighlightProps, type Hit, Hits, type HitsProps, type HostedExperimentAssignmentQuery, type HostedExperimentAssignmentResponse, type MultiSearchQuery, type MultiSearchResponse, Pagination, type PaginationProps, PoweredBy, type PoweredByProps, RangeInput, type RangeInputProps, type RangeMap, RefinementList, type RefinementListItem, type RefinementListProps, type RefinementMap, type SearchActions, SearchBox, type SearchBoxProps, type SearchMode, type SearchParams, type SearchResponse, type SearchState, SortBy, type SortByItem, type SortByProps, Stats, type StatsProps, type UseHighlightProps, type UseHighlightReturn, type UseHitsReturn, type UsePaginationReturn, type UseRangeProps, type UseRangeReturn, type UseRefinementListProps, type UseRefinementListReturn, type UseSearchBoxReturn, type UseSortByProps, type UseSortByReturn, type UseStatsReturn, type WarmInput, type WarmLevel, type WarmResponse, andFilters, assignExperiment, buildExperimentConfigPath, chooseExperimentVariant, combineFilters, experimentSearchMetadata, filterEq, filterExists, filterIn, filterNotEq, filterRange, headband, isRunnableExperiment, orFilters, quoteFilterValue, useHeadbandContext, useHighlight, useHits, usePagination, useRange, useRefinementList, useSearch, useSearchBox, useSortBy, useStats };
682
+ export { type ClickTrackingConfig, type ExperimentAssignment, type ExperimentConfig, type ExperimentConfigQuery, type ExperimentConfigResponse, type ExperimentConfigStatus, type ExperimentConfigsResponse, type ExperimentEventInput, type ExperimentEventResponse, type ExperimentEventType, type ExperimentSearchMetadata, type ExperimentVariantConfig, type FilterInput, type FilterValue, type HeadbandClient, type HeadbandContextValue, HeadbandError, HeadbandProvider, type HeadbandProviderProps, Highlight, type HighlightProps, type Hit, Hits, type HitsProps, type HostedExperimentAssignmentQuery, type HostedExperimentAssignmentResponse, type MultiSearchQuery, type MultiSearchResponse, Pagination, type PaginationProps, PoweredBy, type PoweredByProps, RangeInput, type RangeInputProps, type RangeMap, RefinementList, type RefinementListItem, type RefinementListProps, type RefinementMap, type SearchActions, SearchBox, type SearchBoxProps, type SearchEvent, type SearchEventRejectReason, type SearchEventType, type SearchMode, type SearchParams, type SearchResponse, type SearchState, type SendEventsResponse, SortBy, type SortByItem, type SortByProps, Stats, type StatsProps, type UseClickTrackingReturn, type UseHighlightProps, type UseHighlightReturn, type UseHitsReturn, type UsePaginationReturn, type UseRangeProps, type UseRangeReturn, type UseRefinementListProps, type UseRefinementListReturn, type UseSearchBoxReturn, type UseSortByProps, type UseSortByReturn, type UseStatsReturn, type WarmInput, type WarmLevel, type WarmResponse, andFilters, assignExperiment, browserSessionId, buildExperimentConfigPath, chooseExperimentVariant, combineFilters, experimentSearchMetadata, filterEq, filterExists, filterIn, filterNotEq, filterRange, headband, isRunnableExperiment, orFilters, quoteFilterValue, useClickTracking, useHeadbandContext, useHighlight, useHits, usePagination, useRange, useRefinementList, useSearch, useSearchBox, useSortBy, useStats };
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ __export(index_exports, {
34
34
  Stats: () => Stats,
35
35
  andFilters: () => andFilters,
36
36
  assignExperiment: () => assignExperiment,
37
+ browserSessionId: () => browserSessionId,
37
38
  buildExperimentConfigPath: () => buildExperimentConfigPath,
38
39
  chooseExperimentVariant: () => chooseExperimentVariant,
39
40
  combineFilters: () => combineFilters,
@@ -47,6 +48,7 @@ __export(index_exports, {
47
48
  isRunnableExperiment: () => isRunnableExperiment,
48
49
  orFilters: () => orFilters,
49
50
  quoteFilterValue: () => quoteFilterValue,
51
+ useClickTracking: () => useClickTracking,
50
52
  useHeadbandContext: () => useHeadbandContext,
51
53
  useHighlight: () => useHighlight,
52
54
  useHits: () => useHits,
@@ -124,6 +126,69 @@ function experimentSearchMetadata(assignment) {
124
126
  };
125
127
  }
126
128
 
129
+ // src/events.ts
130
+ var SESSION_KEY = "headband.sessionId";
131
+ var CLICKS_KEY = "headband.clickedQueries";
132
+ var CLICKS_MAX = 50;
133
+ var memorySession = null;
134
+ var memoryClicks = [];
135
+ function randomId() {
136
+ const c = globalThis.crypto;
137
+ if (c?.randomUUID) return `s_${c.randomUUID().replace(/-/g, "").slice(0, 20)}`;
138
+ return `s_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(
139
+ 0,
140
+ 22
141
+ );
142
+ }
143
+ function storage() {
144
+ try {
145
+ return typeof sessionStorage === "undefined" ? null : sessionStorage;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+ function browserSessionId() {
151
+ const s = storage();
152
+ try {
153
+ const stored = s?.getItem(SESSION_KEY);
154
+ if (stored) return stored;
155
+ } catch {
156
+ }
157
+ if (!memorySession) memorySession = randomId();
158
+ try {
159
+ s?.setItem(SESSION_KEY, memorySession);
160
+ } catch {
161
+ }
162
+ return memorySession;
163
+ }
164
+ function readClicks() {
165
+ const s = storage();
166
+ try {
167
+ const raw = s?.getItem(CLICKS_KEY);
168
+ if (raw) {
169
+ const parsed = JSON.parse(raw);
170
+ if (Array.isArray(parsed)) return parsed;
171
+ }
172
+ } catch {
173
+ }
174
+ return memoryClicks;
175
+ }
176
+ function rememberClick(index, docId, queryId) {
177
+ const k = `${index}
178
+ ${String(docId)}`;
179
+ const next = [...readClicks().filter(([key]) => key !== k), [k, queryId]];
180
+ memoryClicks = next.slice(-CLICKS_MAX);
181
+ try {
182
+ storage()?.setItem(CLICKS_KEY, JSON.stringify(memoryClicks));
183
+ } catch {
184
+ }
185
+ }
186
+ function clickedFrom(index, docId) {
187
+ const k = `${index}
188
+ ${String(docId)}`;
189
+ return readClicks().find(([key]) => key === k)?.[1];
190
+ }
191
+
127
192
  // src/client.ts
128
193
  var HeadbandError = class extends Error {
129
194
  constructor(message, status, code) {
@@ -145,7 +210,9 @@ function headband(host, apiKey) {
145
210
  ...method === "POST" ? { "Content-Type": "application/json" } : {},
146
211
  Authorization: `Bearer ${apiKey}`
147
212
  },
148
- ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {}
213
+ ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {},
214
+ // An event sent as the page navigates away (a click on a result link) still arrives.
215
+ ...options.keepalive ? { keepalive: true } : {}
149
216
  });
150
217
  } catch (err) {
151
218
  throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
@@ -207,8 +274,23 @@ function headband(host, apiKey) {
207
274
  return request("/v1/warm", {
208
275
  body: { ...target, ...opts?.level ? { level: opts.level } : {} }
209
276
  });
277
+ },
278
+ sendEvents,
279
+ trackClick(event) {
280
+ if (event.queryId) rememberClick(event.index, event.docId, event.queryId);
281
+ return sendEvents([{ type: "search_result_click", ...event }]);
282
+ },
283
+ trackConversion(event) {
284
+ const queryId = event.queryId ?? clickedFrom(event.index, event.docId);
285
+ return sendEvents([{ type: "conversion", ...event, ...queryId ? { queryId } : {} }]);
210
286
  }
211
287
  };
288
+ function sendEvents(events) {
289
+ return request("/v1/relevance/events", {
290
+ body: { events },
291
+ keepalive: true
292
+ });
293
+ }
212
294
  }
213
295
 
214
296
  // src/filters.ts
@@ -297,6 +379,40 @@ function buildFilterString(refinements, ranges) {
297
379
  });
298
380
  return parts.length > 0 ? parts.join(" AND ") : void 0;
299
381
  }
382
+ function facetCountQueries(facets, refinements, ranges, baseFilter) {
383
+ const queries = [];
384
+ facets.forEach((attribute) => {
385
+ if ((refinements.get(attribute)?.size ?? 0) === 0) return;
386
+ const others = new Map(refinements);
387
+ others.delete(attribute);
388
+ queries.push({
389
+ attribute,
390
+ filter: combineFilters(baseFilter, buildFilterString(others, ranges))
391
+ });
392
+ });
393
+ return queries;
394
+ }
395
+ async function searchWithFacetCounts(client, index, query, params, countQueries) {
396
+ const [main, ...counts] = await Promise.all([
397
+ client.search(index, query, params),
398
+ ...countQueries.map(
399
+ (cq) => client.search(index, query, {
400
+ // One hit is the least every engine accepts. Only the counts are read.
401
+ limit: 1,
402
+ facets: [cq.attribute],
403
+ ...cq.filter ? { filter: cq.filter } : {},
404
+ ...params.mode ? { mode: params.mode } : {}
405
+ }).catch(() => null)
406
+ )
407
+ ]);
408
+ if (countQueries.length === 0) return main;
409
+ const facetDistribution = { ...main.facetDistribution ?? {} };
410
+ countQueries.forEach((cq, i) => {
411
+ const counted = counts[i]?.facetDistribution?.[cq.attribute];
412
+ if (counted) facetDistribution[cq.attribute] = counted;
413
+ });
414
+ return { ...main, facetDistribution };
415
+ }
300
416
  function HeadbandProvider({
301
417
  client,
302
418
  index,
@@ -304,6 +420,9 @@ function HeadbandProvider({
304
420
  experiment = null,
305
421
  baseFilter,
306
422
  mode,
423
+ disjunctiveFacets = true,
424
+ idField = "id",
425
+ sessionId,
307
426
  children
308
427
  }) {
309
428
  const [query, setQueryRaw] = (0, import_react.useState)(initialQuery);
@@ -341,7 +460,8 @@ function HeadbandProvider({
341
460
  ...mode ? { mode } : {},
342
461
  attributesToHighlight: ["*"]
343
462
  };
344
- client.search(index, query, params).then((res) => {
463
+ const countQueries = disjunctiveFacets ? facetCountQueries(facets, refinements, ranges, baseFilter) : [];
464
+ searchWithFacetCounts(client, index, query, params, countQueries).then((res) => {
345
465
  if (requestId !== requestIdRef.current) {
346
466
  return;
347
467
  }
@@ -354,7 +474,23 @@ function HeadbandProvider({
354
474
  setError(err instanceof Error ? err : new Error(String(err)));
355
475
  setIsLoading(false);
356
476
  });
357
- }, [client, index, query, filter, facetList, sortList, experiment, mode, page, hitsPerPage]);
477
+ }, [
478
+ client,
479
+ index,
480
+ query,
481
+ filter,
482
+ facetList,
483
+ sortList,
484
+ experiment,
485
+ mode,
486
+ page,
487
+ hitsPerPage,
488
+ disjunctiveFacets,
489
+ facets,
490
+ refinements,
491
+ ranges,
492
+ baseFilter
493
+ ]);
358
494
  (0, import_react.useEffect)(() => {
359
495
  const timer = setTimeout(executeSearch, 200);
360
496
  return () => {
@@ -411,9 +547,13 @@ function HeadbandProvider({
411
547
  return next;
412
548
  });
413
549
  }, []);
414
- const refresh = (0, import_react.useCallback)(() => {
415
- executeSearch();
550
+ const executeSearchRef = (0, import_react.useRef)(executeSearch);
551
+ (0, import_react.useEffect)(() => {
552
+ executeSearchRef.current = executeSearch;
416
553
  }, [executeSearch]);
554
+ const refresh = (0, import_react.useCallback)(() => {
555
+ executeSearchRef.current();
556
+ }, []);
417
557
  const state = (0, import_react.useMemo)(
418
558
  () => ({
419
559
  query,
@@ -455,9 +595,14 @@ function HeadbandProvider({
455
595
  refresh
456
596
  ]
457
597
  );
598
+ const trackingSessionId = sessionId ?? experiment?.sessionId;
599
+ const tracking = (0, import_react.useMemo)(
600
+ () => ({ idField, ...trackingSessionId ? { sessionId: trackingSessionId } : {} }),
601
+ [idField, trackingSessionId]
602
+ );
458
603
  const value = (0, import_react.useMemo)(
459
- () => ({ client, index, state, actions }),
460
- [client, index, state, actions]
604
+ () => ({ client, index, state, actions, tracking }),
605
+ [client, index, state, actions, tracking]
461
606
  );
462
607
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HeadbandContext.Provider, { value, children });
463
608
  }
@@ -708,8 +853,41 @@ function useHighlight({
708
853
  }, [hit, attribute]);
709
854
  }
710
855
 
711
- // src/components/SearchBox.tsx
856
+ // src/hooks/useClickTracking.ts
712
857
  var import_react10 = require("react");
858
+ function useClickTracking() {
859
+ const { client, index, state, tracking } = useHeadbandContext();
860
+ const queryId = state.results?.queryId;
861
+ const { page, hitsPerPage } = state;
862
+ const trackClick = (0, import_react10.useCallback)(
863
+ (hit, positionOnPage) => {
864
+ const raw = hit[tracking.idField] ?? hit.__data?.[tracking.idField];
865
+ if (typeof raw !== "string" && typeof raw !== "number") return Promise.resolve(null);
866
+ const position = typeof positionOnPage === "number" && positionOnPage >= 0 ? page * hitsPerPage + positionOnPage + 1 : void 0;
867
+ return client.trackClick({
868
+ index,
869
+ docId: raw,
870
+ ...queryId ? { queryId } : {},
871
+ ...position ? { position } : {},
872
+ sessionId: tracking.sessionId ?? browserSessionId()
873
+ }).catch(() => null);
874
+ },
875
+ [client, index, queryId, page, hitsPerPage, tracking]
876
+ );
877
+ const trackConversion = (0, import_react10.useCallback)(
878
+ (docId, opts = {}) => client.trackConversion({
879
+ index,
880
+ docId,
881
+ sessionId: tracking.sessionId ?? browserSessionId(),
882
+ ...opts
883
+ }).catch(() => null),
884
+ [client, index, tracking]
885
+ );
886
+ return { queryId, trackClick, trackConversion };
887
+ }
888
+
889
+ // src/components/SearchBox.tsx
890
+ var import_react11 = require("react");
713
891
  var import_jsx_runtime2 = require("react/jsx-runtime");
714
892
  function SearchBox({
715
893
  placeholder = "Search...",
@@ -719,8 +897,8 @@ function SearchBox({
719
897
  styled = true
720
898
  }) {
721
899
  const { query, setQuery, clear } = useSearchBox();
722
- const inputRef = (0, import_react10.useRef)(null);
723
- const handleChange = (0, import_react10.useCallback)(
900
+ const inputRef = (0, import_react11.useRef)(null);
901
+ const handleChange = (0, import_react11.useCallback)(
724
902
  (e) => {
725
903
  const value = e.target.value;
726
904
  setQuery(value);
@@ -728,15 +906,15 @@ function SearchBox({
728
906
  },
729
907
  [setQuery, onQueryChange]
730
908
  );
731
- const handleClear = (0, import_react10.useCallback)(() => {
909
+ const handleClear = (0, import_react11.useCallback)(() => {
732
910
  clear();
733
911
  onQueryChange?.("");
734
912
  inputRef.current?.focus();
735
913
  }, [clear, onQueryChange]);
736
- const handleSubmit = (0, import_react10.useCallback)((e) => {
914
+ const handleSubmit = (0, import_react11.useCallback)((e) => {
737
915
  e.preventDefault();
738
916
  }, []);
739
- const handleKeyDown = (0, import_react10.useCallback)(
917
+ const handleKeyDown = (0, import_react11.useCallback)(
740
918
  (e) => {
741
919
  if (e.key === "Escape") {
742
920
  handleClear();
@@ -777,7 +955,10 @@ function SearchBox({
777
955
  "input",
778
956
  {
779
957
  ref: inputRef,
780
- type: "search",
958
+ type: "text",
959
+ role: "searchbox",
960
+ inputMode: "search",
961
+ enterKeyHint: "search",
781
962
  value: query,
782
963
  onChange: handleChange,
783
964
  onKeyDown: handleKeyDown,
@@ -807,14 +988,7 @@ function SearchBox({
807
988
  stroke: "currentColor",
808
989
  className: "h-3.5 w-3.5",
809
990
  "aria-hidden": "true",
810
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
811
- "path",
812
- {
813
- strokeLinecap: "round",
814
- strokeLinejoin: "round",
815
- d: "M6 18 18 6M6 6l12 12"
816
- }
817
- )
991
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" })
818
992
  }
819
993
  )
820
994
  }
@@ -876,7 +1050,7 @@ function Hits({
876
1050
  }
877
1051
 
878
1052
  // src/components/RefinementList.tsx
879
- var import_react11 = require("react");
1053
+ var import_react12 = require("react");
880
1054
  var import_jsx_runtime4 = require("react/jsx-runtime");
881
1055
  function RefinementList({
882
1056
  attribute,
@@ -891,7 +1065,7 @@ function RefinementList({
891
1065
  limit,
892
1066
  sortBy
893
1067
  });
894
- const handleChange = (0, import_react11.useCallback)(
1068
+ const handleChange = (0, import_react12.useCallback)(
895
1069
  (value) => () => {
896
1070
  refine(value);
897
1071
  },
@@ -951,14 +1125,14 @@ function RefinementList({
951
1125
  }
952
1126
 
953
1127
  // src/components/Pagination.tsx
954
- var import_react12 = require("react");
1128
+ var import_react13 = require("react");
955
1129
  var import_jsx_runtime5 = require("react/jsx-runtime");
956
1130
  function Pagination({ className, styled = true }) {
957
1131
  const { currentPage, totalPages, setPage, canPrevious, canNext, pages } = usePagination();
958
- const handlePrevious = (0, import_react12.useCallback)(() => {
1132
+ const handlePrevious = (0, import_react13.useCallback)(() => {
959
1133
  setPage(currentPage - 1);
960
1134
  }, [setPage, currentPage]);
961
- const handleNext = (0, import_react12.useCallback)(() => {
1135
+ const handleNext = (0, import_react13.useCallback)(() => {
962
1136
  setPage(currentPage + 1);
963
1137
  }, [setPage, currentPage]);
964
1138
  if (totalPages <= 1) return null;
@@ -1061,7 +1235,7 @@ function Stats({ className, styled = true, render }) {
1061
1235
  }
1062
1236
 
1063
1237
  // src/components/SortBy.tsx
1064
- var import_react13 = require("react");
1238
+ var import_react14 = require("react");
1065
1239
  var import_jsx_runtime7 = require("react/jsx-runtime");
1066
1240
  function SortBy({
1067
1241
  items: itemsProp,
@@ -1070,7 +1244,7 @@ function SortBy({
1070
1244
  label = "Sort by"
1071
1245
  }) {
1072
1246
  const { currentSort, setSort, items } = useSortBy({ items: itemsProp });
1073
- const handleChange = (0, import_react13.useCallback)(
1247
+ const handleChange = (0, import_react14.useCallback)(
1074
1248
  (e) => {
1075
1249
  const value = e.target.value;
1076
1250
  setSort(value === "" ? null : value);
@@ -1121,7 +1295,7 @@ function Highlight({
1121
1295
  }
1122
1296
 
1123
1297
  // src/components/RangeInput.tsx
1124
- var import_react14 = require("react");
1298
+ var import_react15 = require("react");
1125
1299
  var import_jsx_runtime9 = require("react/jsx-runtime");
1126
1300
  function RangeInput({
1127
1301
  attribute,
@@ -1135,7 +1309,7 @@ function RangeInput({
1135
1309
  const defaultMin = min !== void 0 ? String(min) : "";
1136
1310
  const defaultMax = max !== void 0 ? String(max) : "";
1137
1311
  const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
1138
- const handleSubmit = (0, import_react14.useCallback)(
1312
+ const handleSubmit = (0, import_react15.useCallback)(
1139
1313
  (e) => {
1140
1314
  e.preventDefault();
1141
1315
  const formData = new FormData(e.currentTarget);
@@ -1281,6 +1455,7 @@ function PoweredBy({
1281
1455
  Stats,
1282
1456
  andFilters,
1283
1457
  assignExperiment,
1458
+ browserSessionId,
1284
1459
  buildExperimentConfigPath,
1285
1460
  chooseExperimentVariant,
1286
1461
  combineFilters,
@@ -1294,6 +1469,7 @@ function PoweredBy({
1294
1469
  isRunnableExperiment,
1295
1470
  orFilters,
1296
1471
  quoteFilterValue,
1472
+ useClickTracking,
1297
1473
  useHeadbandContext,
1298
1474
  useHighlight,
1299
1475
  useHits,
package/dist/index.mjs CHANGED
@@ -64,6 +64,69 @@ function experimentSearchMetadata(assignment) {
64
64
  };
65
65
  }
66
66
 
67
+ // src/events.ts
68
+ var SESSION_KEY = "headband.sessionId";
69
+ var CLICKS_KEY = "headband.clickedQueries";
70
+ var CLICKS_MAX = 50;
71
+ var memorySession = null;
72
+ var memoryClicks = [];
73
+ function randomId() {
74
+ const c = globalThis.crypto;
75
+ if (c?.randomUUID) return `s_${c.randomUUID().replace(/-/g, "").slice(0, 20)}`;
76
+ return `s_${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`.slice(
77
+ 0,
78
+ 22
79
+ );
80
+ }
81
+ function storage() {
82
+ try {
83
+ return typeof sessionStorage === "undefined" ? null : sessionStorage;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ function browserSessionId() {
89
+ const s = storage();
90
+ try {
91
+ const stored = s?.getItem(SESSION_KEY);
92
+ if (stored) return stored;
93
+ } catch {
94
+ }
95
+ if (!memorySession) memorySession = randomId();
96
+ try {
97
+ s?.setItem(SESSION_KEY, memorySession);
98
+ } catch {
99
+ }
100
+ return memorySession;
101
+ }
102
+ function readClicks() {
103
+ const s = storage();
104
+ try {
105
+ const raw = s?.getItem(CLICKS_KEY);
106
+ if (raw) {
107
+ const parsed = JSON.parse(raw);
108
+ if (Array.isArray(parsed)) return parsed;
109
+ }
110
+ } catch {
111
+ }
112
+ return memoryClicks;
113
+ }
114
+ function rememberClick(index, docId, queryId) {
115
+ const k = `${index}
116
+ ${String(docId)}`;
117
+ const next = [...readClicks().filter(([key]) => key !== k), [k, queryId]];
118
+ memoryClicks = next.slice(-CLICKS_MAX);
119
+ try {
120
+ storage()?.setItem(CLICKS_KEY, JSON.stringify(memoryClicks));
121
+ } catch {
122
+ }
123
+ }
124
+ function clickedFrom(index, docId) {
125
+ const k = `${index}
126
+ ${String(docId)}`;
127
+ return readClicks().find(([key]) => key === k)?.[1];
128
+ }
129
+
67
130
  // src/client.ts
68
131
  var HeadbandError = class extends Error {
69
132
  constructor(message, status, code) {
@@ -85,7 +148,9 @@ function headband(host, apiKey) {
85
148
  ...method === "POST" ? { "Content-Type": "application/json" } : {},
86
149
  Authorization: `Bearer ${apiKey}`
87
150
  },
88
- ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {}
151
+ ...method === "POST" ? { body: JSON.stringify(options.body ?? {}) } : {},
152
+ // An event sent as the page navigates away (a click on a result link) still arrives.
153
+ ...options.keepalive ? { keepalive: true } : {}
89
154
  });
90
155
  } catch (err) {
91
156
  throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
@@ -147,8 +212,23 @@ function headband(host, apiKey) {
147
212
  return request("/v1/warm", {
148
213
  body: { ...target, ...opts?.level ? { level: opts.level } : {} }
149
214
  });
215
+ },
216
+ sendEvents,
217
+ trackClick(event) {
218
+ if (event.queryId) rememberClick(event.index, event.docId, event.queryId);
219
+ return sendEvents([{ type: "search_result_click", ...event }]);
220
+ },
221
+ trackConversion(event) {
222
+ const queryId = event.queryId ?? clickedFrom(event.index, event.docId);
223
+ return sendEvents([{ type: "conversion", ...event, ...queryId ? { queryId } : {} }]);
150
224
  }
151
225
  };
226
+ function sendEvents(events) {
227
+ return request("/v1/relevance/events", {
228
+ body: { events },
229
+ keepalive: true
230
+ });
231
+ }
152
232
  }
153
233
 
154
234
  // src/filters.ts
@@ -245,6 +325,40 @@ function buildFilterString(refinements, ranges) {
245
325
  });
246
326
  return parts.length > 0 ? parts.join(" AND ") : void 0;
247
327
  }
328
+ function facetCountQueries(facets, refinements, ranges, baseFilter) {
329
+ const queries = [];
330
+ facets.forEach((attribute) => {
331
+ if ((refinements.get(attribute)?.size ?? 0) === 0) return;
332
+ const others = new Map(refinements);
333
+ others.delete(attribute);
334
+ queries.push({
335
+ attribute,
336
+ filter: combineFilters(baseFilter, buildFilterString(others, ranges))
337
+ });
338
+ });
339
+ return queries;
340
+ }
341
+ async function searchWithFacetCounts(client, index, query, params, countQueries) {
342
+ const [main, ...counts] = await Promise.all([
343
+ client.search(index, query, params),
344
+ ...countQueries.map(
345
+ (cq) => client.search(index, query, {
346
+ // One hit is the least every engine accepts. Only the counts are read.
347
+ limit: 1,
348
+ facets: [cq.attribute],
349
+ ...cq.filter ? { filter: cq.filter } : {},
350
+ ...params.mode ? { mode: params.mode } : {}
351
+ }).catch(() => null)
352
+ )
353
+ ]);
354
+ if (countQueries.length === 0) return main;
355
+ const facetDistribution = { ...main.facetDistribution ?? {} };
356
+ countQueries.forEach((cq, i) => {
357
+ const counted = counts[i]?.facetDistribution?.[cq.attribute];
358
+ if (counted) facetDistribution[cq.attribute] = counted;
359
+ });
360
+ return { ...main, facetDistribution };
361
+ }
248
362
  function HeadbandProvider({
249
363
  client,
250
364
  index,
@@ -252,6 +366,9 @@ function HeadbandProvider({
252
366
  experiment = null,
253
367
  baseFilter,
254
368
  mode,
369
+ disjunctiveFacets = true,
370
+ idField = "id",
371
+ sessionId,
255
372
  children
256
373
  }) {
257
374
  const [query, setQueryRaw] = useState(initialQuery);
@@ -289,7 +406,8 @@ function HeadbandProvider({
289
406
  ...mode ? { mode } : {},
290
407
  attributesToHighlight: ["*"]
291
408
  };
292
- client.search(index, query, params).then((res) => {
409
+ const countQueries = disjunctiveFacets ? facetCountQueries(facets, refinements, ranges, baseFilter) : [];
410
+ searchWithFacetCounts(client, index, query, params, countQueries).then((res) => {
293
411
  if (requestId !== requestIdRef.current) {
294
412
  return;
295
413
  }
@@ -302,7 +420,23 @@ function HeadbandProvider({
302
420
  setError(err instanceof Error ? err : new Error(String(err)));
303
421
  setIsLoading(false);
304
422
  });
305
- }, [client, index, query, filter, facetList, sortList, experiment, mode, page, hitsPerPage]);
423
+ }, [
424
+ client,
425
+ index,
426
+ query,
427
+ filter,
428
+ facetList,
429
+ sortList,
430
+ experiment,
431
+ mode,
432
+ page,
433
+ hitsPerPage,
434
+ disjunctiveFacets,
435
+ facets,
436
+ refinements,
437
+ ranges,
438
+ baseFilter
439
+ ]);
306
440
  useEffect(() => {
307
441
  const timer = setTimeout(executeSearch, 200);
308
442
  return () => {
@@ -359,9 +493,13 @@ function HeadbandProvider({
359
493
  return next;
360
494
  });
361
495
  }, []);
362
- const refresh = useCallback(() => {
363
- executeSearch();
496
+ const executeSearchRef = useRef(executeSearch);
497
+ useEffect(() => {
498
+ executeSearchRef.current = executeSearch;
364
499
  }, [executeSearch]);
500
+ const refresh = useCallback(() => {
501
+ executeSearchRef.current();
502
+ }, []);
365
503
  const state = useMemo(
366
504
  () => ({
367
505
  query,
@@ -403,9 +541,14 @@ function HeadbandProvider({
403
541
  refresh
404
542
  ]
405
543
  );
544
+ const trackingSessionId = sessionId ?? experiment?.sessionId;
545
+ const tracking = useMemo(
546
+ () => ({ idField, ...trackingSessionId ? { sessionId: trackingSessionId } : {} }),
547
+ [idField, trackingSessionId]
548
+ );
406
549
  const value = useMemo(
407
- () => ({ client, index, state, actions }),
408
- [client, index, state, actions]
550
+ () => ({ client, index, state, actions, tracking }),
551
+ [client, index, state, actions, tracking]
409
552
  );
410
553
  return /* @__PURE__ */ jsx(HeadbandContext.Provider, { value, children });
411
554
  }
@@ -656,11 +799,41 @@ function useHighlight({
656
799
  }, [hit, attribute]);
657
800
  }
658
801
 
802
+ // src/hooks/useClickTracking.ts
803
+ import { useCallback as useCallback7 } from "react";
804
+ function useClickTracking() {
805
+ const { client, index, state, tracking } = useHeadbandContext();
806
+ const queryId = state.results?.queryId;
807
+ const { page, hitsPerPage } = state;
808
+ const trackClick = useCallback7(
809
+ (hit, positionOnPage) => {
810
+ const raw = hit[tracking.idField] ?? hit.__data?.[tracking.idField];
811
+ if (typeof raw !== "string" && typeof raw !== "number") return Promise.resolve(null);
812
+ const position = typeof positionOnPage === "number" && positionOnPage >= 0 ? page * hitsPerPage + positionOnPage + 1 : void 0;
813
+ return client.trackClick({
814
+ index,
815
+ docId: raw,
816
+ ...queryId ? { queryId } : {},
817
+ ...position ? { position } : {},
818
+ sessionId: tracking.sessionId ?? browserSessionId()
819
+ }).catch(() => null);
820
+ },
821
+ [client, index, queryId, page, hitsPerPage, tracking]
822
+ );
823
+ const trackConversion = useCallback7(
824
+ (docId, opts = {}) => client.trackConversion({
825
+ index,
826
+ docId,
827
+ sessionId: tracking.sessionId ?? browserSessionId(),
828
+ ...opts
829
+ }).catch(() => null),
830
+ [client, index, tracking]
831
+ );
832
+ return { queryId, trackClick, trackConversion };
833
+ }
834
+
659
835
  // src/components/SearchBox.tsx
660
- import {
661
- useCallback as useCallback7,
662
- useRef as useRef2
663
- } from "react";
836
+ import { useCallback as useCallback8, useRef as useRef2 } from "react";
664
837
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
665
838
  function SearchBox({
666
839
  placeholder = "Search...",
@@ -671,7 +844,7 @@ function SearchBox({
671
844
  }) {
672
845
  const { query, setQuery, clear } = useSearchBox();
673
846
  const inputRef = useRef2(null);
674
- const handleChange = useCallback7(
847
+ const handleChange = useCallback8(
675
848
  (e) => {
676
849
  const value = e.target.value;
677
850
  setQuery(value);
@@ -679,15 +852,15 @@ function SearchBox({
679
852
  },
680
853
  [setQuery, onQueryChange]
681
854
  );
682
- const handleClear = useCallback7(() => {
855
+ const handleClear = useCallback8(() => {
683
856
  clear();
684
857
  onQueryChange?.("");
685
858
  inputRef.current?.focus();
686
859
  }, [clear, onQueryChange]);
687
- const handleSubmit = useCallback7((e) => {
860
+ const handleSubmit = useCallback8((e) => {
688
861
  e.preventDefault();
689
862
  }, []);
690
- const handleKeyDown = useCallback7(
863
+ const handleKeyDown = useCallback8(
691
864
  (e) => {
692
865
  if (e.key === "Escape") {
693
866
  handleClear();
@@ -728,7 +901,10 @@ function SearchBox({
728
901
  "input",
729
902
  {
730
903
  ref: inputRef,
731
- type: "search",
904
+ type: "text",
905
+ role: "searchbox",
906
+ inputMode: "search",
907
+ enterKeyHint: "search",
732
908
  value: query,
733
909
  onChange: handleChange,
734
910
  onKeyDown: handleKeyDown,
@@ -758,14 +934,7 @@ function SearchBox({
758
934
  stroke: "currentColor",
759
935
  className: "h-3.5 w-3.5",
760
936
  "aria-hidden": "true",
761
- children: /* @__PURE__ */ jsx2(
762
- "path",
763
- {
764
- strokeLinecap: "round",
765
- strokeLinejoin: "round",
766
- d: "M6 18 18 6M6 6l12 12"
767
- }
768
- )
937
+ children: /* @__PURE__ */ jsx2("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" })
769
938
  }
770
939
  )
771
940
  }
@@ -827,7 +996,7 @@ function Hits({
827
996
  }
828
997
 
829
998
  // src/components/RefinementList.tsx
830
- import { useCallback as useCallback8 } from "react";
999
+ import { useCallback as useCallback9 } from "react";
831
1000
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
832
1001
  function RefinementList({
833
1002
  attribute,
@@ -842,7 +1011,7 @@ function RefinementList({
842
1011
  limit,
843
1012
  sortBy
844
1013
  });
845
- const handleChange = useCallback8(
1014
+ const handleChange = useCallback9(
846
1015
  (value) => () => {
847
1016
  refine(value);
848
1017
  },
@@ -902,14 +1071,14 @@ function RefinementList({
902
1071
  }
903
1072
 
904
1073
  // src/components/Pagination.tsx
905
- import { useCallback as useCallback9 } from "react";
1074
+ import { useCallback as useCallback10 } from "react";
906
1075
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
907
1076
  function Pagination({ className, styled = true }) {
908
1077
  const { currentPage, totalPages, setPage, canPrevious, canNext, pages } = usePagination();
909
- const handlePrevious = useCallback9(() => {
1078
+ const handlePrevious = useCallback10(() => {
910
1079
  setPage(currentPage - 1);
911
1080
  }, [setPage, currentPage]);
912
- const handleNext = useCallback9(() => {
1081
+ const handleNext = useCallback10(() => {
913
1082
  setPage(currentPage + 1);
914
1083
  }, [setPage, currentPage]);
915
1084
  if (totalPages <= 1) return null;
@@ -1012,7 +1181,7 @@ function Stats({ className, styled = true, render }) {
1012
1181
  }
1013
1182
 
1014
1183
  // src/components/SortBy.tsx
1015
- import { useCallback as useCallback10 } from "react";
1184
+ import { useCallback as useCallback11 } from "react";
1016
1185
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1017
1186
  function SortBy({
1018
1187
  items: itemsProp,
@@ -1021,7 +1190,7 @@ function SortBy({
1021
1190
  label = "Sort by"
1022
1191
  }) {
1023
1192
  const { currentSort, setSort, items } = useSortBy({ items: itemsProp });
1024
- const handleChange = useCallback10(
1193
+ const handleChange = useCallback11(
1025
1194
  (e) => {
1026
1195
  const value = e.target.value;
1027
1196
  setSort(value === "" ? null : value);
@@ -1072,7 +1241,7 @@ function Highlight({
1072
1241
  }
1073
1242
 
1074
1243
  // src/components/RangeInput.tsx
1075
- import { useCallback as useCallback11 } from "react";
1244
+ import { useCallback as useCallback12 } from "react";
1076
1245
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
1077
1246
  function RangeInput({
1078
1247
  attribute,
@@ -1086,7 +1255,7 @@ function RangeInput({
1086
1255
  const defaultMin = min !== void 0 ? String(min) : "";
1087
1256
  const defaultMax = max !== void 0 ? String(max) : "";
1088
1257
  const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
1089
- const handleSubmit = useCallback11(
1258
+ const handleSubmit = useCallback12(
1090
1259
  (e) => {
1091
1260
  e.preventDefault();
1092
1261
  const formData = new FormData(e.currentTarget);
@@ -1231,6 +1400,7 @@ export {
1231
1400
  Stats,
1232
1401
  andFilters,
1233
1402
  assignExperiment,
1403
+ browserSessionId,
1234
1404
  buildExperimentConfigPath,
1235
1405
  chooseExperimentVariant,
1236
1406
  combineFilters,
@@ -1244,6 +1414,7 @@ export {
1244
1414
  isRunnableExperiment,
1245
1415
  orFilters,
1246
1416
  quoteFilterValue,
1417
+ useClickTracking,
1247
1418
  useHeadbandContext,
1248
1419
  useHighlight,
1249
1420
  useHits,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headband/react",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "React hooks and components for Headband search — an InstantSearch-inspired SDK",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",