@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.js CHANGED
@@ -32,7 +32,23 @@ __export(index_exports, {
32
32
  SearchBox: () => SearchBox,
33
33
  SortBy: () => SortBy,
34
34
  Stats: () => Stats,
35
+ andFilters: () => andFilters,
36
+ assignExperiment: () => assignExperiment,
37
+ browserSessionId: () => browserSessionId,
38
+ buildExperimentConfigPath: () => buildExperimentConfigPath,
39
+ chooseExperimentVariant: () => chooseExperimentVariant,
40
+ combineFilters: () => combineFilters,
41
+ experimentSearchMetadata: () => experimentSearchMetadata,
42
+ filterEq: () => filterEq,
43
+ filterExists: () => filterExists,
44
+ filterIn: () => filterIn,
45
+ filterNotEq: () => filterNotEq,
46
+ filterRange: () => filterRange,
35
47
  headband: () => headband,
48
+ isRunnableExperiment: () => isRunnableExperiment,
49
+ orFilters: () => orFilters,
50
+ quoteFilterValue: () => quoteFilterValue,
51
+ useClickTracking: () => useClickTracking,
36
52
  useHeadbandContext: () => useHeadbandContext,
37
53
  useHighlight: () => useHighlight,
38
54
  useHits: () => useHits,
@@ -46,6 +62,133 @@ __export(index_exports, {
46
62
  });
47
63
  module.exports = __toCommonJS(index_exports);
48
64
 
65
+ // src/experiments.ts
66
+ function stableHash(input) {
67
+ let hash = 2166136261;
68
+ for (let i = 0; i < input.length; i += 1) {
69
+ hash ^= input.charCodeAt(i);
70
+ hash = Math.imul(hash, 16777619);
71
+ }
72
+ return hash >>> 0;
73
+ }
74
+ function bucket(input, max) {
75
+ if (max <= 0) return 0;
76
+ return stableHash(input) % max;
77
+ }
78
+ function assignmentSeed(config, subjectId, scope) {
79
+ return `${config.assignmentSalt || config.experimentKey}:${config.experimentKey}:${scope}:${subjectId}`;
80
+ }
81
+ function buildExperimentConfigPath(query = {}) {
82
+ const params = new URLSearchParams();
83
+ if (query.projectId) params.set("projectId", query.projectId);
84
+ if (query.indexUid) params.set("indexUid", query.indexUid);
85
+ if (query.experimentKey) params.set("experimentKey", query.experimentKey);
86
+ const search = params.toString();
87
+ return `/v1/experiments/config${search ? `?${search}` : ""}`;
88
+ }
89
+ function isRunnableExperiment(config) {
90
+ return config.status === "active" && !config.killSwitch && config.trafficPercent > 0 && config.variants.length > 0;
91
+ }
92
+ function chooseExperimentVariant(config, subjectId) {
93
+ if (!isRunnableExperiment(config)) return null;
94
+ const trafficBucket = bucket(assignmentSeed(config, subjectId, "traffic"), 1e4) / 100;
95
+ if (trafficBucket >= config.trafficPercent) return null;
96
+ const totalWeight = config.variants.reduce(
97
+ (sum, variant) => sum + Math.max(0, variant.weight),
98
+ 0
99
+ );
100
+ if (totalWeight <= 0) return null;
101
+ const variantBucket = bucket(assignmentSeed(config, subjectId, "variant"), totalWeight);
102
+ let cursor = 0;
103
+ for (const variant of config.variants) {
104
+ cursor += Math.max(0, variant.weight);
105
+ if (variantBucket < cursor) return variant;
106
+ }
107
+ return config.variants[config.variants.length - 1] ?? null;
108
+ }
109
+ function assignExperiment(config, subjectId, options = {}) {
110
+ const variant = chooseExperimentVariant(config, subjectId);
111
+ if (!variant) return null;
112
+ return {
113
+ experimentKey: config.experimentKey,
114
+ variant: variant.key,
115
+ indexUid: variant.indexUid,
116
+ sessionId: options.sessionId ?? subjectId,
117
+ config
118
+ };
119
+ }
120
+ function experimentSearchMetadata(assignment) {
121
+ if (!assignment) return void 0;
122
+ return {
123
+ experimentKey: assignment.experimentKey,
124
+ variant: assignment.variant,
125
+ ...assignment.sessionId ? { sessionId: assignment.sessionId } : {}
126
+ };
127
+ }
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
+
49
192
  // src/client.ts
50
193
  var HeadbandError = class extends Error {
51
194
  constructor(message, status, code) {
@@ -57,22 +200,22 @@ var HeadbandError = class extends Error {
57
200
  };
58
201
  function headband(host, apiKey) {
59
202
  const baseUrl = host.replace(/\/+$/, "");
60
- async function request(path, body) {
203
+ async function request(path, options = {}) {
204
+ const method = options.method ?? "POST";
61
205
  let response;
62
206
  try {
63
207
  response = await fetch(`${baseUrl}${path}`, {
64
- method: "POST",
208
+ method,
65
209
  headers: {
66
- "Content-Type": "application/json",
210
+ ...method === "POST" ? { "Content-Type": "application/json" } : {},
67
211
  Authorization: `Bearer ${apiKey}`
68
212
  },
69
- body: JSON.stringify(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 } : {}
70
216
  });
71
217
  } catch (err) {
72
- throw new HeadbandError(
73
- err instanceof Error ? err.message : "Network request failed",
74
- 0
75
- );
218
+ throw new HeadbandError(err instanceof Error ? err.message : "Network request failed", 0);
76
219
  }
77
220
  if (!response.ok) {
78
221
  let errorBody = {};
@@ -91,15 +234,115 @@ function headband(host, apiKey) {
91
234
  return {
92
235
  search(index, query, params) {
93
236
  return request("/v1/search", {
94
- index,
95
- q: query,
96
- ...params
237
+ body: {
238
+ index,
239
+ q: query,
240
+ ...params
241
+ }
97
242
  });
98
243
  },
99
244
  multiSearch(queries) {
100
- return request("/v1/multi-search", { queries });
245
+ return request("/v1/multi-search", { body: { queries } });
246
+ },
247
+ async getExperimentConfigs(query) {
248
+ const response = await request(
249
+ buildExperimentConfigPath(query),
250
+ { method: "GET" }
251
+ );
252
+ return { experiments: response.data?.experiments ?? [] };
253
+ },
254
+ async assignExperiment(query) {
255
+ const response = await request(
256
+ "/v1/experiments/assign",
257
+ { body: query }
258
+ );
259
+ if (!response.data) {
260
+ throw new HeadbandError("Experiment assignment response missing data", 500);
261
+ }
262
+ return response.data;
263
+ },
264
+ async recordExperimentEvent(event) {
265
+ const response = await request("/v1/experiments/events", {
266
+ body: event
267
+ });
268
+ if (!response.data) {
269
+ throw new HeadbandError("Experiment event response missing data", 500);
270
+ }
271
+ return response.data;
272
+ },
273
+ warm(target, opts) {
274
+ return request("/v1/warm", {
275
+ body: { ...target, ...opts?.level ? { level: opts.level } : {} }
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 } : {} }]);
101
286
  }
102
287
  };
288
+ function sendEvents(events) {
289
+ return request("/v1/relevance/events", {
290
+ body: { events },
291
+ keepalive: true
292
+ });
293
+ }
294
+ }
295
+
296
+ // src/filters.ts
297
+ function quoteFilterValue(value) {
298
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
299
+ return JSON.stringify(value);
300
+ }
301
+ function filterEq(attribute, value) {
302
+ return `${attribute} = ${quoteFilterValue(value)}`;
303
+ }
304
+ function filterNotEq(attribute, value) {
305
+ return `${attribute} != ${quoteFilterValue(value)}`;
306
+ }
307
+ function filterIn(attribute, values) {
308
+ return `${attribute} IN [${values.map(quoteFilterValue).join(", ")}]`;
309
+ }
310
+ function filterExists(attribute) {
311
+ return `${attribute} EXISTS`;
312
+ }
313
+ function filterRange(attribute, range) {
314
+ const parts = [];
315
+ const gte = range.gte ?? range.min;
316
+ const lte = range.lte ?? range.max;
317
+ if (range.gt !== void 0) parts.push(`${attribute} > ${range.gt}`);
318
+ if (gte !== void 0) parts.push(`${attribute} >= ${gte}`);
319
+ if (range.lt !== void 0) parts.push(`${attribute} < ${range.lt}`);
320
+ if (lte !== void 0) parts.push(`${attribute} <= ${lte}`);
321
+ return parts.length ? andFilters(...parts) : void 0;
322
+ }
323
+ function normalizeFilter(input) {
324
+ if (!input) return [];
325
+ return (Array.isArray(input) ? input : [input]).filter((part) => Boolean(part));
326
+ }
327
+ function andFilters(...filters) {
328
+ const parts = filters.flatMap(normalizeFilter);
329
+ if (parts.length === 0) return void 0;
330
+ if (parts.length === 1) return parts[0];
331
+ return parts.map((part) => `(${part})`).join(" AND ");
332
+ }
333
+ function orFilters(...filters) {
334
+ const parts = filters.flatMap(normalizeFilter);
335
+ if (parts.length === 0) return void 0;
336
+ if (parts.length === 1) return parts[0];
337
+ return parts.map((part) => `(${part})`).join(" OR ");
338
+ }
339
+ function combineFilters(baseFilter, generatedFilter) {
340
+ const base = normalizeFilter(baseFilter);
341
+ const generated = normalizeFilter(generatedFilter);
342
+ const combined = [...base, ...generated];
343
+ if (combined.length === 0) return void 0;
344
+ if (combined.length === 1) return combined[0];
345
+ return combined;
103
346
  }
104
347
 
105
348
  // src/provider.tsx
@@ -109,9 +352,7 @@ var HeadbandContext = (0, import_react.createContext)(null);
109
352
  function useHeadbandContext() {
110
353
  const ctx = (0, import_react.useContext)(HeadbandContext);
111
354
  if (!ctx) {
112
- throw new Error(
113
- "useHeadbandContext must be used within a <HeadbandProvider>."
114
- );
355
+ throw new Error("useHeadbandContext must be used within a <HeadbandProvider>.");
115
356
  }
116
357
  return ctx;
117
358
  }
@@ -121,116 +362,167 @@ function buildFilterString(refinements, ranges) {
121
362
  if (values.size === 0) return;
122
363
  if (values.size === 1) {
123
364
  const [v] = values;
124
- parts.push(`${attribute} = "${v}"`);
365
+ parts.push(filterEq(attribute, v));
125
366
  } else {
126
- const list = Array.from(values).map((v) => `"${v}"`).join(", ");
127
- parts.push(`${attribute} IN [${list}]`);
367
+ parts.push(filterIn(attribute, Array.from(values)));
128
368
  }
129
369
  });
130
370
  ranges.forEach((range, attribute) => {
131
371
  if (range.min !== void 0) {
132
- parts.push(`${attribute} >= ${range.min}`);
372
+ const generated = filterRange(attribute, { min: range.min });
373
+ if (generated) parts.push(generated);
133
374
  }
134
375
  if (range.max !== void 0) {
135
- parts.push(`${attribute} <= ${range.max}`);
376
+ const generated = filterRange(attribute, { max: range.max });
377
+ if (generated) parts.push(generated);
136
378
  }
137
379
  });
138
380
  return parts.length > 0 ? parts.join(" AND ") : void 0;
139
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
+ }
140
416
  function HeadbandProvider({
141
417
  client,
142
418
  index,
143
419
  initialQuery = "",
420
+ experiment = null,
421
+ baseFilter,
422
+ mode,
423
+ disjunctiveFacets = true,
424
+ idField = "id",
425
+ sessionId,
144
426
  children
145
427
  }) {
146
428
  const [query, setQueryRaw] = (0, import_react.useState)(initialQuery);
147
429
  const [results, setResults] = (0, import_react.useState)(null);
148
430
  const [isLoading, setIsLoading] = (0, import_react.useState)(false);
149
431
  const [error, setError] = (0, import_react.useState)(null);
150
- const [refinements, setRefinements] = (0, import_react.useState)(
151
- () => /* @__PURE__ */ new Map()
152
- );
432
+ const [refinements, setRefinements] = (0, import_react.useState)(() => /* @__PURE__ */ new Map());
153
433
  const [ranges, setRanges] = (0, import_react.useState)(() => /* @__PURE__ */ new Map());
154
434
  const [sortBy, setSortBy] = (0, import_react.useState)(null);
155
435
  const [page, setPage] = (0, import_react.useState)(0);
156
436
  const [hitsPerPage, setHitsPerPage] = (0, import_react.useState)(20);
157
437
  const [facets, setFacets] = (0, import_react.useState)(() => /* @__PURE__ */ new Set());
158
- const timerRef = (0, import_react.useRef)(null);
159
438
  const requestIdRef = (0, import_react.useRef)(0);
160
- const refinementsRef = (0, import_react.useRef)(refinements);
161
- refinementsRef.current = refinements;
162
- const rangesRef = (0, import_react.useRef)(ranges);
163
- rangesRef.current = ranges;
164
- const facetsRef = (0, import_react.useRef)(facets);
165
- facetsRef.current = facets;
439
+ const generatedFilter = (0, import_react.useMemo)(
440
+ () => buildFilterString(refinements, ranges),
441
+ [refinements, ranges]
442
+ );
443
+ const filter = (0, import_react.useMemo)(
444
+ () => combineFilters(baseFilter, generatedFilter),
445
+ [baseFilter, generatedFilter]
446
+ );
447
+ const facetList = (0, import_react.useMemo)(() => facets.size > 0 ? Array.from(facets) : void 0, [facets]);
448
+ const sortList = (0, import_react.useMemo)(() => sortBy ? [sortBy] : void 0, [sortBy]);
166
449
  const executeSearch = (0, import_react.useCallback)(() => {
167
- const id = ++requestIdRef.current;
450
+ const requestId = ++requestIdRef.current;
168
451
  setIsLoading(true);
169
452
  setError(null);
170
- const filter = buildFilterString(refinementsRef.current, rangesRef.current);
171
- const facetList = facetsRef.current.size > 0 ? Array.from(facetsRef.current) : void 0;
172
- const sortList = sortBy ? [sortBy] : void 0;
173
453
  const params = {
174
454
  limit: hitsPerPage,
175
455
  offset: page * hitsPerPage,
176
456
  ...filter ? { filter } : {},
177
457
  ...facetList ? { facets: facetList } : {},
178
458
  ...sortList ? { sort: sortList } : {},
459
+ ...experiment ? experiment : {},
460
+ ...mode ? { mode } : {},
179
461
  attributesToHighlight: ["*"]
180
462
  };
181
- client.search(index, query, params).then((res) => {
182
- if (id === requestIdRef.current) {
183
- setResults(res);
184
- setIsLoading(false);
463
+ const countQueries = disjunctiveFacets ? facetCountQueries(facets, refinements, ranges, baseFilter) : [];
464
+ searchWithFacetCounts(client, index, query, params, countQueries).then((res) => {
465
+ if (requestId !== requestIdRef.current) {
466
+ return;
185
467
  }
468
+ setResults(res);
469
+ setIsLoading(false);
186
470
  }).catch((err) => {
187
- if (id === requestIdRef.current) {
188
- setError(err instanceof Error ? err : new Error(String(err)));
189
- setIsLoading(false);
471
+ if (requestId !== requestIdRef.current) {
472
+ return;
190
473
  }
474
+ setError(err instanceof Error ? err : new Error(String(err)));
475
+ setIsLoading(false);
191
476
  });
192
- }, [client, index, query, sortBy, page, hitsPerPage]);
477
+ }, [
478
+ client,
479
+ index,
480
+ query,
481
+ filter,
482
+ facetList,
483
+ sortList,
484
+ experiment,
485
+ mode,
486
+ page,
487
+ hitsPerPage,
488
+ disjunctiveFacets,
489
+ facets,
490
+ refinements,
491
+ ranges,
492
+ baseFilter
493
+ ]);
193
494
  (0, import_react.useEffect)(() => {
194
- if (timerRef.current) {
195
- clearTimeout(timerRef.current);
196
- }
197
- timerRef.current = setTimeout(executeSearch, 200);
495
+ const timer = setTimeout(executeSearch, 200);
198
496
  return () => {
199
- if (timerRef.current) clearTimeout(timerRef.current);
497
+ clearTimeout(timer);
200
498
  };
201
- }, [executeSearch, refinements, ranges, facets]);
499
+ }, [executeSearch]);
202
500
  const setQuery = (0, import_react.useCallback)((q) => {
203
501
  setQueryRaw(q);
204
502
  setPage(0);
205
503
  }, []);
206
- const toggleRefinement = (0, import_react.useCallback)(
207
- (attribute, value2) => {
208
- setRefinements((prev) => {
209
- const next = new Map(prev);
210
- const current = new Set(next.get(attribute) ?? []);
211
- if (current.has(value2)) {
212
- current.delete(value2);
213
- } else {
214
- current.add(value2);
215
- }
216
- next.set(attribute, current);
217
- return next;
218
- });
219
- setPage(0);
220
- },
221
- []
222
- );
223
- const setRange = (0, import_react.useCallback)(
224
- (attribute, range) => {
225
- setRanges((prev) => {
226
- const next = new Map(prev);
227
- next.set(attribute, range);
228
- return next;
229
- });
230
- setPage(0);
231
- },
232
- []
233
- );
504
+ const toggleRefinement = (0, import_react.useCallback)((attribute, value2) => {
505
+ setRefinements((prev) => {
506
+ const next = new Map(prev);
507
+ const current = new Set(next.get(attribute) ?? []);
508
+ if (current.has(value2)) {
509
+ current.delete(value2);
510
+ } else {
511
+ current.add(value2);
512
+ }
513
+ next.set(attribute, current);
514
+ return next;
515
+ });
516
+ setPage(0);
517
+ }, []);
518
+ const setRange = (0, import_react.useCallback)((attribute, range) => {
519
+ setRanges((prev) => {
520
+ const next = new Map(prev);
521
+ next.set(attribute, range);
522
+ return next;
523
+ });
524
+ setPage(0);
525
+ }, []);
234
526
  const clearRange = (0, import_react.useCallback)((attribute) => {
235
527
  setRanges((prev) => {
236
528
  const next = new Map(prev);
@@ -255,9 +547,13 @@ function HeadbandProvider({
255
547
  return next;
256
548
  });
257
549
  }, []);
258
- const refresh = (0, import_react.useCallback)(() => {
259
- executeSearch();
550
+ const executeSearchRef = (0, import_react.useRef)(executeSearch);
551
+ (0, import_react.useEffect)(() => {
552
+ executeSearchRef.current = executeSearch;
260
553
  }, [executeSearch]);
554
+ const refresh = (0, import_react.useCallback)(() => {
555
+ executeSearchRef.current();
556
+ }, []);
261
557
  const state = (0, import_react.useMemo)(
262
558
  () => ({
263
559
  query,
@@ -286,11 +582,27 @@ function HeadbandProvider({
286
582
  unregisterFacet,
287
583
  refresh
288
584
  }),
289
- [setQuery, toggleRefinement, setRange, clearRange, registerFacet, unregisterFacet, refresh]
585
+ [
586
+ setQuery,
587
+ setPage,
588
+ setSortBy,
589
+ setHitsPerPage,
590
+ toggleRefinement,
591
+ setRange,
592
+ clearRange,
593
+ registerFacet,
594
+ unregisterFacet,
595
+ refresh
596
+ ]
597
+ );
598
+ const trackingSessionId = sessionId ?? experiment?.sessionId;
599
+ const tracking = (0, import_react.useMemo)(
600
+ () => ({ idField, ...trackingSessionId ? { sessionId: trackingSessionId } : {} }),
601
+ [idField, trackingSessionId]
290
602
  );
291
603
  const value = (0, import_react.useMemo)(
292
- () => ({ client, index, state, actions }),
293
- [client, index, state, actions]
604
+ () => ({ client, index, state, actions, tracking }),
605
+ [client, index, state, actions, tracking]
294
606
  );
295
607
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(HeadbandContext.Provider, { value, children });
296
608
  }
@@ -348,18 +660,19 @@ function useRefinementList({
348
660
  actions.unregisterFacet(attribute);
349
661
  };
350
662
  }, [attribute, actions]);
351
- const refined = state.refinements.get(attribute) ?? /* @__PURE__ */ new Set();
663
+ const refined = state.refinements.get(attribute);
352
664
  const items = (0, import_react4.useMemo)(() => {
665
+ const activeRefinements = refined ?? /* @__PURE__ */ new Set();
353
666
  const distribution = state.results?.facetDistribution?.[attribute] ?? {};
354
667
  const allValues = /* @__PURE__ */ new Set([
355
668
  ...Object.keys(distribution),
356
- ...refined
669
+ ...activeRefinements
357
670
  ]);
358
671
  let result = Array.from(allValues).map(
359
672
  (value) => ({
360
673
  value,
361
674
  count: distribution[value] ?? 0,
362
- isRefined: refined.has(value)
675
+ isRefined: activeRefinements.has(value)
363
676
  })
364
677
  );
365
678
  if (sortOrder === "alpha") {
@@ -540,8 +853,41 @@ function useHighlight({
540
853
  }, [hit, attribute]);
541
854
  }
542
855
 
543
- // src/components/SearchBox.tsx
856
+ // src/hooks/useClickTracking.ts
544
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");
545
891
  var import_jsx_runtime2 = require("react/jsx-runtime");
546
892
  function SearchBox({
547
893
  placeholder = "Search...",
@@ -551,8 +897,8 @@ function SearchBox({
551
897
  styled = true
552
898
  }) {
553
899
  const { query, setQuery, clear } = useSearchBox();
554
- const inputRef = (0, import_react10.useRef)(null);
555
- const handleChange = (0, import_react10.useCallback)(
900
+ const inputRef = (0, import_react11.useRef)(null);
901
+ const handleChange = (0, import_react11.useCallback)(
556
902
  (e) => {
557
903
  const value = e.target.value;
558
904
  setQuery(value);
@@ -560,15 +906,15 @@ function SearchBox({
560
906
  },
561
907
  [setQuery, onQueryChange]
562
908
  );
563
- const handleClear = (0, import_react10.useCallback)(() => {
909
+ const handleClear = (0, import_react11.useCallback)(() => {
564
910
  clear();
565
911
  onQueryChange?.("");
566
912
  inputRef.current?.focus();
567
913
  }, [clear, onQueryChange]);
568
- const handleSubmit = (0, import_react10.useCallback)((e) => {
914
+ const handleSubmit = (0, import_react11.useCallback)((e) => {
569
915
  e.preventDefault();
570
916
  }, []);
571
- const handleKeyDown = (0, import_react10.useCallback)(
917
+ const handleKeyDown = (0, import_react11.useCallback)(
572
918
  (e) => {
573
919
  if (e.key === "Escape") {
574
920
  handleClear();
@@ -609,7 +955,10 @@ function SearchBox({
609
955
  "input",
610
956
  {
611
957
  ref: inputRef,
612
- type: "search",
958
+ type: "text",
959
+ role: "searchbox",
960
+ inputMode: "search",
961
+ enterKeyHint: "search",
613
962
  value: query,
614
963
  onChange: handleChange,
615
964
  onKeyDown: handleKeyDown,
@@ -639,14 +988,7 @@ function SearchBox({
639
988
  stroke: "currentColor",
640
989
  className: "h-3.5 w-3.5",
641
990
  "aria-hidden": "true",
642
- children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
643
- "path",
644
- {
645
- strokeLinecap: "round",
646
- strokeLinejoin: "round",
647
- d: "M6 18 18 6M6 6l12 12"
648
- }
649
- )
991
+ children: /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("path", { strokeLinecap: "round", strokeLinejoin: "round", d: "M6 18 18 6M6 6l12 12" })
650
992
  }
651
993
  )
652
994
  }
@@ -659,7 +1001,11 @@ function SearchBox({
659
1001
  // src/components/Hits.tsx
660
1002
  var import_jsx_runtime3 = require("react/jsx-runtime");
661
1003
  function DefaultHit({ hit }) {
662
- const { _formatted, __data, ...rest } = hit;
1004
+ const rest = Object.fromEntries(
1005
+ Object.entries(hit).filter(
1006
+ ([key]) => key !== "_formatted" && key !== "__data"
1007
+ )
1008
+ );
663
1009
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: "rounded-lg border border-gray-200 bg-white p-4 text-sm dark:border-gray-700 dark:bg-gray-900", children: /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("pre", { className: "overflow-x-auto whitespace-pre-wrap text-xs text-gray-700 dark:text-gray-300", children: JSON.stringify(rest, null, 2) }) });
664
1010
  }
665
1011
  function DefaultEmpty() {
@@ -704,7 +1050,7 @@ function Hits({
704
1050
  }
705
1051
 
706
1052
  // src/components/RefinementList.tsx
707
- var import_react11 = require("react");
1053
+ var import_react12 = require("react");
708
1054
  var import_jsx_runtime4 = require("react/jsx-runtime");
709
1055
  function RefinementList({
710
1056
  attribute,
@@ -719,8 +1065,8 @@ function RefinementList({
719
1065
  limit,
720
1066
  sortBy
721
1067
  });
722
- const handleChange = (0, import_react11.useCallback)(
723
- (value) => (_e) => {
1068
+ const handleChange = (0, import_react12.useCallback)(
1069
+ (value) => () => {
724
1070
  refine(value);
725
1071
  },
726
1072
  [refine]
@@ -779,14 +1125,14 @@ function RefinementList({
779
1125
  }
780
1126
 
781
1127
  // src/components/Pagination.tsx
782
- var import_react12 = require("react");
1128
+ var import_react13 = require("react");
783
1129
  var import_jsx_runtime5 = require("react/jsx-runtime");
784
1130
  function Pagination({ className, styled = true }) {
785
1131
  const { currentPage, totalPages, setPage, canPrevious, canNext, pages } = usePagination();
786
- const handlePrevious = (0, import_react12.useCallback)(() => {
1132
+ const handlePrevious = (0, import_react13.useCallback)(() => {
787
1133
  setPage(currentPage - 1);
788
1134
  }, [setPage, currentPage]);
789
- const handleNext = (0, import_react12.useCallback)(() => {
1135
+ const handleNext = (0, import_react13.useCallback)(() => {
790
1136
  setPage(currentPage + 1);
791
1137
  }, [setPage, currentPage]);
792
1138
  if (totalPages <= 1) return null;
@@ -889,7 +1235,7 @@ function Stats({ className, styled = true, render }) {
889
1235
  }
890
1236
 
891
1237
  // src/components/SortBy.tsx
892
- var import_react13 = require("react");
1238
+ var import_react14 = require("react");
893
1239
  var import_jsx_runtime7 = require("react/jsx-runtime");
894
1240
  function SortBy({
895
1241
  items: itemsProp,
@@ -898,7 +1244,7 @@ function SortBy({
898
1244
  label = "Sort by"
899
1245
  }) {
900
1246
  const { currentSort, setSort, items } = useSortBy({ items: itemsProp });
901
- const handleChange = (0, import_react13.useCallback)(
1247
+ const handleChange = (0, import_react14.useCallback)(
902
1248
  (e) => {
903
1249
  const value = e.target.value;
904
1250
  setSort(value === "" ? null : value);
@@ -949,7 +1295,7 @@ function Highlight({
949
1295
  }
950
1296
 
951
1297
  // src/components/RangeInput.tsx
952
- var import_react14 = require("react");
1298
+ var import_react15 = require("react");
953
1299
  var import_jsx_runtime9 = require("react/jsx-runtime");
954
1300
  function RangeInput({
955
1301
  attribute,
@@ -960,17 +1306,15 @@ function RangeInput({
960
1306
  maxPlaceholder = "Max"
961
1307
  }) {
962
1308
  const { min, max, stats, setRange, clear } = useRange({ attribute });
963
- const [localMin, setLocalMin] = (0, import_react14.useState)(min !== void 0 ? String(min) : "");
964
- const [localMax, setLocalMax] = (0, import_react14.useState)(max !== void 0 ? String(max) : "");
965
- (0, import_react14.useEffect)(() => {
966
- setLocalMin(min !== void 0 ? String(min) : "");
967
- }, [min]);
968
- (0, import_react14.useEffect)(() => {
969
- setLocalMax(max !== void 0 ? String(max) : "");
970
- }, [max]);
971
- const handleSubmit = (0, import_react14.useCallback)(
1309
+ const defaultMin = min !== void 0 ? String(min) : "";
1310
+ const defaultMax = max !== void 0 ? String(max) : "";
1311
+ const formKey = `${attribute}:${defaultMin}:${defaultMax}`;
1312
+ const handleSubmit = (0, import_react15.useCallback)(
972
1313
  (e) => {
973
1314
  e.preventDefault();
1315
+ const formData = new FormData(e.currentTarget);
1316
+ const localMin = String(formData.get("min") ?? "");
1317
+ const localMax = String(formData.get("max") ?? "");
974
1318
  const parsedMin = localMin !== "" ? Number(localMin) : void 0;
975
1319
  const parsedMax = localMax !== "" ? Number(localMax) : void 0;
976
1320
  if (parsedMin === void 0 && parsedMax === void 0) {
@@ -982,15 +1326,7 @@ function RangeInput({
982
1326
  });
983
1327
  }
984
1328
  },
985
- [localMin, localMax, setRange, clear]
986
- );
987
- const handleMinChange = (0, import_react14.useCallback)(
988
- (e) => setLocalMin(e.target.value),
989
- []
990
- );
991
- const handleMaxChange = (0, import_react14.useCallback)(
992
- (e) => setLocalMax(e.target.value),
993
- []
1329
+ [setRange, clear]
994
1330
  );
995
1331
  const containerClass = styled ? `space-y-2 ${className ?? ""}`.trim() : className ?? "";
996
1332
  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" : "";
@@ -1025,9 +1361,9 @@ function RangeInput({
1025
1361
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1026
1362
  "input",
1027
1363
  {
1364
+ name: "min",
1028
1365
  type: "number",
1029
- value: localMin,
1030
- onChange: handleMinChange,
1366
+ defaultValue: defaultMin,
1031
1367
  placeholder: minPlaceholder,
1032
1368
  "aria-label": `Minimum ${attribute}`,
1033
1369
  className: inputClass
@@ -1044,9 +1380,9 @@ function RangeInput({
1044
1380
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
1045
1381
  "input",
1046
1382
  {
1383
+ name: "max",
1047
1384
  type: "number",
1048
- value: localMax,
1049
- onChange: handleMaxChange,
1385
+ defaultValue: defaultMax,
1050
1386
  placeholder: maxPlaceholder,
1051
1387
  "aria-label": `Maximum ${attribute}`,
1052
1388
  className: inputClass
@@ -1055,7 +1391,8 @@ function RangeInput({
1055
1391
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { type: "submit", className: buttonClass, children: "Go" })
1056
1392
  ] })
1057
1393
  ]
1058
- }
1394
+ },
1395
+ formKey
1059
1396
  );
1060
1397
  }
1061
1398
 
@@ -1116,7 +1453,23 @@ function PoweredBy({
1116
1453
  SearchBox,
1117
1454
  SortBy,
1118
1455
  Stats,
1456
+ andFilters,
1457
+ assignExperiment,
1458
+ browserSessionId,
1459
+ buildExperimentConfigPath,
1460
+ chooseExperimentVariant,
1461
+ combineFilters,
1462
+ experimentSearchMetadata,
1463
+ filterEq,
1464
+ filterExists,
1465
+ filterIn,
1466
+ filterNotEq,
1467
+ filterRange,
1119
1468
  headband,
1469
+ isRunnableExperiment,
1470
+ orFilters,
1471
+ quoteFilterValue,
1472
+ useClickTracking,
1120
1473
  useHeadbandContext,
1121
1474
  useHighlight,
1122
1475
  useHits,