@luigisbox/luma 0.0.0-stage → 0.2.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/LICENSE.md +90 -0
- package/README.md +615 -2
- package/a11y/audit.mjs +735 -0
- package/a11y/axe.mjs +73 -0
- package/a11y/report.mjs +347 -0
- package/a11y/run.mjs +256 -0
- package/a11y/wcag.mjs +233 -0
- package/bin/config.mjs +60 -0
- package/bin/luma.mjs +99 -0
- package/bin/paths.mjs +11 -0
- package/bin/tools.mjs +43 -0
- package/build/baseline.d.mts +8 -0
- package/build/baseline.mjs +167 -0
- package/build/tooling.mjs +102 -0
- package/build/vite.mjs +507 -0
- package/chrome/block.mjs +8 -0
- package/chrome/run.mjs +150 -0
- package/docs/ACCESSIBILITY.md +501 -0
- package/docs/INTEGRATION-PERFORMANCE.md +213 -0
- package/docs/PARITY.md +946 -0
- package/footprint/model.mjs +184 -0
- package/footprint/run.mjs +331 -0
- package/package.json +58 -4
- package/parity/alignment.mjs +109 -0
- package/parity/archive.mjs +223 -0
- package/parity/args.mjs +137 -0
- package/parity/behaviour.mjs +379 -0
- package/parity/compare.mjs +985 -0
- package/parity/engine.mjs +848 -0
- package/parity/hints.mjs +132 -0
- package/parity/hold.mjs +54 -0
- package/parity/init.mjs +703 -0
- package/parity/map.mjs +640 -0
- package/parity/owner.mjs +170 -0
- package/parity/pairing.mjs +110 -0
- package/parity/pixels.mjs +33 -0
- package/parity/probe.mjs +1239 -0
- package/parity/report.mjs +236 -0
- package/parity/run.mjs +267 -0
- package/snippet/early-hold.js +64 -0
- package/snippet/early-hold.min.js +1 -0
- package/src/analytics/chain.ts +137 -0
- package/src/analytics/collect.ts +64 -0
- package/src/analytics/datalayer.ts +67 -0
- package/src/analytics/from-ga4.ts +68 -0
- package/src/analytics/from-luma.ts +21 -0
- package/src/analytics/index.ts +24 -0
- package/src/analytics/outbox.ts +118 -0
- package/src/analytics/page.ts +46 -0
- package/src/analytics/pageview.ts +82 -0
- package/src/analytics/ref.ts +19 -0
- package/src/analytics/setup.ts +41 -0
- package/src/analytics/start.ts +53 -0
- package/src/analytics/triggers.ts +239 -0
- package/src/analytics/user.ts +12 -0
- package/src/analytics/wire.ts +67 -0
- package/src/api/ai.ts +98 -0
- package/src/api/index.ts +87 -0
- package/src/api/token-key.ts +42 -0
- package/src/api/token.ts +312 -0
- package/src/core/a11y-keys.ts +60 -0
- package/src/core/a11y.ts +160 -0
- package/src/core/boot-state.ts +6 -0
- package/src/core/color.ts +42 -0
- package/src/core/context.ts +108 -0
- package/src/core/early-rules.ts +96 -0
- package/src/core/entries.ts +25 -0
- package/src/core/fold.ts +7 -0
- package/src/core/health.ts +26 -0
- package/src/core/hold.ts +74 -0
- package/src/core/i18n.ts +66 -0
- package/src/core/image-load.ts +102 -0
- package/src/core/image-tags.ts +26 -0
- package/src/core/images.ts +170 -0
- package/src/core/integration.ts +126 -0
- package/src/core/kept.ts +25 -0
- package/src/core/listings.ts +100 -0
- package/src/core/locales.ts +14 -0
- package/src/core/own.ts +49 -0
- package/src/core/page.ts +14 -0
- package/src/core/prefetch.ts +88 -0
- package/src/core/recommenders.ts +166 -0
- package/src/core/reconciler.ts +397 -0
- package/src/core/result.ts +73 -0
- package/src/core/routes.ts +115 -0
- package/src/core/scheduler.ts +83 -0
- package/src/core/sense.ts +107 -0
- package/src/core/speculation.ts +59 -0
- package/src/core/stash.ts +38 -0
- package/src/core/storage.ts +45 -0
- package/src/core/styles.ts +106 -0
- package/src/core/support.ts +12 -0
- package/src/core/takeover.ts +220 -0
- package/src/core/templates.ts +74 -0
- package/src/core/tiles.ts +137 -0
- package/src/core/turbo.ts +64 -0
- package/src/core/url.ts +164 -0
- package/src/core/widget-inits.ts +40 -0
- package/src/env.d.ts +3 -0
- package/src/lit.ts +11 -0
- package/src/locales/cs.json +94 -0
- package/src/locales/de.json +88 -0
- package/src/locales/en.json +88 -0
- package/src/main.ts +493 -0
- package/src/types.ts +778 -0
- package/src/widgets/autocomplete/index.ts +1387 -0
- package/src/widgets/autocomplete/style-autocomplete.css +266 -0
- package/src/widgets/autocomplete/template-autocomplete-box.ts +61 -0
- package/src/widgets/autocomplete/template-autocomplete-empty.ts +53 -0
- package/src/widgets/autocomplete/template-autocomplete-result-default.ts +82 -0
- package/src/widgets/autocomplete/template-autocomplete-sheet.ts +74 -0
- package/src/widgets/listing/element.ts +1772 -0
- package/src/widgets/listing/flow.ts +30 -0
- package/src/widgets/listing/instant.ts +133 -0
- package/src/widgets/listing/intent.ts +193 -0
- package/src/widgets/listing/query.ts +298 -0
- package/src/widgets/listing/stash.ts +97 -0
- package/src/widgets/listing/state.ts +131 -0
- package/src/widgets/listing/view.ts +257 -0
- package/src/widgets/plp/index.ts +11 -0
- package/src/widgets/plp/route.ts +263 -0
- package/src/widgets/recommender/index.ts +261 -0
- package/src/widgets/recommender/style-recommender.css +26 -0
- package/src/widgets/recommender/template-recommender-box-default.ts +11 -0
- package/src/widgets/recommender/template-recommender-popup.ts +12 -0
- package/src/widgets/recommender/template-recommender-result-default.ts +10 -0
- package/src/widgets/search/assets.ts +7 -0
- package/src/widgets/search/first.ts +15 -0
- package/src/widgets/search/form-url.ts +59 -0
- package/src/widgets/search/in-place.ts +226 -0
- package/src/widgets/search/index.ts +16 -0
- package/src/widgets/search/range.ts +260 -0
- package/src/widgets/search/route.ts +32 -0
- package/src/widgets/search/speculate.ts +202 -0
- package/src/widgets/search/style-search.css +65 -0
- package/src/widgets/search/template-search-box.ts +17 -0
- package/src/widgets/search/template-search-facets-default.ts +73 -0
- package/src/widgets/search/template-search-header.ts +14 -0
- package/src/widgets/search/template-search-pagination.ts +12 -0
- package/src/widgets/search/template-search-range-default.ts +48 -0
- package/src/widgets/search/template-search-result-default.ts +13 -0
- package/src/widgets/search/template-search-results-default.ts +8 -0
- package/tasks/model.mjs +116 -0
- package/tasks/run.mjs +305 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Category listing: a collection. The host's category URL becomes the page's
|
|
2
|
+
// scope (collection_filters), either from a category record looked up in the
|
|
3
|
+
// catalog by URL (through the listing's own collection surface), or straight
|
|
4
|
+
// from the URL (fromUrl, in _init.ts).
|
|
5
|
+
//
|
|
6
|
+
// URL compatibility, both ways:
|
|
7
|
+
// - reading: the host's own filter/sort params are mapped to our state
|
|
8
|
+
// (params in _config.ts). A URL with a param we cannot express is not taken
|
|
9
|
+
// over: the host renders it natively, so no URL ever loses a filter.
|
|
10
|
+
// - writing: our UI writes the host's own params where a mapping exists, so
|
|
11
|
+
// URLs we produce are the host's URLs and keep working without us.
|
|
12
|
+
// No DOM: the edge render service resolves URLs with it too.
|
|
13
|
+
|
|
14
|
+
import type { Api, Hit } from '../../api';
|
|
15
|
+
import { isPromise } from '../../core/scheduler';
|
|
16
|
+
import { flagId, has, P, paramValues, plpAccepts, TRACKING, withoutTracking } from '../../core/routes';
|
|
17
|
+
import { lru, session } from '../../core/storage';
|
|
18
|
+
import type { CategoryFilter, FacetLayout, GlobalConfigType, PlpConfigType } from '../../types';
|
|
19
|
+
import { reportError } from '../../core/health';
|
|
20
|
+
import { readOwn, type Query, type Route, type UiState } from '../listing/state';
|
|
21
|
+
import { parseRange } from '../listing/query';
|
|
22
|
+
|
|
23
|
+
/** "Salt & Pepper" -> "salt-and-pepper". */
|
|
24
|
+
export const slugify = (s: string) =>
|
|
25
|
+
s
|
|
26
|
+
.normalize('NFD')
|
|
27
|
+
.replace(/[̀-ͯ]/g, '')
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/&/g, ' and ')
|
|
30
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
+
.replace(/^-+|-+$/g, '');
|
|
32
|
+
|
|
33
|
+
export const plpRoute = (pc: PlpConfigType, api: Api, g: GlobalConfigType): Route => {
|
|
34
|
+
const channel = g.channelId;
|
|
35
|
+
const pm = pc.params ?? {};
|
|
36
|
+
const sep = pm.separator;
|
|
37
|
+
const flags = (pm.flags ?? []).map((f) => ({ ...f, id: flagId(f) }));
|
|
38
|
+
// Our own flags (config.flags) have no host param: always lbx.flag=<id>.
|
|
39
|
+
const ownFlags = pc.flags ?? [];
|
|
40
|
+
const facetParams = pm.facetParams ?? {};
|
|
41
|
+
const mapped = Object.entries(facetParams);
|
|
42
|
+
const order = new Map(mapped.map(([facet], i) => [facet, i]));
|
|
43
|
+
const mappedParams = new Set(mapped.map(([, fp]) => fp.param));
|
|
44
|
+
// Our value -> their value, per facet with a value map.
|
|
45
|
+
const inverse = Object.fromEntries(Object.entries(facetParams).map(([facet, fp]) => [facet, fp.values ? new Map(Object.entries(fp.values).map(([h, v]) => [v, h])) : null]));
|
|
46
|
+
const ignore = new RegExp(pm.ignore ?? TRACKING);
|
|
47
|
+
const values = (url: URL, param: string) => paramValues(url, param, sep);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A catalog lookup: memo, then the session cache (revisits and back
|
|
51
|
+
* navigation resolve without a round trip), then the API. The session holds
|
|
52
|
+
* what the API returned (`raw`), not what we make of it: a new toFilter (a
|
|
53
|
+
* deploy) applies at once. A failed lookup is retried next time.
|
|
54
|
+
*/
|
|
55
|
+
const lookup = <R, V>(memo: ReturnType<typeof lru<V | Promise<V>>>, k: string, fetch: () => Promise<R>, make: (raw: R) => V): V | Promise<V> => {
|
|
56
|
+
const m = memo.get(k);
|
|
57
|
+
if (m !== undefined) return m;
|
|
58
|
+
const stored = session.get<R>(k);
|
|
59
|
+
if (stored !== undefined) {
|
|
60
|
+
const v = make(stored);
|
|
61
|
+
memo.set(k, v);
|
|
62
|
+
return v;
|
|
63
|
+
}
|
|
64
|
+
const p = fetch().then((raw) => {
|
|
65
|
+
const v = make(raw);
|
|
66
|
+
memo.set(k, v);
|
|
67
|
+
session.set(k, raw);
|
|
68
|
+
return v;
|
|
69
|
+
});
|
|
70
|
+
memo.set(k, p);
|
|
71
|
+
p.catch(() => memo.delete(k));
|
|
72
|
+
return p;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// --- category for a URL: from the URL itself, or a catalog record
|
|
76
|
+
const catMemo = lru<CategoryFilter | Promise<CategoryFilter>>(500);
|
|
77
|
+
const category = (url: URL): CategoryFilter | Promise<CategoryFilter> => {
|
|
78
|
+
if (pc.fromUrl) return pc.fromUrl(url);
|
|
79
|
+
const lk = pc.lookup!;
|
|
80
|
+
const value = (lk.absolute ? url.origin : '') + (url.pathname.replace(/\/$/, '') || '/');
|
|
81
|
+
return lookup(
|
|
82
|
+
catMemo,
|
|
83
|
+
`lbx:cat2:${channel}:${value}`,
|
|
84
|
+
() =>
|
|
85
|
+
api
|
|
86
|
+
.search({ endpoint: 'collections', surface: pc.surface!, type: lk.type ?? 'category', scope: { [lk.field]: { eq: value } }, size: 1 })
|
|
87
|
+
.then((r): Hit | null => r.hits[0] ?? null),
|
|
88
|
+
(hit) => (hit ? pc.toFilter!(hit) : null),
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// --- facet slug -> value, per category (only for slug-valued host params):
|
|
93
|
+
// a slug needs the full list of the category's values
|
|
94
|
+
const valueMemo = lru<string[] | Promise<string[]>>(500);
|
|
95
|
+
const facetValues = (cat: NonNullable<CategoryFilter>, facet: string): string[] | Promise<string[]> =>
|
|
96
|
+
lookup(
|
|
97
|
+
valueMemo,
|
|
98
|
+
`lbx:fv:${channel}:${JSON.stringify(cat.scope)}:${facet}`,
|
|
99
|
+
() =>
|
|
100
|
+
api
|
|
101
|
+
.search({ endpoint: 'collections', surface: pc.surface!, type: g.itemType ?? 'product', scope: cat.scope, size: 1 })
|
|
102
|
+
.then((r) => api.facets(r.guid, [{ field: facet, size: 200 }]))
|
|
103
|
+
.then((f) => (f[facet]?.values ?? []).map(String)),
|
|
104
|
+
(vals) => vals,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
/** Build the query once the category (and any slug lookups) are known; sync when memoised. */
|
|
108
|
+
const build = (url: URL, cat: CategoryFilter): Query | null | Promise<Query | null> => {
|
|
109
|
+
if (!cat) return null;
|
|
110
|
+
const own = readOwn(url);
|
|
111
|
+
const q: Query = { scope: cat.scope, title: cat.title, ...own };
|
|
112
|
+
if (pm.sort) {
|
|
113
|
+
const hv = url.searchParams.get(pm.sort.param);
|
|
114
|
+
if (hv && has(pm.sort.values, hv)) q.sort = pm.sort.values[hv];
|
|
115
|
+
}
|
|
116
|
+
const flagged = url.searchParams.getAll(P + 'flag');
|
|
117
|
+
q.flags = [...flags.filter((f) => values(url, f.param).includes(f.value) || flagged.includes(f.id)), ...ownFlags.filter((f) => flagged.includes(f.id))].map((f) => f.id);
|
|
118
|
+
// Their range params (a price range) are a filter of ours, in their format both ways.
|
|
119
|
+
for (const r of pm.ranges ?? []) {
|
|
120
|
+
const min = url.searchParams.get(r.min);
|
|
121
|
+
const max = url.searchParams.get(r.max);
|
|
122
|
+
if (min || max) q.filters = { ...q.filters, [r.field]: [`${min ?? ''}|${max ?? ''}`] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const pending: Promise<void>[] = [];
|
|
126
|
+
let unknownSlug = false;
|
|
127
|
+
const add = (facet: string, vs: string[]) => {
|
|
128
|
+
q.filters = { ...q.filters, [facet]: [...new Set([...(q.filters[facet] ?? []), ...vs])] };
|
|
129
|
+
};
|
|
130
|
+
for (const [facet, fp] of Object.entries(facetParams)) {
|
|
131
|
+
const mine = url.searchParams.getAll(P + facet);
|
|
132
|
+
if (mine.length) add(facet, mine);
|
|
133
|
+
const hv = values(url, fp.param);
|
|
134
|
+
if (!hv.length) continue;
|
|
135
|
+
if (fp.values) {
|
|
136
|
+
// Mapped values; with slug: false the others pass through as they are.
|
|
137
|
+
// A value mapped to '' (or unknown) is one we cannot express: the page is theirs.
|
|
138
|
+
const vs = hv.map((v) => (has(fp.values!, v) ? fp.values![v] : fp.slug === false ? v : ''));
|
|
139
|
+
if (vs.some((v) => !v)) unknownSlug = true;
|
|
140
|
+
add(facet, vs);
|
|
141
|
+
} else if (fp.slug === false) add(facet, hv);
|
|
142
|
+
else {
|
|
143
|
+
const apply = (vals: string[]) => {
|
|
144
|
+
const picked = hv.map((s) => vals.find((v) => slugify(v) === s));
|
|
145
|
+
if (picked.some((v) => v === undefined)) unknownSlug = true;
|
|
146
|
+
add(facet, picked as string[]);
|
|
147
|
+
};
|
|
148
|
+
const vals = facetValues(cat, facet);
|
|
149
|
+
if (isPromise(vals)) pending.push(vals.then(apply));
|
|
150
|
+
else apply(vals);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// A slug the catalog does not know: we cannot reproduce the host's filter.
|
|
154
|
+
if (!pending.length) return unknownSlug ? null : withFacets(url, q);
|
|
155
|
+
return Promise.all(pending).then(() => (unknownSlug ? null : withFacets(url, q)));
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** The page's own facet list (`facetsFor`, _init.ts): e.g. the category's parameters. */
|
|
159
|
+
const withFacets = (url: URL, q: Query): Query | Promise<Query> => {
|
|
160
|
+
if (!pc.facetsFor) return q;
|
|
161
|
+
const apply = (l: FacetLayout | null | undefined): Query => (l?.facets ? { ...q, facets: l.facets, layout: l.layout } : q);
|
|
162
|
+
const failed = (e: unknown) => (reportError('plp facetsFor', e), q);
|
|
163
|
+
try {
|
|
164
|
+
const l = pc.facetsFor(url, { scope: q.scope, title: q.title, filters: q.filters });
|
|
165
|
+
return isPromise(l) ? (l as Promise<FacetLayout | null>).then(apply, failed) : apply(l as FacetLayout | null);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
return failed(e);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Keyed without tracking params: every campaign link is the same page.
|
|
172
|
+
const resolved = lru<Query | null>(500);
|
|
173
|
+
const keyOf = (url: URL) => withoutTracking(url.href, ignore).href;
|
|
174
|
+
const resolve = (url: URL): Query | null | Promise<Query | null> => {
|
|
175
|
+
const key = keyOf(url);
|
|
176
|
+
const memo = resolved.get(key);
|
|
177
|
+
if (memo !== undefined) return memo;
|
|
178
|
+
const cat = category(url);
|
|
179
|
+
const done = (q: Query | null) => {
|
|
180
|
+
resolved.set(key, q);
|
|
181
|
+
return q;
|
|
182
|
+
};
|
|
183
|
+
if (isPromise(cat)) return cat.then((c) => build(url, c)).then(done);
|
|
184
|
+
const q = build(url, cat);
|
|
185
|
+
return isPromise(q) ? q.then(done) : done(q);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
type Params = Pick<URLSearchParams, 'delete' | 'append' | 'set'>;
|
|
189
|
+
/** Write a multi-valued host param in the host's own format. */
|
|
190
|
+
const put = (sp: Params, param: string, vs: string[]) => {
|
|
191
|
+
sp.delete(param);
|
|
192
|
+
if (!vs.length) return;
|
|
193
|
+
if (sep) sp.set(param, vs.join(sep));
|
|
194
|
+
else for (const v of vs) sp.append(param, v);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const write = (url: URL, next: UiState) => {
|
|
198
|
+
// On a detached copy of the query, written back once: every change through
|
|
199
|
+
// url.searchParams serialises the whole query again, and a listing writes
|
|
200
|
+
// a link for every facet value, each over every mapped host param. Written
|
|
201
|
+
// back as the URL would have (only if something was changed).
|
|
202
|
+
const list = new URLSearchParams(url.search);
|
|
203
|
+
let changed = false;
|
|
204
|
+
const sp: Params = {
|
|
205
|
+
delete: (k: string) => void ((changed = true), list.delete(k)),
|
|
206
|
+
append: (k: string, v: string) => void ((changed = true), list.append(k, v)),
|
|
207
|
+
set: (k: string, v: string) => void ((changed = true), list.set(k, v)),
|
|
208
|
+
};
|
|
209
|
+
for (const k of [...list.keys()]) if (k.startsWith(P)) sp.delete(k); // clearOwn
|
|
210
|
+
if (pm.page) sp.delete(pm.page); // our UI never opens a page: their page param goes
|
|
211
|
+
if (pm.sort) sp.delete(pm.sort.param);
|
|
212
|
+
// Range params (a price range): theirs, written in their format.
|
|
213
|
+
const rangeFields = new Set((pm.ranges ?? []).map((r) => r.field));
|
|
214
|
+
for (const r of pm.ranges ?? []) sp.delete(r.min), sp.delete(r.max);
|
|
215
|
+
|
|
216
|
+
for (const f of ownFlags) if (next.flags.includes(f.id)) sp.append(P + 'flag', f.id);
|
|
217
|
+
for (const param of new Set(flags.map((f) => f.param)))
|
|
218
|
+
put(sp, param, flags.filter((f) => f.param === param && next.flags.includes(f.id)).map((f) => f.value));
|
|
219
|
+
// Every mapped param is rewritten, in the map's order. A large map (most
|
|
220
|
+
// params neither on the URL nor selected): those on it go first, then the
|
|
221
|
+
// selected facets are written; the same as one by one while no two facets
|
|
222
|
+
// share a param.
|
|
223
|
+
if (mapped.length) changed = true;
|
|
224
|
+
const shared = mappedParams.size < mapped.length;
|
|
225
|
+
if (!shared) for (const k of new Set(list.keys())) if (mappedParams.has(k)) list.delete(k);
|
|
226
|
+
const picked = shared ? mapped : Object.keys(next.filters).filter((f) => order.has(f) && next.filters[f].length).sort((a, b) => order.get(a)! - order.get(b)!).map((f) => [f, facetParams[f]] as const);
|
|
227
|
+
for (const [facet, fp] of picked) {
|
|
228
|
+
const theirs: string[] = [];
|
|
229
|
+
for (const v of next.filters[facet] ?? []) {
|
|
230
|
+
const h = inverse[facet]?.get(v) ?? (fp.slug === false ? v : inverse[facet] ? undefined : slugify(v));
|
|
231
|
+
// A value their param cannot carry (not in the map, a slug of nothing):
|
|
232
|
+
// ours, rather than dropping the shopper's click.
|
|
233
|
+
if (h) theirs.push(h);
|
|
234
|
+
else sp.append(P + facet, v);
|
|
235
|
+
}
|
|
236
|
+
put(sp, fp.param, theirs);
|
|
237
|
+
}
|
|
238
|
+
for (const r of pm.ranges ?? []) {
|
|
239
|
+
const { from, to } = parseRange(next.filters[r.field]?.[0]);
|
|
240
|
+
if (from != null) sp.set(r.min, String(from));
|
|
241
|
+
if (to != null) sp.set(r.max, String(to));
|
|
242
|
+
}
|
|
243
|
+
for (const [facet, vs] of Object.entries(next.filters)) if (!facetParams[facet] && !rangeFields.has(facet)) for (const v of vs) sp.append(P + facet, v);
|
|
244
|
+
if (next.sort) {
|
|
245
|
+
const hostValue = pm.sort && Object.entries(pm.sort.values).find(([, api]) => api === next.sort)?.[0];
|
|
246
|
+
if (hostValue) sp.set(pm.sort!.param, hostValue);
|
|
247
|
+
else sp.set(P + 'sort', next.sort);
|
|
248
|
+
}
|
|
249
|
+
if (changed) url.search = list.toString();
|
|
250
|
+
return url;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
id: 'plp',
|
|
255
|
+
tag: 'lbx-plp',
|
|
256
|
+
endpoint: 'collections',
|
|
257
|
+
flagDefs: [...flags, ...ownFlags],
|
|
258
|
+
accepts: plpAccepts(pc),
|
|
259
|
+
resolve,
|
|
260
|
+
write,
|
|
261
|
+
releaseOnEmpty: true,
|
|
262
|
+
};
|
|
263
|
+
};
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Recommender widgets. Nothing is requested on page load: the entry places an
|
|
2
|
+
// inline widget as a placeholder and loads this chunk when the placeholder is
|
|
3
|
+
// near the viewport (attach), or when the add-to-cart trigger fires (openPopup).
|
|
4
|
+
// - Inline: inputs (e.g. the basket's items) are read from the page on every
|
|
5
|
+
// reconcile; a change (item removed, added) re-fetches.
|
|
6
|
+
// - Popup: lives on its own terms (top layer, outside the host tree), not the
|
|
7
|
+
// host notice's: theirs may go away on its own, ours stays until dismissed.
|
|
8
|
+
|
|
9
|
+
import type { Hit } from '../../api';
|
|
10
|
+
import { useContrast } from '../../core/a11y';
|
|
11
|
+
import { mark, reportError } from '../../core/health';
|
|
12
|
+
import { untilAborted } from '../../api/token';
|
|
13
|
+
import { afterLayout, rememberWidgetHeight } from '../../core/hold';
|
|
14
|
+
import { lru } from '../../core/storage';
|
|
15
|
+
import { byName, view } from '../../core/templates';
|
|
16
|
+
import { onFlush, scheduleIn } from '../../core/scheduler';
|
|
17
|
+
import { onUrlChange } from '../../core/sense';
|
|
18
|
+
import { setCss } from '../../core/styles';
|
|
19
|
+
import { settleImages } from '../../core/support';
|
|
20
|
+
import type { WidgetContext } from '../../main';
|
|
21
|
+
import type { RecommenderConfigType, Result } from '../../types';
|
|
22
|
+
import { watch } from '../../core/tiles';
|
|
23
|
+
|
|
24
|
+
export { default as css } from './style-recommender.css?raw';
|
|
25
|
+
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
26
|
+
|
|
27
|
+
type Ctx = WidgetContext<RecommenderConfigType>;
|
|
28
|
+
|
|
29
|
+
// Same inputs -> same answer for the life of the page (back/forward, re-renders).
|
|
30
|
+
const cache = lru<Hit[]>(50);
|
|
31
|
+
/** Requests in flight, by the same key: a prefetch (the popup's, at the click) and its use share one. */
|
|
32
|
+
const flying = new Map<string, Promise<Hit[]>>();
|
|
33
|
+
const fetchHits = (ctx: Ctx, items: string[], signal?: AbortSignal) => {
|
|
34
|
+
const rc = ctx.config;
|
|
35
|
+
const type = rc.type ?? ctx.global.itemType ?? 'product';
|
|
36
|
+
const k = JSON.stringify([rc.surface, type, rc.size, rc.fields, !!rc.excludeItems, items]);
|
|
37
|
+
const hit = cache.get(k);
|
|
38
|
+
if (hit) return Promise.resolve(hit);
|
|
39
|
+
let p = flying.get(k);
|
|
40
|
+
if (!p) {
|
|
41
|
+
mark(`reco request ${rc.surface}`);
|
|
42
|
+
// Not aborted with a caller: another may be waiting on it (a request the
|
|
43
|
+
// shopper's click started is answered once, whoever asked).
|
|
44
|
+
p = ctx.api.recommend({ surface: rc.surface, type, anchors: items, exclude: rc.excludeItems ? items : undefined, size: rc.size, fields: rc.fields }).then(
|
|
45
|
+
(hits) => (flying.delete(k), cache.set(k, hits), hits),
|
|
46
|
+
(e) => {
|
|
47
|
+
flying.delete(k);
|
|
48
|
+
throw e;
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
flying.set(k, p);
|
|
52
|
+
}
|
|
53
|
+
return signal ? untilAborted(p, signal) : p;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Our list, for its tiles' clicks and add-to-carts (core/tiles.ts). */
|
|
57
|
+
const listOf = (ctx: Ctx) => ({ list: `lbx_reco_${ctx.id.slice(5)}`, global: ctx.global });
|
|
58
|
+
|
|
59
|
+
const applyStyles = (ctx: Ctx) => {
|
|
60
|
+
ctx.styles.forEach((c, i) => setCss(`itg:${ctx.id}:${i}`, c));
|
|
61
|
+
useContrast();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// --- inline
|
|
65
|
+
interface State {
|
|
66
|
+
near: boolean;
|
|
67
|
+
key: string | null;
|
|
68
|
+
ctl?: AbortController;
|
|
69
|
+
v: ReturnType<typeof view>;
|
|
70
|
+
/** The inputs last requested, its failures in a row, and when the next try may run. */
|
|
71
|
+
tried: string | null;
|
|
72
|
+
failures: number;
|
|
73
|
+
retryAt: number;
|
|
74
|
+
/** What the tiles show (analytics: a click's result). */
|
|
75
|
+
results: Result[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Retry a failed widget after 2, 4, 8, 16 s, then only when its inputs change. */
|
|
79
|
+
const MAX_TRIES = 5;
|
|
80
|
+
const attached = new Map<Element, { ctx: Ctx; s: State }>();
|
|
81
|
+
const io = new IntersectionObserver(
|
|
82
|
+
(entries) => {
|
|
83
|
+
for (const e of entries) {
|
|
84
|
+
const a = attached.get(e.target);
|
|
85
|
+
if (a) a.s.near = e.isIntersecting;
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
{ rootMargin: '0px 0px 200% 0px' },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const load = (ctx: Ctx, el: HTMLElement, s: State, items: string[], key: string) => {
|
|
92
|
+
s.key = s.tried = key;
|
|
93
|
+
s.ctl?.abort();
|
|
94
|
+
const ctl = (s.ctl = new AbortController());
|
|
95
|
+
el.dataset.state = 'loading';
|
|
96
|
+
fetchHits(ctx, items, ctl.signal)
|
|
97
|
+
.then((hits) => {
|
|
98
|
+
if (ctl.signal.aborted) return;
|
|
99
|
+
s.results = hits.map(ctx.result);
|
|
100
|
+
s.v.render('recommender-box-default', { ...ctx.helpers, results: s.results });
|
|
101
|
+
settleImages(el, ctx.global.images?.fade !== false);
|
|
102
|
+
el.style.minHeight = '';
|
|
103
|
+
el.dataset.state = hits.length ? 'ready' : 'empty';
|
|
104
|
+
s.failures = 0;
|
|
105
|
+
if (hits.length) afterLayout(el, (h) => rememberWidgetHeight(ctx.id, h));
|
|
106
|
+
mark(`reco render ${ctx.id} ${hits.length} hits`);
|
|
107
|
+
})
|
|
108
|
+
.catch((e) => {
|
|
109
|
+
if (ctl.signal.aborted) return;
|
|
110
|
+
el.dataset.state = 'empty';
|
|
111
|
+
el.style.minHeight = '';
|
|
112
|
+
reportError(`reco ${ctx.id}`, e);
|
|
113
|
+
// Not on every reconcile (any host mutation): a failing API would get a request per frame.
|
|
114
|
+
if (++s.failures >= MAX_TRIES) return; // key stays: tried again when the inputs change
|
|
115
|
+
const wait = 1000 * 2 ** s.failures;
|
|
116
|
+
s.retryAt = performance.now() + wait;
|
|
117
|
+
s.key = null;
|
|
118
|
+
scheduleIn(wait + 10);
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/** Called by the entry once the placeholder is near the viewport. */
|
|
123
|
+
export const attach = (ctx: Ctx, el: HTMLElement) => {
|
|
124
|
+
applyStyles(ctx);
|
|
125
|
+
const s: State = { near: true, key: null, v: view(el, ctx.templates), tried: null, failures: 0, retryAt: 0, results: [] };
|
|
126
|
+
attached.set(el, { ctx, s });
|
|
127
|
+
watch(el, { ...listOf(ctx), results: () => s.results });
|
|
128
|
+
io.observe(el);
|
|
129
|
+
sync();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// After each reconcile: (re)load attached widgets near the viewport whose inputs changed.
|
|
133
|
+
const sync = () => {
|
|
134
|
+
for (const [el, { ctx, s }] of attached) {
|
|
135
|
+
if (!el.isConnected) {
|
|
136
|
+
// Removed for good (the rule left the page: a new placeholder next time),
|
|
137
|
+
// or for a moment by a host re-render (the reconciler puts this same
|
|
138
|
+
// element back): kept, unless too many have piled up.
|
|
139
|
+
if (attached.size > 20) {
|
|
140
|
+
attached.delete(el);
|
|
141
|
+
io.unobserve(el);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (!s.near) continue;
|
|
146
|
+
let items: string[] = [];
|
|
147
|
+
try {
|
|
148
|
+
items = ctx.config.items?.() ?? [];
|
|
149
|
+
} catch (e) {
|
|
150
|
+
reportError(`reco ${ctx.id} items`, e);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const key = JSON.stringify(items);
|
|
154
|
+
if (key === s.key) continue;
|
|
155
|
+
if (key !== s.tried) s.failures = s.retryAt = 0; // new inputs: no backoff
|
|
156
|
+
if (performance.now() < s.retryAt) continue;
|
|
157
|
+
load(ctx, el as HTMLElement, s, items, key);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
onFlush(sync);
|
|
161
|
+
|
|
162
|
+
// --- popup
|
|
163
|
+
/**
|
|
164
|
+
* Where the popup must live to be usable: inside what makes the rest of the
|
|
165
|
+
* page inert or closes on a press outside it (a modal dialog, a dialog or
|
|
166
|
+
* drawer around their notice, which may itself be an aria-modal drawer, or
|
|
167
|
+
* a role=dialog while a modal library has locked the page: a Radix modal
|
|
168
|
+
* dialog). Not inside a notice that is a plain dialog or toast: it goes away
|
|
169
|
+
* on its own.
|
|
170
|
+
*/
|
|
171
|
+
const modalAround = (notice?: Element): Element | null => {
|
|
172
|
+
let open: Element | null = null;
|
|
173
|
+
try {
|
|
174
|
+
open = [...document.querySelectorAll('dialog:modal')].pop() ?? null; // the topmost
|
|
175
|
+
} catch {} // no :modal (older Safari)
|
|
176
|
+
// A plain [role=dialog] (a toast, a flyout) counts only while a modal library
|
|
177
|
+
// has disabled the rest of the page (Radix sets no aria-modal).
|
|
178
|
+
const locked = document.body.style.pointerEvents === 'none';
|
|
179
|
+
// An auto popover around the notice (a press on ours would light-dismiss it
|
|
180
|
+
// otherwise); a non-modal <dialog> or plain role=dialog only under a lock
|
|
181
|
+
// (otherwise it may be a toast that goes away on its own, taking ours along).
|
|
182
|
+
const around = `:is([popover=""], [popover=auto i]):popover-open, [aria-modal=true]${locked ? ', dialog[open], [role=dialog]' : ''}`;
|
|
183
|
+
// The notice itself under a lock (a Radix modal is a role=dialog without
|
|
184
|
+
// aria-modal): outside it, a press on ours is "outside" and closes it.
|
|
185
|
+
return open ?? notice?.parentElement?.closest(around) ?? notice?.closest(locked ? '[aria-modal=true], [role=dialog]' : '[aria-modal=true]') ?? null;
|
|
186
|
+
};
|
|
187
|
+
let pop: HTMLElement | null = null;
|
|
188
|
+
let popView: ReturnType<typeof view> | null = null;
|
|
189
|
+
let isOpen = false;
|
|
190
|
+
let popCtl: AbortController | null = null;
|
|
191
|
+
let popResults: Result[] = [];
|
|
192
|
+
|
|
193
|
+
const close = () => {
|
|
194
|
+
popCtl?.abort();
|
|
195
|
+
if (!isOpen) return;
|
|
196
|
+
isOpen = false;
|
|
197
|
+
try {
|
|
198
|
+
pop!.hidePopover();
|
|
199
|
+
} catch {}
|
|
200
|
+
pop!.style.setProperty('display', 'none', 'important');
|
|
201
|
+
mark('reco popup close');
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Called by the entry on an add-to-cart click (the intent), before the host's
|
|
206
|
+
* notice confirms it: the request starts now, so the popup opens as soon as
|
|
207
|
+
* the notice appears rather than a request later. One request per click that
|
|
208
|
+
* does not end in the cart (a failed add): what the popup costs.
|
|
209
|
+
*/
|
|
210
|
+
export const prefetchPopup = (ctx: Ctx, item: string) => void fetchHits(ctx, [item]).catch(() => {});
|
|
211
|
+
|
|
212
|
+
/** Called by the entry when "added to cart" was confirmed by the host's notice. */
|
|
213
|
+
export const openPopup = (ctx: Ctx, item: string, near: DOMRect, notice?: Element) => {
|
|
214
|
+
applyStyles(ctx);
|
|
215
|
+
popCtl?.abort();
|
|
216
|
+
const c = (popCtl = new AbortController());
|
|
217
|
+
fetchHits(ctx, [item], c.signal)
|
|
218
|
+
.then((hits) => {
|
|
219
|
+
if (c.signal.aborted || !hits.length) return;
|
|
220
|
+
if (!pop) {
|
|
221
|
+
pop = document.createElement('lbx-reco-pop');
|
|
222
|
+
pop.setAttribute('data-lbx', ctx.id);
|
|
223
|
+
pop.setAttribute('popover', 'manual');
|
|
224
|
+
pop.setAttribute('role', 'dialog');
|
|
225
|
+
pop.style.setProperty('display', 'none', 'important');
|
|
226
|
+
// A modal library may set pointer-events: none on body, which inherits.
|
|
227
|
+
pop.style.pointerEvents = 'auto';
|
|
228
|
+
popView = view(pop, ctx.templates);
|
|
229
|
+
watch(pop, { ...listOf(ctx), results: () => popResults });
|
|
230
|
+
// Dismissal: close button, Escape, a press outside, or leaving the page.
|
|
231
|
+
addEventListener('keydown', (e) => isOpen && e.key === 'Escape' && close(), true);
|
|
232
|
+
addEventListener('pointerdown', (e) => isOpen && !pop!.contains(e.target as Node) && close(), true);
|
|
233
|
+
onUrlChange(close);
|
|
234
|
+
}
|
|
235
|
+
// Inside what makes the rest of the page inert (a modal dialog, an
|
|
236
|
+
// aria-modal drawer, which may be their notice itself), or ours could not
|
|
237
|
+
// be used. Not inside a plain toast: it goes away on its own, ours stays.
|
|
238
|
+
const parent = modalAround(notice) ?? document.body;
|
|
239
|
+
if (pop.parentNode !== parent) {
|
|
240
|
+
parent.append(pop); // moving a popover closes it: shown again below
|
|
241
|
+
isOpen = false;
|
|
242
|
+
}
|
|
243
|
+
pop.setAttribute('aria-label', ctx.t('title'));
|
|
244
|
+
popResults = hits.map(ctx.result);
|
|
245
|
+
popView!.render('recommender-popup', { ...ctx.helpers, results: popResults, close });
|
|
246
|
+
settleImages(pop, ctx.global.images?.fade !== false);
|
|
247
|
+
pop.querySelector('[data-lbx-list]')?.scrollTo(0, 0);
|
|
248
|
+
// Just below the host's notice, right-aligned with it.
|
|
249
|
+
const vw = document.documentElement.clientWidth;
|
|
250
|
+
const gap = 12;
|
|
251
|
+
pop.style.top = `${Math.round(Math.max(gap, near.bottom + 8))}px`;
|
|
252
|
+
pop.style.right = `${Math.round(Math.max(gap, vw - near.right))}px`;
|
|
253
|
+
pop.style.setProperty('display', 'block', 'important');
|
|
254
|
+
if (!isOpen) pop.showPopover();
|
|
255
|
+
isOpen = true;
|
|
256
|
+
mark(`reco popup open ${hits.length} hits`);
|
|
257
|
+
})
|
|
258
|
+
.catch((e) => {
|
|
259
|
+
if (!c.signal.aborted) reportError('reco popup', e);
|
|
260
|
+
});
|
|
261
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
lbx-reco { display: block; }
|
|
2
|
+
lbx-reco[data-state="empty"] { display: none; }
|
|
3
|
+
.lbx-r__list { display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory; }
|
|
4
|
+
.lbx-r__list > * { scroll-snap-align: start; }
|
|
5
|
+
.lbx-r__tile { flex: 0 0 180px; display: flex; flex-direction: column; gap: 6px; color: inherit; text-decoration: var(--lbx-c-underline, none); }
|
|
6
|
+
/* Images (src/core/images.ts): the box keeps its size before the image arrives; its placeholder (the catalog's --lbx-ph, else a neutral skeleton) until it has loaded; lazy images fade in, never the first row, not under reduced motion. */
|
|
7
|
+
.lbx-img { display: block; }
|
|
8
|
+
.lbx-img > img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
|
9
|
+
[data-lbx-ph]:not([data-lbx-loaded]) { background: var(--lbx-ph, var(--lbx-c-ph, #efefef)); }
|
|
10
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
11
|
+
[data-lbx-fade] img[data-lbx-img][loading="lazy"]:not([data-lbx-loaded]) { opacity: 0; }
|
|
12
|
+
[data-lbx-fade] img[data-lbx-loaded="fade"] { animation: lbx-img-in .25s ease-out; }
|
|
13
|
+
}
|
|
14
|
+
@keyframes lbx-img-in { from { opacity: 0; } }
|
|
15
|
+
/* dvh (vh before it): iOS Safari's 100vh is its viewport with the toolbars
|
|
16
|
+
hidden, taller than what shows while they are: the popup's end went under them. */
|
|
17
|
+
lbx-reco-pop {
|
|
18
|
+
position: fixed; inset: auto; margin: 0; padding: 16px; border: var(--lbx-c-border, 0) solid var(--lbx-c-line, transparent); box-sizing: border-box;
|
|
19
|
+
width: min(680px, calc(100vw - 24px)); max-height: calc(100vh - 24px); max-height: calc(100dvh - 24px); overflow: auto;
|
|
20
|
+
background: var(--lbx-c-bg, #fff); color: var(--lbx-c-fg, inherit); border-radius: 8px; box-shadow: 0 12px 40px rgba(0,0,0,.25);
|
|
21
|
+
}
|
|
22
|
+
.lbx-r__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
|
23
|
+
.lbx-r__close { font-size: 28px; line-height: 1; min-width: 44px; min-height: 44px; padding: 4px 10px; background: none; border: 0; color: inherit; cursor: pointer; }
|
|
24
|
+
@media (prefers-reduced-motion: reduce) { .lbx-r__list { scroll-behavior: auto; } }
|
|
25
|
+
/* Windows contrast themes drop the shadow: the popup keeps an edge. */
|
|
26
|
+
@media (forced-colors: active) { lbx-reco-pop { border: 2px solid CanvasText; } }
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { html, repeat } from '../../lit';
|
|
2
|
+
import type { RecommenderScope } from '../../types';
|
|
3
|
+
|
|
4
|
+
export default (s: RecommenderScope) => html`
|
|
5
|
+
<section class="lbx-r" aria-label=${s.t('title')}>
|
|
6
|
+
<h2 class="lbx-r__title">${s.t('title')}</h2>
|
|
7
|
+
<div class="lbx-r__list" data-lbx-list data-lbx-part="list">
|
|
8
|
+
${repeat(s.results, (r) => r.c.id, (r, i) => s.include('recommender-result-default', { r, i }))}
|
|
9
|
+
</div>
|
|
10
|
+
</section>
|
|
11
|
+
`;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { html, repeat } from '../../lit';
|
|
2
|
+
import type { RecommenderScope } from '../../types';
|
|
3
|
+
|
|
4
|
+
export default (s: RecommenderScope) => html`
|
|
5
|
+
<div class="lbx-r__head">
|
|
6
|
+
<h2>${s.t('title')}</h2>
|
|
7
|
+
<button type="button" class="lbx-r__close" aria-label=${s.t('close')} @click=${() => s.close?.()}>×</button>
|
|
8
|
+
</div>
|
|
9
|
+
<div class="lbx-r__list" data-lbx-list>
|
|
10
|
+
${repeat(s.results, (r) => r.c.id, (r, i) => s.include('recommender-result-default', { r, i }))}
|
|
11
|
+
</div>
|
|
12
|
+
`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { html, nothing } from '../../lit';
|
|
2
|
+
import type { RecommenderResultScope } from '../../types';
|
|
3
|
+
|
|
4
|
+
export default ({ r, i, img, plain, price }: RecommenderResultScope) => html`
|
|
5
|
+
<a class="lbx-r__tile" data-lbx-part="tile" href=${r.c.url}>
|
|
6
|
+
${r.c.image ? img(r, i, { sizes: '180px' }) : nothing}
|
|
7
|
+
<span class="lbx-r__tile-title" data-lbx-part="title">${plain(r.c.title)}</span>
|
|
8
|
+
${r.c.price ? html`<span class="lbx-r__tile-price" data-lbx-part="price">${price(r.c.price)}</span>` : nothing}
|
|
9
|
+
</a>
|
|
10
|
+
`;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Built-in listing templates and styles, shared by search and category pages.
|
|
2
|
+
// An integration overrides any of them by name: integration/search/template-search-result-default.ts.
|
|
3
|
+
import { byName } from '../../core/templates';
|
|
4
|
+
import css from './style-search.css?raw';
|
|
5
|
+
|
|
6
|
+
export const templates = byName(import.meta.glob('./template-*.ts', { eager: true, import: 'default' }));
|
|
7
|
+
export { css };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// A search page's first result, asked by `listing()` in src/core/listings.ts
|
|
2
|
+
// (lbx-nav) at boot, in parallel with the listing's chunks. Here rather than in the
|
|
3
|
+
// entry: the query code (route, query building, the answer's shaping) is in
|
|
4
|
+
// lbx-nav, which the listing imports anyway, and not on pages without a listing
|
|
5
|
+
// (the API client's requests are in lbx-core, which the listing loads too).
|
|
6
|
+
import { getApi } from '../../api';
|
|
7
|
+
import type { GlobalConfigType, SearchConfigType } from '../../types';
|
|
8
|
+
import { quickChannel } from '../listing/flow';
|
|
9
|
+
import { listingQuery, type ListingResult } from '../listing/query';
|
|
10
|
+
import { searchRoute } from './route';
|
|
11
|
+
|
|
12
|
+
export const searchFirst = (g: GlobalConfigType, cfg: SearchConfigType, url: URL, onProducts?: (r: ListingResult) => void) => {
|
|
13
|
+
const route = searchRoute(cfg);
|
|
14
|
+
return listingQuery(getApi(g), g, cfg as any, route, route.resolve(url) as any, undefined, undefined, onProducts, quickChannel(route.id));
|
|
15
|
+
};
|