@headband/react 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,5 +1,132 @@
1
1
  "use client";
2
2
 
3
+ // src/experiments.ts
4
+ function stableHash(input) {
5
+ let hash = 2166136261;
6
+ for (let i = 0; i < input.length; i += 1) {
7
+ hash ^= input.charCodeAt(i);
8
+ hash = Math.imul(hash, 16777619);
9
+ }
10
+ return hash >>> 0;
11
+ }
12
+ function bucket(input, max) {
13
+ if (max <= 0) return 0;
14
+ return stableHash(input) % max;
15
+ }
16
+ function assignmentSeed(config, subjectId, scope) {
17
+ return `${config.assignmentSalt || config.experimentKey}:${config.experimentKey}:${scope}:${subjectId}`;
18
+ }
19
+ function buildExperimentConfigPath(query = {}) {
20
+ const params = new URLSearchParams();
21
+ if (query.projectId) params.set("projectId", query.projectId);
22
+ if (query.indexUid) params.set("indexUid", query.indexUid);
23
+ if (query.experimentKey) params.set("experimentKey", query.experimentKey);
24
+ const search = params.toString();
25
+ return `/v1/experiments/config${search ? `?${search}` : ""}`;
26
+ }
27
+ function isRunnableExperiment(config) {
28
+ return config.status === "active" && !config.killSwitch && config.trafficPercent > 0 && config.variants.length > 0;
29
+ }
30
+ function chooseExperimentVariant(config, subjectId) {
31
+ if (!isRunnableExperiment(config)) return null;
32
+ const trafficBucket = bucket(assignmentSeed(config, subjectId, "traffic"), 1e4) / 100;
33
+ if (trafficBucket >= config.trafficPercent) return null;
34
+ const totalWeight = config.variants.reduce(
35
+ (sum, variant) => sum + Math.max(0, variant.weight),
36
+ 0
37
+ );
38
+ if (totalWeight <= 0) return null;
39
+ const variantBucket = bucket(assignmentSeed(config, subjectId, "variant"), totalWeight);
40
+ let cursor = 0;
41
+ for (const variant of config.variants) {
42
+ cursor += Math.max(0, variant.weight);
43
+ if (variantBucket < cursor) return variant;
44
+ }
45
+ return config.variants[config.variants.length - 1] ?? null;
46
+ }
47
+ function assignExperiment(config, subjectId, options = {}) {
48
+ const variant = chooseExperimentVariant(config, subjectId);
49
+ if (!variant) return null;
50
+ return {
51
+ experimentKey: config.experimentKey,
52
+ variant: variant.key,
53
+ indexUid: variant.indexUid,
54
+ sessionId: options.sessionId ?? subjectId,
55
+ config
56
+ };
57
+ }
58
+ function experimentSearchMetadata(assignment) {
59
+ if (!assignment) return void 0;
60
+ return {
61
+ experimentKey: assignment.experimentKey,
62
+ variant: assignment.variant,
63
+ ...assignment.sessionId ? { sessionId: assignment.sessionId } : {}
64
+ };
65
+ }
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
+
3
130
  // src/client.ts
4
131
  var HeadbandError = class extends Error {
5
132
  constructor(message, status, code) {
@@ -11,22 +138,22 @@ var HeadbandError = class extends Error {
11
138
  };
12
139
  function headband(host, apiKey) {
13
140
  const baseUrl = host.replace(/\/+$/, "");
14
- async function request(path, body) {
141
+ async function request(path, options = {}) {
142
+ const method = options.method ?? "POST";
15
143
  let response;
16
144
  try {
17
145
  response = await fetch(`${baseUrl}${path}`, {
18
- method: "POST",
146
+ method,
19
147
  headers: {
20
- "Content-Type": "application/json",
148
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
21
149
  Authorization: `Bearer ${apiKey}`
22
150
  },
23
- body: JSON.stringify(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 } : {}
24
154
  });
25
155
  } catch (err) {
26
- throw new HeadbandError(
27
- err instanceof Error ? err.message : "Network request failed",
28
- 0
29
- );
156
+ throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
30
157
  }
31
158
  if (!response.ok) {
32
159
  let errorBody = {};
@@ -45,15 +172,115 @@ function headband(host, apiKey) {
45
172
  return {
46
173
  search(index, query, params) {
47
174
  return request("/v1/search", {
48
- index,
49
- q: query,
50
- ...params
175
+ body: {
176
+ index,
177
+ q: query,
178
+ ...params
179
+ }
51
180
  });
52
181
  },
53
182
  multiSearch(queries) {
54
- return request("/v1/multi-search", { queries });
183
+ return request("/v1/multi-search", { body: { queries } });
184
+ },
185
+ async getExperimentConfigs(query) {
186
+ const response = await request(
187
+ buildExperimentConfigPath(query),
188
+ { method: "GET" }
189
+ );
190
+ return { experiments: response.data?.experiments ?? [] };
191
+ },
192
+ async assignExperiment(query) {
193
+ const response = await request(
194
+ "/v1/experiments/assign",
195
+ { body: query }
196
+ );
197
+ if (!response.data) {
198
+ throw new HeadbandError("Experiment assignment response missing data", 500);
199
+ }
200
+ return response.data;
201
+ },
202
+ async recordExperimentEvent(event) {
203
+ const response = await request("/v1/experiments/events", {
204
+ body: event
205
+ });
206
+ if (!response.data) {
207
+ throw new HeadbandError("Experiment event response missing data", 500);
208
+ }
209
+ return response.data;
210
+ },
211
+ warm(target, opts) {
212
+ return request("/v1/warm", {
213
+ body: { ...target, ...opts?.level ? { level: opts.level } : {} }
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 } : {} }]);
55
224
  }
56
225
  };
226
+ function sendEvents(events) {
227
+ return request("/v1/relevance/events", {
228
+ body: { events },
229
+ keepalive: true
230
+ });
231
+ }
232
+ }
233
+
234
+ // src/filters.ts
235
+ function quoteFilterValue(value) {
236
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
237
+ return JSON.stringify(value);
238
+ }
239
+ function filterEq(attribute, value) {
240
+ return `${attribute} = ${quoteFilterValue(value)}`;
241
+ }
242
+ function filterNotEq(attribute, value) {
243
+ return `${attribute} != ${quoteFilterValue(value)}`;
244
+ }
245
+ function filterIn(attribute, values) {
246
+ return `${attribute} IN [${values.map(quoteFilterValue).join(", ")}]`;
247
+ }
248
+ function filterExists(attribute) {
249
+ return `${attribute} EXISTS`;
250
+ }
251
+ function filterRange(attribute, range) {
252
+ const parts = [];
253
+ const gte = range.gte ?? range.min;
254
+ const lte = range.lte ?? range.max;
255
+ if (range.gt !== void 0) parts.push(`${attribute} > ${range.gt}`);
256
+ if (gte !== void 0) parts.push(`${attribute} >= ${gte}`);
257
+ if (range.lt !== void 0) parts.push(`${attribute} < ${range.lt}`);
258
+ if (lte !== void 0) parts.push(`${attribute} <= ${lte}`);
259
+ return parts.length ? andFilters(...parts) : void 0;
260
+ }
261
+ function normalizeFilter(input) {
262
+ if (!input) return [];
263
+ return (Array.isArray(input) ? input : [input]).filter((part) => Boolean(part));
264
+ }
265
+ function andFilters(...filters) {
266
+ const parts = filters.flatMap(normalizeFilter);
267
+ if (parts.length === 0) return void 0;
268
+ if (parts.length === 1) return parts[0];
269
+ return parts.map((part) => `(${part})`).join(" AND ");
270
+ }
271
+ function orFilters(...filters) {
272
+ const parts = filters.flatMap(normalizeFilter);
273
+ if (parts.length === 0) return void 0;
274
+ if (parts.length === 1) return parts[0];
275
+ return parts.map((part) => `(${part})`).join(" OR ");
276
+ }
277
+ function combineFilters(baseFilter, generatedFilter) {
278
+ const base = normalizeFilter(baseFilter);
279
+ const generated = normalizeFilter(generatedFilter);
280
+ const combined = [...base, ...generated];
281
+ if (combined.length === 0) return void 0;
282
+ if (combined.length === 1) return combined[0];
283
+ return combined;
57
284
  }
58
285
 
59
286
  // src/provider.tsx
@@ -62,8 +289,8 @@ import {
62
289
  useContext,
63
290
  useCallback,
64
291
  useEffect,
65
- useRef,
66
292
  useMemo,
293
+ useRef,
67
294
  useState
68
295
  } from "react";
69
296
  import { jsx } from "react/jsx-runtime";
@@ -71,9 +298,7 @@ var HeadbandContext = createContext(null);
71
298
  function useHeadbandContext() {
72
299
  const ctx = useContext(HeadbandContext);
73
300
  if (!ctx) {
74
- throw new Error(
75
- "useHeadbandContext must be used within a <HeadbandProvider>."
76
- );
301
+ throw new Error("useHeadbandContext must be used within a <HeadbandProvider>.");
77
302
  }
78
303
  return ctx;
79
304
  }
@@ -83,116 +308,167 @@ function buildFilterString(refinements, ranges) {
83
308
  if (values.size === 0) return;
84
309
  if (values.size === 1) {
85
310
  const [v] = values;
86
- parts.push(`${attribute} = "${v}"`);
311
+ parts.push(filterEq(attribute, v));
87
312
  } else {
88
- const list = Array.from(values).map((v) => `"${v}"`).join(", ");
89
- parts.push(`${attribute} IN [${list}]`);
313
+ parts.push(filterIn(attribute, Array.from(values)));
90
314
  }
91
315
  });
92
316
  ranges.forEach((range, attribute) => {
93
317
  if (range.min !== void 0) {
94
- parts.push(`${attribute} >= ${range.min}`);
318
+ const generated = filterRange(attribute, { min: range.min });
319
+ if (generated) parts.push(generated);
95
320
  }
96
321
  if (range.max !== void 0) {
97
- parts.push(`${attribute} <= ${range.max}`);
322
+ const generated = filterRange(attribute, { max: range.max });
323
+ if (generated) parts.push(generated);
98
324
  }
99
325
  });
100
326
  return parts.length > 0 ? parts.join(" AND ") : void 0;
101
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
+ }
102
362
  function HeadbandProvider({
103
363
  client,
104
364
  index,
105
365
  initialQuery = "",
366
+ experiment = null,
367
+ baseFilter,
368
+ mode,
369
+ disjunctiveFacets = true,
370
+ idField = "id",
371
+ sessionId,
106
372
  children
107
373
  }) {
108
374
  const [query, setQueryRaw] = useState(initialQuery);
109
375
  const [results, setResults] = useState(null);
110
376
  const [isLoading, setIsLoading] = useState(false);
111
377
  const [error, setError] = useState(null);
112
- const [refinements, setRefinements] = useState(
113
- () => /* @__PURE__ */ new Map()
114
- );
378
+ const [refinements, setRefinements] = useState(() => /* @__PURE__ */ new Map());
115
379
  const [ranges, setRanges] = useState(() => /* @__PURE__ */ new Map());
116
380
  const [sortBy, setSortBy] = useState(null);
117
381
  const [page, setPage] = useState(0);
118
382
  const [hitsPerPage, setHitsPerPage] = useState(20);
119
383
  const [facets, setFacets] = useState(() => /* @__PURE__ */ new Set());
120
- const timerRef = useRef(null);
121
384
  const requestIdRef = useRef(0);
122
- const refinementsRef = useRef(refinements);
123
- refinementsRef.current = refinements;
124
- const rangesRef = useRef(ranges);
125
- rangesRef.current = ranges;
126
- const facetsRef = useRef(facets);
127
- facetsRef.current = facets;
385
+ const generatedFilter = useMemo(
386
+ () => buildFilterString(refinements, ranges),
387
+ [refinements, ranges]
388
+ );
389
+ const filter = useMemo(
390
+ () => combineFilters(baseFilter, generatedFilter),
391
+ [baseFilter, generatedFilter]
392
+ );
393
+ const facetList = useMemo(() => facets.size > 0 ? Array.from(facets) : void 0, [facets]);
394
+ const sortList = useMemo(() => sortBy ? [sortBy] : void 0, [sortBy]);
128
395
  const executeSearch = useCallback(() => {
129
- const id = ++requestIdRef.current;
396
+ const requestId = ++requestIdRef.current;
130
397
  setIsLoading(true);
131
398
  setError(null);
132
- const filter = buildFilterString(refinementsRef.current, rangesRef.current);
133
- const facetList = facetsRef.current.size > 0 ? Array.from(facetsRef.current) : void 0;
134
- const sortList = sortBy ? [sortBy] : void 0;
135
399
  const params = {
136
400
  limit: hitsPerPage,
137
401
  offset: page * hitsPerPage,
138
402
  ...filter ? { filter } : {},
139
403
  ...facetList ? { facets: facetList } : {},
140
404
  ...sortList ? { sort: sortList } : {},
405
+ ...experiment ? experiment : {},
406
+ ...mode ? { mode } : {},
141
407
  attributesToHighlight: ["*"]
142
408
  };
143
- client.search(index, query, params).then((res) => {
144
- if (id === requestIdRef.current) {
145
- setResults(res);
146
- setIsLoading(false);
409
+ const countQueries = disjunctiveFacets ? facetCountQueries(facets, refinements, ranges, baseFilter) : [];
410
+ searchWithFacetCounts(client, index, query, params, countQueries).then((res) => {
411
+ if (requestId !== requestIdRef.current) {
412
+ return;
147
413
  }
414
+ setResults(res);
415
+ setIsLoading(false);
148
416
  }).catch((err) => {
149
- if (id === requestIdRef.current) {
150
- setError(err instanceof Error ? err : new Error(String(err)));
151
- setIsLoading(false);
417
+ if (requestId !== requestIdRef.current) {
418
+ return;
152
419
  }
420
+ setError(err instanceof Error ? err : new Error(String(err)));
421
+ setIsLoading(false);
153
422
  });
154
- }, [client, index, query, sortBy, 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
+ ]);
155
440
  useEffect(() => {
156
- if (timerRef.current) {
157
- clearTimeout(timerRef.current);
158
- }
159
- timerRef.current = setTimeout(executeSearch, 200);
441
+ const timer = setTimeout(executeSearch, 200);
160
442
  return () => {
161
- if (timerRef.current) clearTimeout(timerRef.current);
443
+ clearTimeout(timer);
162
444
  };
163
- }, [executeSearch, refinements, ranges, facets]);
445
+ }, [executeSearch]);
164
446
  const setQuery = useCallback((q) => {
165
447
  setQueryRaw(q);
166
448
  setPage(0);
167
449
  }, []);
168
- const toggleRefinement = useCallback(
169
- (attribute, value2) => {
170
- setRefinements((prev) => {
171
- const next = new Map(prev);
172
- const current = new Set(next.get(attribute) ?? []);
173
- if (current.has(value2)) {
174
- current.delete(value2);
175
- } else {
176
- current.add(value2);
177
- }
178
- next.set(attribute, current);
179
- return next;
180
- });
181
- setPage(0);
182
- },
183
- []
184
- );
185
- const setRange = useCallback(
186
- (attribute, range) => {
187
- setRanges((prev) => {
188
- const next = new Map(prev);
189
- next.set(attribute, range);
190
- return next;
191
- });
192
- setPage(0);
193
- },
194
- []
195
- );
450
+ const toggleRefinement = useCallback((attribute, value2) => {
451
+ setRefinements((prev) => {
452
+ const next = new Map(prev);
453
+ const current = new Set(next.get(attribute) ?? []);
454
+ if (current.has(value2)) {
455
+ current.delete(value2);
456
+ } else {
457
+ current.add(value2);
458
+ }
459
+ next.set(attribute, current);
460
+ return next;
461
+ });
462
+ setPage(0);
463
+ }, []);
464
+ const setRange = useCallback((attribute, range) => {
465
+ setRanges((prev) => {
466
+ const next = new Map(prev);
467
+ next.set(attribute, range);
468
+ return next;
469
+ });
470
+ setPage(0);
471
+ }, []);
196
472
  const clearRange = useCallback((attribute) => {
197
473
  setRanges((prev) => {
198
474
  const next = new Map(prev);
@@ -217,9 +493,13 @@ function HeadbandProvider({
217
493
  return next;
218
494
  });
219
495
  }, []);
220
- const refresh = useCallback(() => {
221
- executeSearch();
496
+ const executeSearchRef = useRef(executeSearch);
497
+ useEffect(() => {
498
+ executeSearchRef.current = executeSearch;
222
499
  }, [executeSearch]);
500
+ const refresh = useCallback(() => {
501
+ executeSearchRef.current();
502
+ }, []);
223
503
  const state = useMemo(
224
504
  () => ({
225
505
  query,
@@ -248,11 +528,27 @@ function HeadbandProvider({
248
528
  unregisterFacet,
249
529
  refresh
250
530
  }),
251
- [setQuery, toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet, refresh]
531
+ [
532
+ setQuery,
533
+ setPage,
534
+ setSortBy,
535
+ setHitsPerPage,
536
+ toggleRefinement,
537
+ setRange,
538
+ clearRange,
539
+ registerFacet,
540
+ unregisterFacet,
541
+ refresh
542
+ ]
543
+ );
544
+ const trackingSessionId = sessionId ?? experiment?.sessionId;
545
+ const tracking = useMemo(
546
+ () => ({ idField, ...trackingSessionId ? { sessionId: trackingSessionId } : {} }),
547
+ [idField, trackingSessionId]
252
548
  );
253
549
  const value = useMemo(
254
- () => ({ client, index, state, actions }),
255
- [client, index, state, actions]
550
+ () => ({ client, index, state, actions, tracking }),
551
+ [client, index, state, actions, tracking]
256
552
  );
257
553
  return /* @__PURE__ */ jsx(HeadbandContext.Provider, { value, children });
258
554
  }
@@ -310,18 +606,19 @@ function useRefinementList({
310
606
  actions.unregisterFacet(attribute);
311
607
  };
312
608
  }, [attribute, actions]);
313
- const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
609
+ const refined = state.refinements.get(attribute);
314
610
  const items = useMemo3(() => {
611
+ const activeRefinements = refined ?? /* @__PURE__ */ new Set();
315
612
  const distribution = state.results?.facetDistribution?.[attribute] ?? {};
316
613
  const allValues = /* @__PURE__ */ new Set([
317
614
  ...Object.keys(distribution),
318
- ...refined
615
+ ...activeRefinements
319
616
  ]);
320
617
  let result = Array.from(allValues).map(
321
618
  (value) => ({
322
619
  value,
323
620
  count: distribution[value] ?? 0,
324
- isRefined: refined.has(value)
621
+ isRefined: activeRefinements.has(value)
325
622
  })
326
623
  );
327
624
  if (sortOrder === "alpha") {
@@ -502,11 +799,41 @@ function useHighlight({
502
799
  }, [hit, attribute]);
503
800
  }
504
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
+
505
835
  // src/components/SearchBox.tsx
506
- import {
507
- useCallback as useCallback7,
508
- useRef as useRef2
509
- } from "react";
836
+ import { useCallback as useCallback8, useRef as useRef2 } from "react";
510
837
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
511
838
  function SearchBox({
512
839
  placeholder = "Search...",
@@ -517,7 +844,7 @@ function SearchBox({
517
844
  }) {
518
845
  const { query, setQuery, clear } = useSearchBox();
519
846
  const inputRef = useRef2(null);
520
- const handleChange = useCallback7(
847
+ const handleChange = useCallback8(
521
848
  (e) => {
522
849
  const value = e.target.value;
523
850
  setQuery(value);
@@ -525,15 +852,15 @@ function SearchBox({
525
852
  },
526
853
  [setQuery, onQueryChange]
527
854
  );
528
- const handleClear = useCallback7(() => {
855
+ const handleClear = useCallback8(() => {
529
856
  clear();
530
857
  onQueryChange?.("");
531
858
  inputRef.current?.focus();
532
859
  }, [clear, onQueryChange]);
533
- const handleSubmit = useCallback7((e) => {
860
+ const handleSubmit = useCallback8((e) => {
534
861
  e.preventDefault();
535
862
  }, []);
536
- const handleKeyDown = useCallback7(
863
+ const handleKeyDown = useCallback8(
537
864
  (e) => {
538
865
  if (e.key === "Escape") {
539
866
  handleClear();
@@ -574,7 +901,10 @@ function SearchBox({
574
901
  "input",
575
902
  {
576
903
  ref: inputRef,
577
- type: "search",
904
+ type: "text",
905
+ role: "searchbox",
906
+ inputMode: "search",
907
+ enterKeyHint: "search",
578
908
  value: query,
579
909
  onChange: handleChange,
580
910
  onKeyDown: handleKeyDown,
@@ -604,14 +934,7 @@ function SearchBox({
604
934
  stroke: "currentColor",
605
935
  className: "h-3.5 w-3.5",
606
936
  "aria-hidden": "true",
607
- children: /* @__PURE__ */ jsx2(
608
- "path",
609
- {
610
- strokeLinecap: "round",
611
- strokeLinejoin: "round",
612
- d: "M6 18 18 6M6 6l12 12"
613
- }
614
- )
937
+ children: /* @__PURE__ */ jsx2("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" })
615
938
  }
616
939
  )
617
940
  }
@@ -624,7 +947,11 @@ function SearchBox({
624
947
  // src/components/Hits.tsx
625
948
  import { Fragment, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
626
949
  function DefaultHit({ hit }) {
627
- const { _formatted, __data, ...rest } = hit;
950
+ const rest = Object.fromEntries(
951
+ Object.entries(hit).filter(
952
+ ([key]) => key !== "_formatted" && key !== "__data"
953
+ )
954
+ );
628
955
  return /* @__PURE__ */ jsx3("div", { className: "rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-900", children: /* @__PURE__ */ jsx3("pre", { className: "overflow-x-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300", children: JSON.stringify(rest, null, 2) }) });
629
956
  }
630
957
  function DefaultEmpty() {
@@ -669,7 +996,7 @@ function Hits({
669
996
  }
670
997
 
671
998
  // src/components/RefinementList.tsx
672
- import { useCallback as useCallback8 } from "react";
999
+ import { useCallback as useCallback9 } from "react";
673
1000
  import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
674
1001
  function RefinementList({
675
1002
  attribute,
@@ -684,8 +1011,8 @@ function RefinementList({
684
1011
  limit,
685
1012
  sortBy
686
1013
  });
687
- const handleChange = useCallback8(
688
- (value) => (_e) => {
1014
+ const handleChange = useCallback9(
1015
+ (value) => () => {
689
1016
  refine(value);
690
1017
  },
691
1018
  [refine]
@@ -744,14 +1071,14 @@ function RefinementList({
744
1071
  }
745
1072
 
746
1073
  // src/components/Pagination.tsx
747
- import { useCallback as useCallback9 } from "react";
1074
+ import { useCallback as useCallback10 } from "react";
748
1075
  import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
749
1076
  function Pagination({ className, styled = true }) {
750
1077
  const { currentPage, totalPages, setPage, canPrevious, canNext, pages } = usePagination();
751
- const handlePrevious = useCallback9(() => {
1078
+ const handlePrevious = useCallback10(() => {
752
1079
  setPage(currentPage - 1);
753
1080
  }, [setPage, currentPage]);
754
- const handleNext = useCallback9(() => {
1081
+ const handleNext = useCallback10(() => {
755
1082
  setPage(currentPage + 1);
756
1083
  }, [setPage, currentPage]);
757
1084
  if (totalPages <= 1) return null;
@@ -854,7 +1181,7 @@ function Stats({ className, styled = true, render }) {
854
1181
  }
855
1182
 
856
1183
  // src/components/SortBy.tsx
857
- import { useCallback as useCallback10 } from "react";
1184
+ import { useCallback as useCallback11 } from "react";
858
1185
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
859
1186
  function SortBy({
860
1187
  items: itemsProp,
@@ -863,7 +1190,7 @@ function SortBy({
863
1190
  label = "Sort by"
864
1191
  }) {
865
1192
  const { currentSort, setSort, items } = useSortBy({ items: itemsProp });
866
- const handleChange = useCallback10(
1193
+ const handleChange = useCallback11(
867
1194
  (e) => {
868
1195
  const value = e.target.value;
869
1196
  setSort(value === "" ? null : value);
@@ -914,11 +1241,7 @@ function Highlight({
914
1241
  }
915
1242
 
916
1243
  // src/components/RangeInput.tsx
917
- import {
918
- useState as useState2,
919
- useCallback as useCallback11,
920
- useEffect as useEffect3
921
- } from "react";
1244
+ import { useCallback as useCallback12 } from "react";
922
1245
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
923
1246
  function RangeInput({
924
1247
  attribute,
@@ -929,17 +1252,15 @@ function RangeInput({
929
1252
  maxPlaceholder = "Max"
930
1253
  }) {
931
1254
  const { min, max, stats, setRange, clear } = useRange({ attribute });
932
- const [localMin, setLocalMin] = useState2(min !== void 0 ? String(min) : "");
933
- const [localMax, setLocalMax] = useState2(max !== void 0 ? String(max) : "");
934
- useEffect3(() => {
935
- setLocalMin(min !== void 0 ? String(min) : "");
936
- }, [min]);
937
- useEffect3(() => {
938
- setLocalMax(max !== void 0 ? String(max) : "");
939
- }, [max]);
940
- const handleSubmit = useCallback11(
1255
+ const defaultMin = min !== void 0 ? String(min) : "";
1256
+ const defaultMax = max !== void 0 ? String(max) : "";
1257
+ const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
1258
+ const handleSubmit = useCallback12(
941
1259
  (e) => {
942
1260
  e.preventDefault();
1261
+ const formData = new FormData(e.currentTarget);
1262
+ const localMin = String(formData.get("min") ?? "");
1263
+ const localMax = String(formData.get("max") ?? "");
943
1264
  const parsedMin = localMin !== "" ? Number(localMin) : void 0;
944
1265
  const parsedMax = localMax !== "" ? Number(localMax) : void 0;
945
1266
  if (parsedMin === void 0 && parsedMax === void 0) {
@@ -951,15 +1272,7 @@ function RangeInput({
951
1272
  });
952
1273
  }
953
1274
  },
954
- [localMin, localMax, setRange, clear]
955
- );
956
- const handleMinChange = useCallback11(
957
- (e) => setLocalMin(e.target.value),
958
- []
959
- );
960
- const handleMaxChange = useCallback11(
961
- (e) => setLocalMax(e.target.value),
962
- []
1275
+ [setRange, clear]
963
1276
  );
964
1277
  const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
965
1278
  const inputClass = styled ? "w-full rounded border border-gray-200 bg-white px-2.5 py-1.5 text-sm outline-none transition-colors placeholder:text-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder:text-gray-500" : "";
@@ -994,9 +1307,9 @@ function RangeInput({
994
1307
  /* @__PURE__ */ jsx9(
995
1308
  "input",
996
1309
  {
1310
+ name: "min",
997
1311
  type: "number",
998
- value: localMin,
999
- onChange: handleMinChange,
1312
+ defaultValue: defaultMin,
1000
1313
  placeholder: minPlaceholder,
1001
1314
  "aria-label": `Minimum ${attribute}`,
1002
1315
  className: inputClass
@@ -1013,9 +1326,9 @@ function RangeInput({
1013
1326
  /* @__PURE__ */ jsx9(
1014
1327
  "input",
1015
1328
  {
1329
+ name: "max",
1016
1330
  type: "number",
1017
- value: localMax,
1018
- onChange: handleMaxChange,
1331
+ defaultValue: defaultMax,
1019
1332
  placeholder: maxPlaceholder,
1020
1333
  "aria-label": `Maximum ${attribute}`,
1021
1334
  className: inputClass
@@ -1024,7 +1337,8 @@ function RangeInput({
1024
1337
  /* @__PURE__ */ jsx9("button", { type: "submit", className: buttonClass, children: "Go" })
1025
1338
  ] })
1026
1339
  ]
1027
- }
1340
+ },
1341
+ formKey
1028
1342
  );
1029
1343
  }
1030
1344
 
@@ -1084,7 +1398,23 @@ export {
1084
1398
  SearchBox,
1085
1399
  SortBy,
1086
1400
  Stats,
1401
+ andFilters,
1402
+ assignExperiment,
1403
+ browserSessionId,
1404
+ buildExperimentConfigPath,
1405
+ chooseExperimentVariant,
1406
+ combineFilters,
1407
+ experimentSearchMetadata,
1408
+ filterEq,
1409
+ filterExists,
1410
+ filterIn,
1411
+ filterNotEq,
1412
+ filterRange,
1087
1413
  headband,
1414
+ isRunnableExperiment,
1415
+ orFilters,
1416
+ quoteFilterValue,
1417
+ useClickTracking,
1088
1418
  useHeadbandContext,
1089
1419
  useHighlight,
1090
1420
  useHits,