@shop-on/theme-server 0.5.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/account.js +7 -4
- package/dist/analytics-proxy.d.ts +2 -1
- package/dist/analytics-proxy.js +2 -1
- package/dist/blog.d.ts +26 -0
- package/dist/blog.js +65 -1
- package/dist/cart.d.ts +7 -7
- package/dist/checkout.js +1 -1
- package/dist/collection.d.ts +41 -4
- package/dist/collection.js +66 -5
- package/dist/content-version.d.ts +12 -6
- package/dist/content-version.js +25 -4
- package/dist/graphql-env.d.ts +525 -29
- package/dist/graphql.js +8 -2
- package/dist/marketing-cookies.d.ts +6 -4
- package/dist/marketing-cookies.js +35 -14
- package/dist/metafields.d.ts +7 -0
- package/dist/metafields.js +33 -0
- package/dist/page.d.ts +15 -0
- package/dist/page.js +35 -1
- package/dist/product.d.ts +18 -1
- package/dist/product.js +48 -34
- package/dist/recommendations.d.ts +3 -0
- package/dist/recommendations.js +8 -3
- package/dist/schema.storefront.graphql +499 -20
- package/dist/search.d.ts +3 -0
- package/dist/search.js +5 -0
- package/dist/structured-data.d.ts +47 -0
- package/dist/structured-data.js +110 -0
- package/package.json +4 -3
package/dist/graphql.js
CHANGED
|
@@ -16,6 +16,7 @@ let mintHoldError = null;
|
|
|
16
16
|
const MINT_FAILURE_HOLD_MS = 2e3;
|
|
17
17
|
const MINT_THROTTLE_FALLBACK_HOLD_MS = 15e3;
|
|
18
18
|
const MINT_HOLD_MAX_MS = 6e4;
|
|
19
|
+
const GRAPHQL_TIMEOUT_MS = 8e3;
|
|
19
20
|
var MintError = class extends Error {
|
|
20
21
|
holdMs;
|
|
21
22
|
constructor(message, holdMs) {
|
|
@@ -157,7 +158,11 @@ function invalidateToken(used) {
|
|
|
157
158
|
if (cached?.accessToken === used) cached = null;
|
|
158
159
|
}
|
|
159
160
|
async function executeSingle(document, variables, options, trace) {
|
|
160
|
-
const send = async (token) => await buildClient(bearerHeaders(token, options, trace)).request(
|
|
161
|
+
const send = async (token) => await buildClient(bearerHeaders(token, options, trace)).request({
|
|
162
|
+
document,
|
|
163
|
+
variables,
|
|
164
|
+
signal: AbortSignal.timeout(GRAPHQL_TIMEOUT_MS)
|
|
165
|
+
});
|
|
161
166
|
const token = await getToken();
|
|
162
167
|
try {
|
|
163
168
|
return await send(token);
|
|
@@ -236,7 +241,8 @@ async function executeBatchChunk(chunk, options, trace) {
|
|
|
236
241
|
"Content-Type": "application/json",
|
|
237
242
|
...bearerHeaders(token, options, trace)
|
|
238
243
|
},
|
|
239
|
-
body
|
|
244
|
+
body,
|
|
245
|
+
signal: AbortSignal.timeout(GRAPHQL_TIMEOUT_MS)
|
|
240
246
|
}));
|
|
241
247
|
if (!res.ok) throw batchClientError({
|
|
242
248
|
status: res.status,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
//#region src/marketing-cookies.d.ts
|
|
2
|
-
/** Attribution
|
|
2
|
+
/** Attribution read off the request cookies. A key is absent, never empty. */
|
|
3
3
|
export interface MarketingCookies {
|
|
4
4
|
fbp?: string;
|
|
5
5
|
fbc?: string;
|
|
6
|
+
/** Every allowlisted cookie as `name=value; …`, for Rails to parse. */
|
|
7
|
+
cookies?: string;
|
|
6
8
|
}
|
|
7
9
|
/**
|
|
8
|
-
* Parse
|
|
9
|
-
* header is missing or carries
|
|
10
|
-
* all rather than a hash of nulls.
|
|
10
|
+
* Parse the attribution cookies out of a raw `Cookie` header. Returns `{}` when
|
|
11
|
+
* the header is missing or carries none — the caller then sends no attribution
|
|
12
|
+
* at all rather than a hash of nulls.
|
|
11
13
|
*/
|
|
12
14
|
export declare function parseMarketingCookies(cookie: string | null | undefined): MarketingCookies;
|
|
13
15
|
//#endregion
|
|
@@ -1,26 +1,47 @@
|
|
|
1
1
|
//#region src/marketing-cookies.ts
|
|
2
2
|
const MAX_LENGTH = 256;
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
const MAX_COOKIES_LENGTH = 2048;
|
|
4
|
+
const ALLOWED_NAMES = /* @__PURE__ */ new Set([
|
|
5
|
+
"_fbp",
|
|
6
|
+
"_fbc",
|
|
7
|
+
"_ga",
|
|
8
|
+
"_gcl_aw",
|
|
9
|
+
"_gcl_au",
|
|
10
|
+
"_ttp"
|
|
11
|
+
]);
|
|
12
|
+
const GA_STREAM_NAME = /^_ga_[A-Za-z0-9]+$/;
|
|
13
|
+
function isAllowed(name) {
|
|
14
|
+
return ALLOWED_NAMES.has(name) || GA_STREAM_NAME.test(name);
|
|
11
15
|
}
|
|
12
16
|
/**
|
|
13
|
-
* Parse
|
|
14
|
-
* header is missing or carries
|
|
15
|
-
* all rather than a hash of nulls.
|
|
17
|
+
* Parse the attribution cookies out of a raw `Cookie` header. Returns `{}` when
|
|
18
|
+
* the header is missing or carries none — the caller then sends no attribution
|
|
19
|
+
* at all rather than a hash of nulls.
|
|
16
20
|
*/
|
|
17
21
|
function parseMarketingCookies(cookie) {
|
|
18
22
|
if (!cookie) return {};
|
|
23
|
+
const found = /* @__PURE__ */ new Map();
|
|
24
|
+
for (const pair of cookie.split(";")) {
|
|
25
|
+
const separator = pair.indexOf("=");
|
|
26
|
+
if (separator === -1) continue;
|
|
27
|
+
const name = pair.slice(0, separator).trim();
|
|
28
|
+
const value = pair.slice(separator + 1).trim();
|
|
29
|
+
if (!isAllowed(name) || found.has(name)) continue;
|
|
30
|
+
if (!value || value.length > MAX_LENGTH) continue;
|
|
31
|
+
found.set(name, value);
|
|
32
|
+
}
|
|
19
33
|
const result = {};
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
const fbp = found.get("_fbp");
|
|
35
|
+
const fbc = found.get("_fbc");
|
|
36
|
+
if (fbp) result.fbp = fbp;
|
|
37
|
+
if (fbc) result.fbc = fbc;
|
|
38
|
+
let cookies = "";
|
|
39
|
+
for (const [name, value] of found) {
|
|
40
|
+
const next = cookies ? `${cookies}; ${name}=${value}` : `${name}=${value}`;
|
|
41
|
+
if (next.length > MAX_COOKIES_LENGTH) break;
|
|
42
|
+
cookies = next;
|
|
23
43
|
}
|
|
44
|
+
if (cookies) result.cookies = cookies;
|
|
24
45
|
return result;
|
|
25
46
|
}
|
|
26
47
|
//#endregion
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { memoTTL } from "./cache.js";
|
|
2
|
+
import { getContentVersion } from "./content-version.js";
|
|
3
|
+
//#region src/metafields.ts
|
|
4
|
+
/** Metafield nodes as the `"namespace.key"`-keyed map every reader uses. */
|
|
5
|
+
function toMetafieldMap(nodes) {
|
|
6
|
+
return Object.fromEntries(nodes.map((m) => [`${m.namespace}.${m.key}`, {
|
|
7
|
+
type: m.type,
|
|
8
|
+
value: m.value
|
|
9
|
+
}]));
|
|
10
|
+
}
|
|
11
|
+
/** The cached metafield read behind each route resource's `*_metafield`
|
|
12
|
+
* dynamic source. `read` returns the resource's metafield nodes, or `null`
|
|
13
|
+
* when the resource is missing. A resource with no published fields is `{}`
|
|
14
|
+
* and IS cached; a missing resource or a failed read is retried next time.
|
|
15
|
+
* Either way the caller gets `{}`, so the template renders its literal. */
|
|
16
|
+
function metafieldsReader(name, read) {
|
|
17
|
+
const cached = memoTTL(async (ref, locale) => {
|
|
18
|
+
try {
|
|
19
|
+
const nodes = await read(ref, locale);
|
|
20
|
+
return nodes ? toMetafieldMap(nodes) : null;
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.warn(`[${name}] failed:`, err);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}, {
|
|
26
|
+
key: (ref, locale) => `${name}|${ref}|${locale ?? ""}`,
|
|
27
|
+
shouldCache: (v) => v !== null,
|
|
28
|
+
version: getContentVersion
|
|
29
|
+
});
|
|
30
|
+
return async (ref, locale) => await cached(ref, locale) ?? {};
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
33
|
+
export { metafieldsReader, toMetafieldMap };
|
package/dist/page.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { MetafieldMap } from "./metafields.js";
|
|
1
2
|
//#region src/page.d.ts
|
|
2
3
|
export type PageData = {
|
|
3
4
|
id: string;
|
|
@@ -25,4 +26,18 @@ export type PageRef = {
|
|
|
25
26
|
* `page(id:)` applies the same `public_visible` gate as the handle path, so a
|
|
26
27
|
* missing/draft/cross-shop page returns null — the caller falls back. */
|
|
27
28
|
export declare const fetchPageById: (id: string, locale?: string) => Promise<PageRef | null>;
|
|
29
|
+
/** The page's storefront metafields keyed `"namespace.key"` — the
|
|
30
|
+
* `page_metafield` dynamic source. Its own read, not a field on `fetchPage`,
|
|
31
|
+
* so a page whose template binds none never asks for them. `{}` on a miss or
|
|
32
|
+
* a failure, so the literal renders. */
|
|
33
|
+
export declare const fetchPageMetafields: (ref: string, locale?: string) => Promise<MetafieldMap>;
|
|
34
|
+
/** The route CMS page as the theme loader's dynamic sources read it
|
|
35
|
+
* (`DynamicSourcePage` in `@shop-on/theme-loader`): the title off the page
|
|
36
|
+
* read the theme already made, and a lazy provider the loader calls only when
|
|
37
|
+
* a template binds a page field. One builder so a theme picks up a new
|
|
38
|
+
* provider on upgrade instead of re-listing them. */
|
|
39
|
+
export declare function routePageSource(page: Pick<PageData, "handle" | "title">, locale?: string): {
|
|
40
|
+
title: string;
|
|
41
|
+
getMetafields: () => Promise<MetafieldMap>;
|
|
42
|
+
};
|
|
28
43
|
//#endregion
|
package/dist/page.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memoTTL } from "./cache.js";
|
|
2
2
|
import { graphql, request } from "./graphql.js";
|
|
3
3
|
import { getContentVersion } from "./content-version.js";
|
|
4
|
+
import { metafieldsReader } from "./metafields.js";
|
|
4
5
|
//#region src/page.ts
|
|
5
6
|
function parseRef(ref) {
|
|
6
7
|
return ref.startsWith("gid://") ? { id: ref } : { handle: ref };
|
|
@@ -90,5 +91,38 @@ const fetchPageById = async (id, locale) => {
|
|
|
90
91
|
return null;
|
|
91
92
|
}
|
|
92
93
|
};
|
|
94
|
+
const FetchPageMetafieldsQuery = graphql(`
|
|
95
|
+
query FetchPageMetafields($handle: String, $id: ID) {
|
|
96
|
+
page(handle: $handle, id: $id) {
|
|
97
|
+
# Storefront-visible only (the API gates on the storefront scope).
|
|
98
|
+
metafields(first: 50) {
|
|
99
|
+
nodes {
|
|
100
|
+
namespace
|
|
101
|
+
key
|
|
102
|
+
type
|
|
103
|
+
value
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
`);
|
|
109
|
+
/** The page's storefront metafields keyed `"namespace.key"` — the
|
|
110
|
+
* `page_metafield` dynamic source. Its own read, not a field on `fetchPage`,
|
|
111
|
+
* so a page whose template binds none never asks for them. `{}` on a miss or
|
|
112
|
+
* a failure, so the literal renders. */
|
|
113
|
+
const fetchPageMetafields = metafieldsReader("fetchPageMetafields", async (handle, locale) => {
|
|
114
|
+
return (await request(FetchPageMetafieldsQuery, parseRef(handle), locale ? { acceptLanguage: locale } : void 0)).page?.metafields.nodes ?? null;
|
|
115
|
+
});
|
|
116
|
+
/** The route CMS page as the theme loader's dynamic sources read it
|
|
117
|
+
* (`DynamicSourcePage` in `@shop-on/theme-loader`): the title off the page
|
|
118
|
+
* read the theme already made, and a lazy provider the loader calls only when
|
|
119
|
+
* a template binds a page field. One builder so a theme picks up a new
|
|
120
|
+
* provider on upgrade instead of re-listing them. */
|
|
121
|
+
function routePageSource(page, locale) {
|
|
122
|
+
return {
|
|
123
|
+
title: page.title,
|
|
124
|
+
getMetafields: () => fetchPageMetafields(page.handle, locale)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
93
127
|
//#endregion
|
|
94
|
-
export { fetchPage, fetchPageById };
|
|
128
|
+
export { fetchPage, fetchPageById, fetchPageMetafields, routePageSource };
|
package/dist/product.d.ts
CHANGED
|
@@ -40,6 +40,8 @@ export type ProductData = {
|
|
|
40
40
|
type: string;
|
|
41
41
|
value: string;
|
|
42
42
|
}>;
|
|
43
|
+
finalSale?: boolean;
|
|
44
|
+
gtin?: string | null;
|
|
43
45
|
};
|
|
44
46
|
export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
|
|
45
47
|
tags: string[];
|
|
@@ -48,6 +50,21 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
|
|
|
48
50
|
on: "ShopProduct";
|
|
49
51
|
masked: false;
|
|
50
52
|
}>;
|
|
53
|
+
/** `"namespace.key"` names as the card queries' metafield variables. Nothing
|
|
54
|
+
* named ⇒ the selection stays `@include`d out and the variables at their
|
|
55
|
+
* defaults, so a caller naming no keys sends exactly the query it always did.
|
|
56
|
+
* A name without a dot (or with an empty side) names no key and is dropped. */
|
|
57
|
+
export declare function metafieldKeysVariables(keys: readonly string[] | undefined): {
|
|
58
|
+
withMetafields?: true;
|
|
59
|
+
metafieldFirst?: number;
|
|
60
|
+
metafieldKeys?: Array<{
|
|
61
|
+
namespace: string;
|
|
62
|
+
key: string;
|
|
63
|
+
}>;
|
|
64
|
+
};
|
|
65
|
+
/** The cache-key segment for a key list: sorted, so two callers naming the
|
|
66
|
+
* same keys in a different order share one entry. */
|
|
67
|
+
export declare function metafieldKeysKey(keys: readonly string[] | undefined): string;
|
|
51
68
|
/** Lean fetch for product cards (collection grids, social tiles, shop-the-look).
|
|
52
69
|
* Skips description and caps media at 5. Used by the `product_list` resolver.
|
|
53
70
|
* `includeOptions` (default true) gates the variant-options payload — pass
|
|
@@ -55,7 +72,7 @@ export declare const BadgeTagsFragment: import("gql.tada").TadaDocumentNode<{
|
|
|
55
72
|
export declare function fetchProduct(ref: string, opts?: {
|
|
56
73
|
includeOptions?: boolean;
|
|
57
74
|
locale?: string;
|
|
58
|
-
|
|
75
|
+
metafieldKeys?: readonly string[];
|
|
59
76
|
}): Promise<ProductData | null>;
|
|
60
77
|
/** Lean card fetch with a plain-text description — same footprint as
|
|
61
78
|
* {@link fetchProduct}, plus `descriptionPlain`; no options/variants. For cards
|
package/dist/product.js
CHANGED
|
@@ -2,6 +2,7 @@ import { memoTTL } from "./cache.js";
|
|
|
2
2
|
import { graphql, request } from "./graphql.js";
|
|
3
3
|
import { getContentVersion } from "./content-version.js";
|
|
4
4
|
import { fetchFileById, toFileInfo } from "./files.js";
|
|
5
|
+
import { toMetafieldMap } from "./metafields.js";
|
|
5
6
|
//#region src/product.ts
|
|
6
7
|
const BadgeTagsFragment = graphql(`
|
|
7
8
|
fragment BadgeTags on ShopProduct @_unmask {
|
|
@@ -127,9 +128,9 @@ const FetchProductCardQuery = graphql(`
|
|
|
127
128
|
$handle: String
|
|
128
129
|
$id: ID
|
|
129
130
|
$includeOptions: Boolean!
|
|
130
|
-
$
|
|
131
|
-
$
|
|
132
|
-
$
|
|
131
|
+
$withMetafields: Boolean = false
|
|
132
|
+
$metafieldFirst: Int = 0
|
|
133
|
+
$metafieldKeys: [MetafieldKeyInput!] = []
|
|
133
134
|
) {
|
|
134
135
|
product(handle: $handle, id: $id) {
|
|
135
136
|
id
|
|
@@ -162,14 +163,16 @@ const FetchProductCardQuery = graphql(`
|
|
|
162
163
|
options @include(if: $includeOptions) {
|
|
163
164
|
...ProductOptionNode
|
|
164
165
|
}
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
metafield
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
# The storefront-visible metafields a card names and no more (a
|
|
167
|
+
# theme's product-variations swatches name one; a closest product
|
|
168
|
+
# metafield source bound in the picker's scope names each it reads).
|
|
169
|
+
metafields(first: $metafieldFirst, keys: $metafieldKeys) @include(if: $withMetafields) {
|
|
170
|
+
nodes {
|
|
171
|
+
namespace
|
|
172
|
+
key
|
|
173
|
+
type
|
|
174
|
+
value
|
|
175
|
+
}
|
|
173
176
|
}
|
|
174
177
|
media(first: 5) {
|
|
175
178
|
nodes {
|
|
@@ -193,6 +196,7 @@ const FetchProductFullQuery = graphql(`
|
|
|
193
196
|
themeTemplate
|
|
194
197
|
...BadgeTags
|
|
195
198
|
giftCard
|
|
199
|
+
finalSale
|
|
196
200
|
descriptionHtml
|
|
197
201
|
# Server-side plain-text projection of descriptionHtml — Rails
|
|
198
202
|
# strips tags AND decodes HTML entities, which a client-side regex
|
|
@@ -204,6 +208,7 @@ const FetchProductFullQuery = graphql(`
|
|
|
204
208
|
}
|
|
205
209
|
defaultVariant {
|
|
206
210
|
...ProductVariantNode
|
|
211
|
+
gtin
|
|
207
212
|
}
|
|
208
213
|
options {
|
|
209
214
|
...ProductOptionNode
|
|
@@ -306,25 +311,38 @@ function toMoney(m) {
|
|
|
306
311
|
currencyCode: m.currencyCode
|
|
307
312
|
};
|
|
308
313
|
}
|
|
309
|
-
/** `namespace.key` as the
|
|
310
|
-
*
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
+
/** `"namespace.key"` names as the card queries' metafield variables. Nothing
|
|
315
|
+
* named ⇒ the selection stays `@include`d out and the variables at their
|
|
316
|
+
* defaults, so a caller naming no keys sends exactly the query it always did.
|
|
317
|
+
* A name without a dot (or with an empty side) names no key and is dropped. */
|
|
318
|
+
function metafieldKeysVariables(keys) {
|
|
319
|
+
const parsed = [];
|
|
320
|
+
for (const ref of keys ?? []) {
|
|
321
|
+
const dot = ref.indexOf(".");
|
|
322
|
+
if (dot <= 0 || dot === ref.length - 1) continue;
|
|
323
|
+
parsed.push({
|
|
324
|
+
namespace: ref.slice(0, dot),
|
|
325
|
+
key: ref.slice(dot + 1)
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (parsed.length === 0) return {};
|
|
314
329
|
return {
|
|
315
|
-
|
|
316
|
-
|
|
330
|
+
withMetafields: true,
|
|
331
|
+
metafieldFirst: parsed.length,
|
|
332
|
+
metafieldKeys: parsed
|
|
317
333
|
};
|
|
318
334
|
}
|
|
319
|
-
|
|
320
|
-
|
|
335
|
+
/** The cache-key segment for a key list: sorted, so two callers naming the
|
|
336
|
+
* same keys in a different order share one entry. */
|
|
337
|
+
function metafieldKeysKey(keys) {
|
|
338
|
+
return keys?.length ? [...keys].sort().join(",") : "";
|
|
339
|
+
}
|
|
340
|
+
const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metafieldKeys) => {
|
|
321
341
|
try {
|
|
322
342
|
const p = (await request(FetchProductCardQuery, {
|
|
323
343
|
...parseRef(ref),
|
|
324
344
|
includeOptions,
|
|
325
|
-
|
|
326
|
-
metafieldNamespace: lookup?.namespace ?? "",
|
|
327
|
-
metafieldKey: lookup?.key ?? ""
|
|
345
|
+
...metafieldKeysVariables(metafieldKeys)
|
|
328
346
|
}, locale ? { acceptLanguage: locale } : void 0)).product;
|
|
329
347
|
if (!p) return null;
|
|
330
348
|
return {
|
|
@@ -343,17 +361,14 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
|
|
|
343
361
|
compare_at: p.defaultVariant.compareAtV2 ? toMoney(p.defaultVariant.compareAtV2) : null,
|
|
344
362
|
media: toMedia(p.media.nodes, 480),
|
|
345
363
|
...p.options ? { options: p.options.map(toOption) } : {},
|
|
346
|
-
...p.
|
|
347
|
-
type: p.metafield.type,
|
|
348
|
-
value: p.metafield.value
|
|
349
|
-
} } } : {}
|
|
364
|
+
...p.metafields ? { metafields: toMetafieldMap(p.metafields.nodes) } : {}
|
|
350
365
|
};
|
|
351
366
|
} catch (err) {
|
|
352
367
|
console.warn("[fetchProduct] failed:", err);
|
|
353
368
|
return null;
|
|
354
369
|
}
|
|
355
370
|
}, {
|
|
356
|
-
key: (ref, includeOptions, locale,
|
|
371
|
+
key: (ref, includeOptions, locale, metafieldKeys) => `card|${ref}|${includeOptions}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
|
|
357
372
|
shouldCache: (v) => v !== null,
|
|
358
373
|
version: getContentVersion
|
|
359
374
|
});
|
|
@@ -362,7 +377,7 @@ const fetchProductCardCached = memoTTL(async (ref, includeOptions, locale, metaf
|
|
|
362
377
|
* `includeOptions` (default true) gates the variant-options payload — pass
|
|
363
378
|
* false when the consuming cards render no swatches. */
|
|
364
379
|
function fetchProduct(ref, opts) {
|
|
365
|
-
return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.
|
|
380
|
+
return fetchProductCardCached(ref, opts?.includeOptions ?? true, opts?.locale, opts?.metafieldKeys);
|
|
366
381
|
}
|
|
367
382
|
const FetchProductShowcaseQuery = graphql(`
|
|
368
383
|
query FetchProductShowcase($handle: String, $id: ID) {
|
|
@@ -464,10 +479,9 @@ async function loadProductFull(handle, locale) {
|
|
|
464
479
|
title: p.seo.title,
|
|
465
480
|
description: p.seo.description
|
|
466
481
|
},
|
|
467
|
-
metafields:
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
}]))
|
|
482
|
+
metafields: toMetafieldMap(p.metafields.nodes),
|
|
483
|
+
finalSale: p.finalSale,
|
|
484
|
+
gtin: p.defaultVariant.gtin ?? null
|
|
471
485
|
};
|
|
472
486
|
} catch (err) {
|
|
473
487
|
console.warn("[fetchProductFull] failed:", err);
|
|
@@ -657,4 +671,4 @@ const fetchProductShowcaseMedia = memoTTL(async ({ handle, namespace, imageKey,
|
|
|
657
671
|
version: getContentVersion
|
|
658
672
|
});
|
|
659
673
|
//#endregion
|
|
660
|
-
export { BadgeTagsFragment, fetchProduct, fetchProductFull, fetchProductListDetail, fetchProductShowcase, fetchProductShowcaseMedia, fetchVariantMediaMap };
|
|
674
|
+
export { BadgeTagsFragment, fetchProduct, fetchProductFull, fetchProductListDetail, fetchProductShowcase, fetchProductShowcaseMedia, fetchVariantMediaMap, metafieldKeysKey, metafieldKeysVariables };
|
|
@@ -22,5 +22,8 @@ export declare const fetchProductRecommendations: (data: {
|
|
|
22
22
|
* swatches switch the card to it. Needs `includeOptions`. */
|
|
23
23
|
includeVariants?: boolean;
|
|
24
24
|
locale?: string;
|
|
25
|
+
/** Storefront-visible metafields (`"namespace.key"`) each card carries, for
|
|
26
|
+
* the `closest_product_metafield` sources bound on it. Omitted ⇒ none. */
|
|
27
|
+
metafieldKeys?: readonly string[];
|
|
25
28
|
}) => Promise<RecommendationsData>;
|
|
26
29
|
//#endregion
|
package/dist/recommendations.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { memoTTL } from "./cache.js";
|
|
2
2
|
import { graphql, request } from "./graphql.js";
|
|
3
3
|
import { getContentVersion } from "./content-version.js";
|
|
4
|
+
import { metafieldKeysKey, metafieldKeysVariables } from "./product.js";
|
|
4
5
|
import { CollectionProductNodeFragment, toCollectionProduct } from "./collection.js";
|
|
5
6
|
//#region src/recommendations.ts
|
|
6
7
|
const FetchProductRecommendationsQuery = graphql(`
|
|
@@ -10,6 +11,9 @@ const FetchProductRecommendationsQuery = graphql(`
|
|
|
10
11
|
$includeOptions: Boolean!
|
|
11
12
|
$includeVariants: Boolean!
|
|
12
13
|
$mediaFirst: Int!
|
|
14
|
+
$withMetafields: Boolean = false
|
|
15
|
+
$metafieldFirst: Int = 0
|
|
16
|
+
$metafieldKeys: [MetafieldKeyInput!] = []
|
|
13
17
|
) {
|
|
14
18
|
reference: product(handle: $productHandle) {
|
|
15
19
|
id
|
|
@@ -21,13 +25,14 @@ const FetchProductRecommendationsQuery = graphql(`
|
|
|
21
25
|
`, [CollectionProductNodeFragment]);
|
|
22
26
|
/** A recommendation card's media: its image and the ones a hover gallery shows. */
|
|
23
27
|
const MEDIA_FIRST = 5;
|
|
24
|
-
const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, includeOptions, includeVariants, locale }) => {
|
|
28
|
+
const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, includeOptions, includeVariants, locale, metafieldKeys }) => {
|
|
25
29
|
const res = await request(FetchProductRecommendationsQuery, {
|
|
26
30
|
productHandle,
|
|
27
31
|
intent,
|
|
28
32
|
includeOptions,
|
|
29
33
|
includeVariants,
|
|
30
|
-
mediaFirst: MEDIA_FIRST
|
|
34
|
+
mediaFirst: MEDIA_FIRST,
|
|
35
|
+
...metafieldKeysVariables(metafieldKeys)
|
|
31
36
|
}, locale ? { acceptLanguage: locale } : void 0);
|
|
32
37
|
const products = res.productRecommendations.map(toCollectionProduct);
|
|
33
38
|
return {
|
|
@@ -37,7 +42,7 @@ const fetchRecommendationsCached = memoTTL(async ({ productHandle, intent, inclu
|
|
|
37
42
|
products
|
|
38
43
|
};
|
|
39
44
|
}, {
|
|
40
|
-
key: ({ productHandle, intent, includeOptions, includeVariants, locale }) => `${intent}|${productHandle}|${includeOptions}|${includeVariants}|${locale ?? ""}`,
|
|
45
|
+
key: ({ productHandle, intent, includeOptions, includeVariants, locale, metafieldKeys }) => `${intent}|${productHandle}|${includeOptions}|${includeVariants}|${locale ?? ""}|${metafieldKeysKey(metafieldKeys)}`,
|
|
41
46
|
version: getContentVersion
|
|
42
47
|
});
|
|
43
48
|
/** Server fetch for the storefront `productRecommendations` field. Returns an
|