@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/LICENSE +21 -0
- package/README.md +307 -160
- package/dist/index.d.mts +271 -5
- package/dist/index.d.ts +271 -5
- package/dist/index.js +485 -132
- package/dist/index.mjs +469 -139
- package/package.json +2 -2
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,
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
365
|
+
parts.push(filterEq(attribute, v));
|
|
125
366
|
} else {
|
|
126
|
-
|
|
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
|
-
|
|
372
|
+
const generated = filterRange(attribute, { min: range.min });
|
|
373
|
+
if (generated) parts.push(generated);
|
|
133
374
|
}
|
|
134
375
|
if (range.max !== void 0) {
|
|
135
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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 (
|
|
188
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
195
|
-
clearTimeout(timerRef.current);
|
|
196
|
-
}
|
|
197
|
-
timerRef.current = setTimeout(executeSearch, 200);
|
|
495
|
+
const timer = setTimeout(executeSearch, 200);
|
|
198
496
|
return () => {
|
|
199
|
-
|
|
497
|
+
clearTimeout(timer);
|
|
200
498
|
};
|
|
201
|
-
}, [executeSearch
|
|
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
|
-
(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
259
|
-
|
|
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
|
-
[
|
|
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)
|
|
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
|
-
...
|
|
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:
|
|
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/
|
|
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,
|
|
555
|
-
const handleChange = (0,
|
|
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,
|
|
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,
|
|
914
|
+
const handleSubmit = (0, import_react11.useCallback)((e) => {
|
|
569
915
|
e.preventDefault();
|
|
570
916
|
}, []);
|
|
571
|
-
const handleKeyDown = (0,
|
|
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: "
|
|
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
|
|
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
|
|
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,
|
|
723
|
-
(value) => (
|
|
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
|
|
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,
|
|
1132
|
+
const handlePrevious = (0, import_react13.useCallback)(() => {
|
|
787
1133
|
setPage(currentPage - 1);
|
|
788
1134
|
}, [setPage, currentPage]);
|
|
789
|
-
const handleNext = (0,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
964
|
-
const
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|