@findlane/browser 0.6.0 → 0.7.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 CHANGED
@@ -93,6 +93,32 @@ 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);
@@ -119,4 +145,9 @@ export declare class SearchBrowserClient {
119
145
  suggest(request: SearchRequest, options?: {
120
146
  signal?: AbortSignal;
121
147
  }): Promise<DidYouMean | null>;
148
+ /** A product's recommendations (the recommendations add-on): products that go with it, and similar
149
+ * products, for a product page or a cart. Not counted as a search. */
150
+ recommendations<T = Record<string, unknown>>(objectID: string, request?: RecommendationsRequest, options?: {
151
+ signal?: AbortSignal;
152
+ }): Promise<RecommendationsResponse<T>>;
122
153
  }
package/dist/index.js CHANGED
@@ -40,4 +40,9 @@ export class SearchBrowserClient {
40
40
  async suggest(request, options = {}) {
41
41
  return (await this.post("suggest", request, options.signal)).didYouMean;
42
42
  }
43
+ /** A product's recommendations (the recommendations add-on): products that go with it, and similar
44
+ * products, for a product page or a cart. Not counted as a search. */
45
+ recommendations(objectID, request = {}, options = {}) {
46
+ return this.post("recommendations", { objectID, ...request }, options.signal);
47
+ }
43
48
  }
@@ -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>>;
@@ -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 caches = new WeakMap();
5
- const flights = new WeakMap();
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
- const entry = caches.get(client)?.get(searchKey(request));
26
- return entry && Date.now() - entry.at < maxAge ? entry.response : null;
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
- const found = peek(client, request);
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
- const key = searchKey(request);
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
- let flight = inFlight.get(key);
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: client.search(request, { signal: controller.signal }).then((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 (inFlight.get(key) === started)
50
- inFlight.delete(key); }),
69
+ }).finally(() => { if (flights.get(key) === started)
70
+ flights.delete(key); }),
51
71
  };
52
- inFlight.set(key, (flight = started));
72
+ flights.set(key, (flight = started));
53
73
  }
54
- const shared = flight;
55
- shared.waiting++;
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 (--shared.waiting > 0)
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 (inFlight.get(key) === shared)
63
- inFlight.delete(key);
64
- shared.controller.abort();
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
- shared.response.then((response) => resolve(response), reject)
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 = caches.get(client);
91
+ function remember(from, client, key, response) {
92
+ let cache = from.responses.get(client);
73
93
  if (!cache)
74
- caches.set(client, (cache = new Map()));
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.
@@ -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";
@@ -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
+ }
@@ -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.6.0",
3
+ "version": "0.7.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",