@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 +66 -9
- package/dist/index.d.mts +106 -3
- package/dist/index.d.ts +106 -3
- package/dist/index.js +207 -31
- package/dist/index.mjs +204 -33
- package/package.json +1 -1
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
|
|
183
|
-
|
|
|
184
|
-
| `client`
|
|
185
|
-
| `index`
|
|
186
|
-
| `initialQuery`
|
|
187
|
-
| `experiment`
|
|
188
|
-
| `mode`
|
|
189
|
-
| `
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
415
|
-
|
|
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/
|
|
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,
|
|
723
|
-
const handleChange = (0,
|
|
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,
|
|
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,
|
|
914
|
+
const handleSubmit = (0, import_react11.useCallback)((e) => {
|
|
737
915
|
e.preventDefault();
|
|
738
916
|
}, []);
|
|
739
|
-
const handleKeyDown = (0,
|
|
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: "
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
1132
|
+
const handlePrevious = (0, import_react13.useCallback)(() => {
|
|
959
1133
|
setPage(currentPage - 1);
|
|
960
1134
|
}, [setPage, currentPage]);
|
|
961
|
-
const handleNext = (0,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
|
363
|
-
|
|
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 =
|
|
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 =
|
|
855
|
+
const handleClear = useCallback8(() => {
|
|
683
856
|
clear();
|
|
684
857
|
onQueryChange?.("");
|
|
685
858
|
inputRef.current?.focus();
|
|
686
859
|
}, [clear, onQueryChange]);
|
|
687
|
-
const handleSubmit =
|
|
860
|
+
const handleSubmit = useCallback8((e) => {
|
|
688
861
|
e.preventDefault();
|
|
689
862
|
}, []);
|
|
690
|
-
const handleKeyDown =
|
|
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: "
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
1078
|
+
const handlePrevious = useCallback10(() => {
|
|
910
1079
|
setPage(currentPage - 1);
|
|
911
1080
|
}, [setPage, currentPage]);
|
|
912
|
-
const handleNext =
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|