@findlane/browser 0.6.0 → 0.8.0
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.d.ts +73 -1
- package/dist/index.js +51 -0
- package/dist/react/cache.d.ts +6 -1
- package/dist/react/cache.js +44 -24
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +3 -1
- package/dist/react/recommendations.d.ts +21 -0
- package/dist/react/recommendations.js +17 -0
- package/dist/react/styles.css +22 -0
- package/dist/react/use-recommendations.d.ts +14 -0
- package/dist/react/use-recommendations.js +41 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -93,21 +93,87 @@ export type DidYouMean = {
|
|
|
93
93
|
to: string;
|
|
94
94
|
}[];
|
|
95
95
|
};
|
|
96
|
+
/** How a product's recommendations are served (the recommendations add-on). */
|
|
97
|
+
export type RecommendationsRequest = {
|
|
98
|
+
/** Up to this many products in each list, 1 to 20; 8 by default. */
|
|
99
|
+
limit?: number;
|
|
100
|
+
/** Only picks matching these filters, as in a search, such as `{ inStock: true }`. The index's browser
|
|
101
|
+
* access decides which fields may be filtered by. */
|
|
102
|
+
filters?: SearchRequest["filters"];
|
|
103
|
+
/** The record fields to return; by default, those the index's browser access returns. */
|
|
104
|
+
attributesToRetrieve?: string[];
|
|
105
|
+
/** Goes with picks cost at most this many times the product; null for no limit. By default, the index's setting. */
|
|
106
|
+
maxPriceRatio?: number | null;
|
|
107
|
+
};
|
|
108
|
+
export type RecommendationHit<T> = {
|
|
109
|
+
id: string;
|
|
110
|
+
score: number | null;
|
|
111
|
+
record: T;
|
|
112
|
+
};
|
|
113
|
+
export type RecommendationsResponse<T> = {
|
|
114
|
+
objectID: string;
|
|
115
|
+
/** false until the product's lists are built; both lists are empty meanwhile. */
|
|
116
|
+
ready: boolean;
|
|
117
|
+
/** Products a shopper would want with this one, the likeliest first; `score` is the decision model's probability. */
|
|
118
|
+
goesWith: RecommendationHit<T>[];
|
|
119
|
+
/** Products a shopper could choose instead, then (unless the index hides them) variants of this one. */
|
|
120
|
+
similar: RecommendationHit<T>[];
|
|
121
|
+
};
|
|
96
122
|
export declare class FindlaneError extends Error {
|
|
97
123
|
status: number;
|
|
98
124
|
constructor(status: number, message: string);
|
|
99
125
|
}
|
|
126
|
+
/** A product in a cart or an order. */
|
|
127
|
+
export type EventItem = {
|
|
128
|
+
objectID: string;
|
|
129
|
+
quantity?: number;
|
|
130
|
+
price?: number;
|
|
131
|
+
};
|
|
132
|
+
/** Where a clicked product was: its place in the results (from 1), and the search's query or the
|
|
133
|
+
* recommendation list (and the product whose list it was). */
|
|
134
|
+
export type ClickDetails = {
|
|
135
|
+
position?: number;
|
|
136
|
+
query?: string;
|
|
137
|
+
list?: "goesWith" | "similar" | "together";
|
|
138
|
+
from?: string;
|
|
139
|
+
};
|
|
140
|
+
/** Events from the page, sent in the background: a batch a second, and what's left when the page is
|
|
141
|
+
* hidden or closed. They carry no cookie or ID. */
|
|
142
|
+
export type BrowserEvents = {
|
|
143
|
+
view(objectID: string): void;
|
|
144
|
+
click(objectID: string, details?: ClickDetails): void;
|
|
145
|
+
addToCart(items: EventItem[], options?: {
|
|
146
|
+
currency?: string;
|
|
147
|
+
}): void;
|
|
148
|
+
/** Counts for Bought together only when the index allows orders from browsers (usually a server
|
|
149
|
+
* sends them); always counts on the index's Events page. */
|
|
150
|
+
purchase(order: {
|
|
151
|
+
orderID: string;
|
|
152
|
+
items: EventItem[];
|
|
153
|
+
currency?: string;
|
|
154
|
+
}): void;
|
|
155
|
+
/** With `events: "afterConsent"`: true sends the events held so far and those after; false drops them. */
|
|
156
|
+
consent(granted: boolean): void;
|
|
157
|
+
/** Sends the events waiting now. */
|
|
158
|
+
flush(): void;
|
|
159
|
+
};
|
|
160
|
+
type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
100
161
|
/** The hosted Findlane API, used unless `baseUrl` says otherwise. */
|
|
101
162
|
export declare const defaultBaseUrl = "https://api.findlane.dev";
|
|
102
163
|
export type BrowserClientOptions = {
|
|
103
164
|
/** The API to call; defaults to the hosted Findlane API. */
|
|
104
165
|
baseUrl?: string;
|
|
105
166
|
publicId: string;
|
|
106
|
-
fetch?:
|
|
167
|
+
fetch?: Fetch;
|
|
168
|
+
/** Whether `events` sends anything: true (the default), false, or "afterConsent" to hold events until
|
|
169
|
+
* `events.consent(true)`. */
|
|
170
|
+
events?: boolean | "afterConsent";
|
|
107
171
|
};
|
|
108
172
|
export declare class SearchBrowserClient {
|
|
109
173
|
private readonly url;
|
|
110
174
|
private readonly fetcher;
|
|
175
|
+
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
176
|
+
readonly events: BrowserEvents;
|
|
111
177
|
constructor(options: BrowserClientOptions);
|
|
112
178
|
private post;
|
|
113
179
|
search<T = Record<string, unknown>>(request: SearchRequest, options?: {
|
|
@@ -119,4 +185,10 @@ export declare class SearchBrowserClient {
|
|
|
119
185
|
suggest(request: SearchRequest, options?: {
|
|
120
186
|
signal?: AbortSignal;
|
|
121
187
|
}): Promise<DidYouMean | null>;
|
|
188
|
+
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
189
|
+
* products, for a product page or a cart. Not counted as a search. */
|
|
190
|
+
recommendations<T = Record<string, unknown>>(objectID: string, request?: RecommendationsRequest, options?: {
|
|
191
|
+
signal?: AbortSignal;
|
|
192
|
+
}): Promise<RecommendationsResponse<T>>;
|
|
122
193
|
}
|
|
194
|
+
export {};
|
package/dist/index.js
CHANGED
|
@@ -8,14 +8,60 @@ export class FindlaneError extends Error {
|
|
|
8
8
|
}
|
|
9
9
|
/** The hosted Findlane API, used unless `baseUrl` says otherwise. */
|
|
10
10
|
export const defaultBaseUrl = "https://api.findlane.dev";
|
|
11
|
+
function eventSender(url, fetcher, mode) {
|
|
12
|
+
let queue = [];
|
|
13
|
+
let allowed = mode === "afterConsent" ? undefined : mode;
|
|
14
|
+
let timer;
|
|
15
|
+
let listening = false;
|
|
16
|
+
const flush = () => {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
timer = undefined;
|
|
19
|
+
while (allowed && queue.length) {
|
|
20
|
+
const body = JSON.stringify({ events: queue.splice(0, 100) });
|
|
21
|
+
// A beacon outlives the page; text/plain needs no CORS preflight.
|
|
22
|
+
if (fetcher || !(typeof navigator == "object" && navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" }))))
|
|
23
|
+
(fetcher ?? fetch)(url, { method: "POST", headers: { "content-type": "text/plain" }, body, keepalive: true })
|
|
24
|
+
.catch(() => undefined);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const add = (event) => {
|
|
28
|
+
if (allowed === false)
|
|
29
|
+
return;
|
|
30
|
+
if (queue.push(event) > 500)
|
|
31
|
+
queue.shift();
|
|
32
|
+
if (!listening && typeof document == "object") {
|
|
33
|
+
listening = true;
|
|
34
|
+
addEventListener("pagehide", flush);
|
|
35
|
+
document.addEventListener("visibilitychange", () => document.hidden && flush());
|
|
36
|
+
}
|
|
37
|
+
timer ??= setTimeout(flush, 1000);
|
|
38
|
+
};
|
|
39
|
+
return {
|
|
40
|
+
view: (objectID) => add({ type: "view", objectID }),
|
|
41
|
+
click: (objectID, details) => add({ type: "click", objectID, ...details }),
|
|
42
|
+
addToCart: (items, options) => add({ type: "addToCart", items, ...options }),
|
|
43
|
+
purchase: (order) => add({ type: "purchase", ...order }),
|
|
44
|
+
consent(granted) {
|
|
45
|
+
allowed = granted;
|
|
46
|
+
if (granted)
|
|
47
|
+
flush();
|
|
48
|
+
else
|
|
49
|
+
queue = [];
|
|
50
|
+
},
|
|
51
|
+
flush,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
11
54
|
export class SearchBrowserClient {
|
|
12
55
|
url;
|
|
13
56
|
fetcher;
|
|
57
|
+
/** Views, clicks, add-to-carts and orders, for the index's Events page and Bought together. */
|
|
58
|
+
events;
|
|
14
59
|
constructor(options) {
|
|
15
60
|
if (!/^pub_[a-f0-9]{32}$/.test(options.publicId))
|
|
16
61
|
throw new Error("A valid public index ID (pub_…) is required");
|
|
17
62
|
this.url = `${(options.baseUrl ?? defaultBaseUrl).replace(/\/$/, "")}/api/public/indexes/${options.publicId}`;
|
|
18
63
|
this.fetcher = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
64
|
+
this.events = eventSender(`${this.url}/events`, options.fetch, options.events ?? true);
|
|
19
65
|
}
|
|
20
66
|
async post(operation, request, signal) {
|
|
21
67
|
const response = await this.fetcher(`${this.url}/${operation}`, {
|
|
@@ -40,4 +86,9 @@ export class SearchBrowserClient {
|
|
|
40
86
|
async suggest(request, options = {}) {
|
|
41
87
|
return (await this.post("suggest", request, options.signal)).didYouMean;
|
|
42
88
|
}
|
|
89
|
+
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
90
|
+
* products, for a product page or a cart. Not counted as a search. */
|
|
91
|
+
recommendations(objectID, request = {}, options = {}) {
|
|
92
|
+
return this.post("recommendations", { objectID, ...request }, options.signal);
|
|
93
|
+
}
|
|
43
94
|
}
|
package/dist/react/cache.d.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import type { SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
|
|
1
|
+
import type { RecommendationsRequest, RecommendationsResponse, SearchBrowserClient, SearchRequest, SearchResponse } from "../index.js";
|
|
2
2
|
/** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
|
|
3
3
|
* the query's first eight lowercase words, defaults left out, and list order ignored. */
|
|
4
4
|
export declare function searchKey(request: SearchRequest): string;
|
|
5
|
+
/** A product's recommendations share a key when only defaults differ: the limit of 8 and empty filters. */
|
|
6
|
+
export declare function recommendationsKey(objectID: string, request?: RecommendationsRequest): string;
|
|
5
7
|
/** A cached response, if there's a recent one. */
|
|
6
8
|
export declare function peek<T>(client: SearchBrowserClient, request: SearchRequest): SearchResponse<T> | null;
|
|
9
|
+
export declare function peekRecommendations<T>(client: SearchBrowserClient, objectID: string, request?: RecommendationsRequest): RecommendationsResponse<T> | null;
|
|
7
10
|
/** The search's response: from memory, from the same search in flight, or a new request. A caller's
|
|
8
11
|
* signal stops its own wait; the request stops once every caller waiting for it has. */
|
|
9
12
|
export declare function cachedSearch<T>(client: SearchBrowserClient, request: SearchRequest, signal?: AbortSignal): Promise<SearchResponse<T>>;
|
|
13
|
+
/** A product's recommendations, kept and shared the same way. */
|
|
14
|
+
export declare function cachedRecommendations<T>(client: SearchBrowserClient, objectID: string, request?: RecommendationsRequest, signal?: AbortSignal): Promise<RecommendationsResponse<T>>;
|
package/dist/react/cache.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { canonical } from "./state.js";
|
|
2
2
|
const size = 50;
|
|
3
3
|
const maxAge = 5 * 60_000;
|
|
4
|
-
const
|
|
5
|
-
const
|
|
4
|
+
const store = () => ({ responses: new WeakMap(), flights: new WeakMap() });
|
|
5
|
+
const searches = store();
|
|
6
|
+
const recommendations = store();
|
|
6
7
|
/** Requests the API answers the same way share a key, as at the edge (workers/api/src/edge.ts):
|
|
7
8
|
* the query's first eight lowercase words, defaults left out, and list order ignored. */
|
|
8
9
|
export function searchKey(request) {
|
|
@@ -20,58 +21,77 @@ export function searchKey(request) {
|
|
|
20
21
|
}
|
|
21
22
|
return canonical(normal);
|
|
22
23
|
}
|
|
24
|
+
/** A product's recommendations share a key when only defaults differ: the limit of 8 and empty filters. */
|
|
25
|
+
export function recommendationsKey(objectID, request = {}) {
|
|
26
|
+
const { limit, filters, ...rest } = request;
|
|
27
|
+
return canonical({ objectID, ...rest, ...(limit !== undefined && limit !== 8 ? { limit } : {}),
|
|
28
|
+
...(filters && Object.keys(filters).length ? { filters } : {}) });
|
|
29
|
+
}
|
|
30
|
+
function lookup(from, client, key) {
|
|
31
|
+
const entry = from.responses.get(client)?.get(key);
|
|
32
|
+
return entry && Date.now() - entry.at < maxAge ? entry.response : null;
|
|
33
|
+
}
|
|
23
34
|
/** A cached response, if there's a recent one. */
|
|
24
35
|
export function peek(client, request) {
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
return lookup(searches, client, searchKey(request));
|
|
37
|
+
}
|
|
38
|
+
export function peekRecommendations(client, objectID, request) {
|
|
39
|
+
return lookup(recommendations, client, recommendationsKey(objectID, request));
|
|
27
40
|
}
|
|
28
41
|
/** The search's response: from memory, from the same search in flight, or a new request. A caller's
|
|
29
42
|
* signal stops its own wait; the request stops once every caller waiting for it has. */
|
|
30
43
|
export function cachedSearch(client, request, signal) {
|
|
31
|
-
|
|
44
|
+
return shared(searches, client, searchKey(request), (stop) => client.search(request, { signal: stop }), signal);
|
|
45
|
+
}
|
|
46
|
+
/** A product's recommendations, kept and shared the same way. */
|
|
47
|
+
export function cachedRecommendations(client, objectID, request = {}, signal) {
|
|
48
|
+
return shared(recommendations, client, recommendationsKey(objectID, request), (stop) => client.recommendations(objectID, request, { signal: stop }), signal);
|
|
49
|
+
}
|
|
50
|
+
function shared(from, client, key, load, signal) {
|
|
51
|
+
const found = lookup(from, client, key);
|
|
32
52
|
if (found)
|
|
33
53
|
return Promise.resolve(found);
|
|
34
54
|
if (signal?.aborted)
|
|
35
55
|
return Promise.reject(signal.reason);
|
|
36
|
-
|
|
37
|
-
let inFlight = flights.get(client);
|
|
56
|
+
let inFlight = from.flights.get(client);
|
|
38
57
|
if (!inFlight)
|
|
39
|
-
flights.set(client, (inFlight = new Map()));
|
|
40
|
-
|
|
58
|
+
from.flights.set(client, (inFlight = new Map()));
|
|
59
|
+
const flights = inFlight;
|
|
60
|
+
let flight = flights.get(key);
|
|
41
61
|
if (!flight) {
|
|
42
62
|
const controller = new AbortController();
|
|
43
63
|
const started = {
|
|
44
64
|
controller,
|
|
45
65
|
waiting: 0,
|
|
46
|
-
response:
|
|
47
|
-
remember(client, key, response);
|
|
66
|
+
response: load(controller.signal).then((response) => {
|
|
67
|
+
remember(from, client, key, response);
|
|
48
68
|
return response;
|
|
49
|
-
}).finally(() => { if (
|
|
50
|
-
|
|
69
|
+
}).finally(() => { if (flights.get(key) === started)
|
|
70
|
+
flights.delete(key); }),
|
|
51
71
|
};
|
|
52
|
-
|
|
72
|
+
flights.set(key, (flight = started));
|
|
53
73
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
74
|
+
const current = flight;
|
|
75
|
+
current.waiting++;
|
|
56
76
|
return new Promise((resolve, reject) => {
|
|
57
77
|
const leave = () => {
|
|
58
78
|
reject(signal.reason);
|
|
59
|
-
if (--
|
|
79
|
+
if (--current.waiting > 0)
|
|
60
80
|
return;
|
|
61
81
|
// A later caller starts a new request rather than joining a stopped one.
|
|
62
|
-
if (
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
if (flights.get(key) === current)
|
|
83
|
+
flights.delete(key);
|
|
84
|
+
current.controller.abort();
|
|
65
85
|
};
|
|
66
86
|
signal?.addEventListener("abort", leave, { once: true });
|
|
67
|
-
|
|
87
|
+
current.response.then((response) => resolve(response), reject)
|
|
68
88
|
.finally(() => signal?.removeEventListener("abort", leave));
|
|
69
89
|
});
|
|
70
90
|
}
|
|
71
|
-
function remember(client, key, response) {
|
|
72
|
-
let cache =
|
|
91
|
+
function remember(from, client, key, response) {
|
|
92
|
+
let cache = from.responses.get(client);
|
|
73
93
|
if (!cache)
|
|
74
|
-
|
|
94
|
+
from.responses.set(client, (cache = new Map()));
|
|
75
95
|
cache.delete(key);
|
|
76
96
|
cache.set(key, { at: Date.now(), response });
|
|
77
97
|
// A Map keeps insertion order, so the first key is the oldest.
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { FindlaneProvider, useClient, type ClientOptions } from "./client.js";
|
|
2
2
|
export { useSearch, type UseSearchOptions, type UseSearchResult } from "./use-search.js";
|
|
3
3
|
export { useAutocomplete, type AutocompleteOptions } from "./use-autocomplete.js";
|
|
4
|
+
export { useRecommendations, type UseRecommendationsOptions, type UseRecommendationsResult } from "./use-recommendations.js";
|
|
5
|
+
export { Recommendations, type RecommendationsProps } from "./recommendations.js";
|
|
4
6
|
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, type Range, type Refinement, type SearchProviderProps, } from "./search-provider.js";
|
|
5
7
|
export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, type HitsProps, type RefinementListProps, type SearchBoxProps, } from "./components.js";
|
|
6
8
|
export { SearchDialog, type SearchDialogProps } from "./dialog.js";
|
package/dist/react/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
// @findlane/browser/react: hooks that keep searches current, and components built on them.
|
|
2
|
+
// @findlane/browser/react: hooks that keep searches (and recommendations) current, and components built on them.
|
|
3
3
|
export { FindlaneProvider, useClient } from "./client.js";
|
|
4
4
|
export { useSearch } from "./use-search.js";
|
|
5
5
|
export { useAutocomplete } from "./use-autocomplete.js";
|
|
6
|
+
export { useRecommendations } from "./use-recommendations.js";
|
|
7
|
+
export { Recommendations } from "./recommendations.js";
|
|
6
8
|
export { SearchProvider, useCurrentRefinements, useDidYouMean, useGeoSearch, useHits, useLoadMore, usePagination, useRange, useRefinementList, useSearchBox, useSearchState, useSortBy, useToggle, } from "./search-provider.js";
|
|
7
9
|
export { CurrentRefinements, DefaultHit, DidYouMean, Hits, LoadMore, RefinementList, SearchBox, SearchInput, SortSelect, Stats, } from "./components.js";
|
|
8
10
|
export { SearchDialog } from "./dialog.js";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { RecommendationsRequest } from "../index.js";
|
|
3
|
+
import type { ClientOptions } from "./client.js";
|
|
4
|
+
import type { Hit } from "./state.js";
|
|
5
|
+
export type RecommendationsProps<T> = ClientOptions & RecommendationsRequest & {
|
|
6
|
+
/** The product whose recommendations to show. */
|
|
7
|
+
objectID: string;
|
|
8
|
+
/** Products that go with it (the default), or similar products. */
|
|
9
|
+
list?: "goesWith" | "similar";
|
|
10
|
+
/** The heading (default "Goes well with" or "Similar products"); null for none. */
|
|
11
|
+
title?: ReactNode;
|
|
12
|
+
/** A product's content; DefaultHit shows an image and a title. */
|
|
13
|
+
render?: (hit: Hit<T>, index: number) => ReactNode;
|
|
14
|
+
/** Shown while the lists load. */
|
|
15
|
+
loading?: ReactNode;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
/** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
|
|
19
|
+
* product has none, so a product page can always include it. A Goes with and a Similar list for the
|
|
20
|
+
* same product share one request. */
|
|
21
|
+
export declare function Recommendations<T = Record<string, unknown>>({ objectID, list, title, render, loading, className, ...options }: RecommendationsProps<T>): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { DefaultHit } from "./components.js";
|
|
4
|
+
import { useRecommendations } from "./use-recommendations.js";
|
|
5
|
+
/** One of a product's recommendation lists (the recommendations add-on). It renders nothing while the
|
|
6
|
+
* product has none, so a product page can always include it. A Goes with and a Similar list for the
|
|
7
|
+
* same product share one request. */
|
|
8
|
+
export function Recommendations({ objectID, list = "goesWith", title, render, loading = null, className, ...options }) {
|
|
9
|
+
const { data, isPending } = useRecommendations(objectID, options);
|
|
10
|
+
if (!data)
|
|
11
|
+
return isPending ? _jsx(_Fragment, { children: loading }) : null;
|
|
12
|
+
const hits = data[list];
|
|
13
|
+
if (!hits.length)
|
|
14
|
+
return null;
|
|
15
|
+
const heading = title === undefined ? (list === "goesWith" ? "Goes well with" : "Similar products") : title;
|
|
16
|
+
return (_jsxs("section", { className: className, "data-findlane-recommendations": list, children: [heading !== null && _jsx("h2", { "data-findlane-recommendations-title": "", children: heading }), _jsx("ol", { "data-findlane-hits": "", children: hits.map((hit, index) => _jsx("li", { children: render ? render(hit, index) : _jsx(DefaultHit, { hit: hit }) }, hit.id)) })] }));
|
|
17
|
+
}
|
package/dist/react/styles.css
CHANGED
|
@@ -152,6 +152,28 @@
|
|
|
152
152
|
transition: opacity 0.15s 0.1s;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
:where([data-findlane-recommendations]) {
|
|
156
|
+
margin: 24px 0;
|
|
157
|
+
}
|
|
158
|
+
:where([data-findlane-recommendations-title]) {
|
|
159
|
+
margin: 0 0 12px;
|
|
160
|
+
font-size: 1.1em;
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
}
|
|
163
|
+
:where([data-findlane-recommendations] [data-findlane-hits]) {
|
|
164
|
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
|
165
|
+
}
|
|
166
|
+
:where([data-findlane-recommendations] [data-findlane-hits] > li) {
|
|
167
|
+
display: grid;
|
|
168
|
+
gap: 6px;
|
|
169
|
+
align-content: start;
|
|
170
|
+
}
|
|
171
|
+
:where([data-findlane-recommendations] [data-findlane-image]) {
|
|
172
|
+
width: 100%;
|
|
173
|
+
height: auto;
|
|
174
|
+
aspect-ratio: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
155
177
|
:where([data-findlane-refinement-list]) {
|
|
156
178
|
min-width: 0;
|
|
157
179
|
margin: 0 0 18px;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RecommendationsRequest, RecommendationsResponse } from "../index.js";
|
|
2
|
+
import { type ClientOptions } from "./client.js";
|
|
3
|
+
export type UseRecommendationsOptions<T> = ClientOptions & RecommendationsRequest & {
|
|
4
|
+
/** Lists for the first product, such as ones from the server. */
|
|
5
|
+
initialData?: RecommendationsResponse<T>;
|
|
6
|
+
};
|
|
7
|
+
export type UseRecommendationsResult<T> = {
|
|
8
|
+
data: RecommendationsResponse<T> | null;
|
|
9
|
+
error: unknown;
|
|
10
|
+
isPending: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
13
|
+
* products. Components asking for the same product at once share one request. Pass `null` for none. */
|
|
14
|
+
export declare function useRecommendations<T = Record<string, unknown>>(objectID: string | null, { initialData, client: given, publicId, baseUrl, ...request }?: UseRecommendationsOptions<T>): UseRecommendationsResult<T>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { cachedRecommendations, peekRecommendations, recommendationsKey } from "./cache.js";
|
|
4
|
+
import { useClient } from "./client.js";
|
|
5
|
+
/** A product's recommendations (the recommendations add-on): products that go with it, and similar
|
|
6
|
+
* products. Components asking for the same product at once share one request. Pass `null` for none. */
|
|
7
|
+
export function useRecommendations(objectID, { initialData, client: given, publicId, baseUrl, ...request } = {}) {
|
|
8
|
+
const client = useClient({ client: given, publicId, baseUrl });
|
|
9
|
+
const key = objectID === null ? null : recommendationsKey(objectID, request);
|
|
10
|
+
const [result, setResult] = useState(() => ({
|
|
11
|
+
data: initialData && initialData.objectID === objectID ? initialData : null,
|
|
12
|
+
error: null,
|
|
13
|
+
isPending: objectID !== null && !(initialData && initialData.objectID === objectID),
|
|
14
|
+
}));
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (objectID === null) {
|
|
17
|
+
setResult((current) => (current.data || current.error || current.isPending ? { data: null, error: null, isPending: false } : current));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (initialData && initialData.objectID === objectID)
|
|
21
|
+
return;
|
|
22
|
+
const cached = peekRecommendations(client, objectID, request);
|
|
23
|
+
if (cached) {
|
|
24
|
+
setResult({ data: cached, error: null, isPending: false });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
setResult((current) => (current.isPending ? current : { ...current, isPending: true }));
|
|
28
|
+
let active = true;
|
|
29
|
+
const controller = new AbortController();
|
|
30
|
+
cachedRecommendations(client, objectID, request, controller.signal).then((data) => { if (active)
|
|
31
|
+
setResult({ data, error: null, isPending: false }); }, (error) => { if (active)
|
|
32
|
+
setResult((current) => ({ ...current, error, isPending: false })); });
|
|
33
|
+
return () => {
|
|
34
|
+
active = false;
|
|
35
|
+
controller.abort();
|
|
36
|
+
};
|
|
37
|
+
// The key stands for the product and options, so a new options object asking the same doesn't fetch again.
|
|
38
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
39
|
+
}, [client, key]);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@findlane/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Findlane browser SDK: fast, typo-tolerant public search from the browser with an index's public ID, plus React hooks and components.",
|
|
5
5
|
"keywords": ["findlane", "search", "full-text search", "typo tolerance", "instant search", "ecommerce search", "browser", "react", "autocomplete"],
|
|
6
6
|
"license": "MIT",
|