@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.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,
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
311
|
+
parts.push(filterEq(attribute, v));
|
|
87
312
|
} else {
|
|
88
|
-
|
|
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
|
-
|
|
318
|
+
const generated = filterRange(attribute, { min: range.min });
|
|
319
|
+
if (generated) parts.push(generated);
|
|
95
320
|
}
|
|
96
321
|
if (range.max !== void 0) {
|
|
97
|
-
|
|
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
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 (
|
|
150
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
157
|
-
clearTimeout(timerRef.current);
|
|
158
|
-
}
|
|
159
|
-
timerRef.current = setTimeout(executeSearch, 200);
|
|
441
|
+
const timer = setTimeout(executeSearch, 200);
|
|
160
442
|
return () => {
|
|
161
|
-
|
|
443
|
+
clearTimeout(timer);
|
|
162
444
|
};
|
|
163
|
-
}, [executeSearch
|
|
445
|
+
}, [executeSearch]);
|
|
164
446
|
const setQuery = useCallback((q) => {
|
|
165
447
|
setQueryRaw(q);
|
|
166
448
|
setPage(0);
|
|
167
449
|
}, []);
|
|
168
|
-
const toggleRefinement = useCallback(
|
|
169
|
-
(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
[
|
|
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)
|
|
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
|
-
...
|
|
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:
|
|
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 =
|
|
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 =
|
|
855
|
+
const handleClear = useCallback8(() => {
|
|
529
856
|
clear();
|
|
530
857
|
onQueryChange?.("");
|
|
531
858
|
inputRef.current?.focus();
|
|
532
859
|
}, [clear, onQueryChange]);
|
|
533
|
-
const handleSubmit =
|
|
860
|
+
const handleSubmit = useCallback8((e) => {
|
|
534
861
|
e.preventDefault();
|
|
535
862
|
}, []);
|
|
536
|
-
const handleKeyDown =
|
|
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: "
|
|
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
|
|
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
|
|
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 =
|
|
688
|
-
(value) => (
|
|
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
|
|
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 =
|
|
1078
|
+
const handlePrevious = useCallback10(() => {
|
|
752
1079
|
setPage(currentPage - 1);
|
|
753
1080
|
}, [setPage, currentPage]);
|
|
754
|
-
const handleNext =
|
|
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
|
|
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 =
|
|
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
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|