@asstio-dev/storefront 0.25.0 → 0.28.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/README.md +28 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/schema.d.ts +11 -1
- package/dist/seo/product.d.ts +2 -1
- package/dist/seo/product.js +30 -18
- package/dist/seo/types.d.ts +3 -2
- package/dist/variant.d.ts +34 -0
- package/dist/variant.js +71 -0
- package/package.json +2 -2
- package/src/schema.d.ts +11 -1
package/README.md
CHANGED
|
@@ -191,14 +191,39 @@ const { data: listing } = await storefront.GET("/v1/products", {
|
|
|
191
191
|
});
|
|
192
192
|
```
|
|
193
193
|
|
|
194
|
+
### Variant links
|
|
195
|
+
|
|
196
|
+
A link to one variant is the product URL plus `?variant={variantId}` (the id from `variants[].id`); `variant.url` is
|
|
197
|
+
that link ready-made, `null` when the variant has no price on the market. Read the parameter on the server, pass it
|
|
198
|
+
to the API, and render by `selectedVariantStatus`:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const raw = (await headers()).get("x-search") ?? ""; // the raw search string, e.g. set by middleware
|
|
202
|
+
const param = readVariantParam(raw); // absent | id | invalid
|
|
203
|
+
const { data: product } = await storefront.GET("/v1/products/{slugOrId}", {
|
|
204
|
+
params: { path: { slugOrId: slug }, query: { variant: variantQueryForApi(param) } },
|
|
205
|
+
});
|
|
206
|
+
if (product?.redirectTo) permanentRedirect(redirectTarget(product.url!, raw)); // 308; keeps ?variant= and UTM as received
|
|
207
|
+
// product.selectedVariantStatus: null (absent: your default), "matched" (preselect product.selectedVariant),
|
|
208
|
+
// "notSoldHere" (show it as unavailable here, preselect nothing), "unknown" (note that it is gone, preselect nothing).
|
|
209
|
+
if (!product) notFound();
|
|
210
|
+
const ld = productJsonLd(product, { site, market: "se" });
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
Pass the **raw search string** to `readVariantParam` and `redirectTarget` when you can (for example from a
|
|
214
|
+
middleware header): a Next.js `searchParams` record is accepted too, but it loses the interleaving of different keys.
|
|
215
|
+
Canonical stays the product `url`. Never a 404 while the product resolves. If you have variant ids but no `v.url` (for example a cart line), `variantUrl(product.url, product.id, variantId)` builds the same link; the parameter name is exported as `VARIANT_PARAM`. The full contract is in the onboarding
|
|
216
|
+
guide, "Variant links".
|
|
217
|
+
|
|
194
218
|
### SEO
|
|
195
219
|
|
|
196
220
|
Pure helpers that compute schema.org JSON-LD and `<head>` data from the DTOs. They render nothing: your frontend
|
|
197
221
|
writes the markup. No backend call, no DOM, no dependency.
|
|
198
222
|
|
|
199
223
|
**Product JSON-LD.** `productJsonLd` returns a `Product` (one variant) or a `ProductGroup` with one `Product` and
|
|
200
|
-
`Offer` per variant sold on the market.
|
|
201
|
-
|
|
224
|
+
`Offer` per variant sold on the market. Each variant's link is `v.url` from the API (see Variant links above), so no
|
|
225
|
+
`variantUrl` hook is needed; pass one only to override it. Google needs a distinct URL per variant, and opening that
|
|
226
|
+
URL **must preselect the variant** on your page. `productJsonLd` reads `product.selectedVariant` itself.
|
|
202
227
|
`variesBy` maps your option names to `size`, `color`, `material` or `pattern`; unmapped options still name the
|
|
203
228
|
variants. A product with more than 100 variants arrives with `variants: null`: pass the first `/variants` page as
|
|
204
229
|
`variants`, or the product gets no offer (valid schema.org, but not eligible for product rich results).
|
|
@@ -206,7 +231,6 @@ variants. A product with more than 100 variants arrives with `variants: null`: p
|
|
|
206
231
|
```tsx
|
|
207
232
|
const ld = productJsonLd(product, {
|
|
208
233
|
site, market: "se",
|
|
209
|
-
variantUrl: (v) => `${product.url}?variant=${v.id}`,
|
|
210
234
|
variesBy: { Storlek: "size", Färg: "color" },
|
|
211
235
|
});
|
|
212
236
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdScript(ld) }} />
|
|
@@ -237,7 +261,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
237
261
|
Titles come out as `{ absolute }` on purpose, so a layout's `title.template` does not apply a second time.
|
|
238
262
|
|
|
239
263
|
**Errors.** `SeoError` means a caller or configuration error: `redirectTo` is set (answer 301 instead), an unknown
|
|
240
|
-
market, a bad `variesBy` value, a `variantUrl` that is not an absolute http(s) URL or
|
|
264
|
+
market, a bad `variesBy` value, a variant URL (`v.url` or the optional `variantUrl` hook) that is missing, not an absolute http(s) URL or repeated for two variants, or
|
|
241
265
|
two markets with the same `language-COUNTRY` and different URLs. Catch it at your SEO boundary and fix the market
|
|
242
266
|
config. Holes in the data never throw: a missing image, description or price only leaves that part out.
|
|
243
267
|
|
package/dist/index.d.ts
CHANGED
|
@@ -61,3 +61,4 @@ export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|
|
|
61
61
|
export { trackSearchEvents, type SearchEvent, type SearchEventsResponse } from "./analytics/events.js";
|
|
62
62
|
export { AnalyticsError, type Ga4Ecommerce, type Ga4Item, type Ga4ItemOptions, type Ga4ItemSource, type Ga4Purchase, type Ga4SharedOptions, type ProductFacts, type SelectedVariant, } from "./analytics/types.js";
|
|
63
63
|
export { SeoError, type BreadcrumbLink, type CollectionHeadOptions, type HeadOptions, type JsonLd, type NextMetadataLike, type ProductJsonLdOptions, type SchemaProperty, type SeoContext, type SeoHead, type SeoMeta, type VariantAvailability, } from "./seo/types.js";
|
|
64
|
+
export { readVariantParam, redirectTarget, VARIANT_PARAM, variantQueryForApi, variantUrl, type QueryInput, type VariantParam, } from "./variant.js";
|
package/dist/index.js
CHANGED
|
@@ -50,3 +50,4 @@ export { longestCategory, toGa4Ecommerce, toGa4Item } from "./analytics/ga4.js";
|
|
|
50
50
|
export { trackSearchEvents } from "./analytics/events.js";
|
|
51
51
|
export { AnalyticsError, } from "./analytics/types.js";
|
|
52
52
|
export { SeoError, } from "./seo/types.js";
|
|
53
|
+
export { readVariantParam, redirectTarget, VARIANT_PARAM, variantQueryForApi, variantUrl, } from "./variant.js";
|
package/dist/schema.d.ts
CHANGED
|
@@ -546,6 +546,7 @@ export interface components {
|
|
|
546
546
|
selectedOptions: {
|
|
547
547
|
[key: string]: string;
|
|
548
548
|
};
|
|
549
|
+
url: null | string;
|
|
549
550
|
};
|
|
550
551
|
CategoryNodeDto: {
|
|
551
552
|
children: components["schemas"]["CategoryNodeDto"][];
|
|
@@ -923,6 +924,7 @@ export interface components {
|
|
|
923
924
|
};
|
|
924
925
|
OrderLineVariantDto: {
|
|
925
926
|
brand: null | components["schemas"]["BrandDto"];
|
|
927
|
+
url: null | string;
|
|
926
928
|
};
|
|
927
929
|
OrderShippingDto: {
|
|
928
930
|
carrier: string;
|
|
@@ -1011,6 +1013,8 @@ export interface components {
|
|
|
1011
1013
|
redirectTo: null | string;
|
|
1012
1014
|
related: components["schemas"]["RelatedGroupDto"][];
|
|
1013
1015
|
responsible: components["schemas"]["ResponsibleDto"];
|
|
1016
|
+
selectedVariant: null | components["schemas"]["VariantDto"];
|
|
1017
|
+
selectedVariantStatus: null | components["schemas"]["SelectedVariantStatus"];
|
|
1014
1018
|
seo: components["schemas"]["SeoDto"];
|
|
1015
1019
|
shortDescription: null | string;
|
|
1016
1020
|
sku: string;
|
|
@@ -1099,6 +1103,8 @@ export interface components {
|
|
|
1099
1103
|
name: string;
|
|
1100
1104
|
slug: string;
|
|
1101
1105
|
};
|
|
1106
|
+
/** @enum {unknown} */
|
|
1107
|
+
SelectedVariantStatus: "matched" | "notSoldHere" | "unknown";
|
|
1102
1108
|
SellabilityDto: {
|
|
1103
1109
|
mode: components["schemas"]["SiteSellabilityMode"];
|
|
1104
1110
|
};
|
|
@@ -1255,6 +1261,7 @@ export interface components {
|
|
|
1255
1261
|
};
|
|
1256
1262
|
sku: string;
|
|
1257
1263
|
stock: string;
|
|
1264
|
+
url: null | string;
|
|
1258
1265
|
};
|
|
1259
1266
|
VariantOptionDto: {
|
|
1260
1267
|
name: string;
|
|
@@ -3314,7 +3321,10 @@ export interface operations {
|
|
|
3314
3321
|
};
|
|
3315
3322
|
GetProduct: {
|
|
3316
3323
|
parameters: {
|
|
3317
|
-
query?:
|
|
3324
|
+
query?: {
|
|
3325
|
+
/** @description A variant id (`VariantDto.id`) to preselect. Answered in `selectedVariantStatus`; a value that is not one of this product's variants is `unknown`, never an error. */
|
|
3326
|
+
variant?: string;
|
|
3327
|
+
};
|
|
3318
3328
|
header: {
|
|
3319
3329
|
/** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
|
|
3320
3330
|
"X-Site-Key": string;
|
package/dist/seo/product.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type JsonLd, type ProductDto, type ProductJsonLdOptions } from "./types.js";
|
|
2
2
|
/**
|
|
3
3
|
* schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
|
|
4
|
-
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price.
|
|
4
|
+
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price. A priced `p.selectedVariant` is always
|
|
5
|
+
* in the group, appended when the list does not hold it (variant URLs spec §6).
|
|
5
6
|
*/
|
|
6
7
|
export declare function productJsonLd(p: ProductDto, ctx: ProductJsonLdOptions): JsonLd;
|
package/dist/seo/product.js
CHANGED
|
@@ -13,11 +13,14 @@ function availabilityOf(v, ctx) {
|
|
|
13
13
|
return SCHEMA_AVAILABILITY[state] ?? OUT_OF_STOCK;
|
|
14
14
|
}
|
|
15
15
|
const brandNode = (label) => ({ "@type": "Brand", name: label });
|
|
16
|
-
/** Spec §4.12: absolute http(s), distinct per variant; checked for every emitted variant, single path included.
|
|
17
|
-
|
|
16
|
+
/** Spec §4.12: absolute http(s), distinct per variant; checked for every emitted variant, single path included. The
|
|
17
|
+
* caller's hook wins, else the variant's own `url` (variant URLs spec §6); neither is an error. */
|
|
18
|
+
function checkVariantUrls(variants, ctx) {
|
|
18
19
|
const seen = new Set();
|
|
19
20
|
return variants.map((v) => {
|
|
20
|
-
const u = variantUrl(v);
|
|
21
|
+
const u = ctx.variantUrl?.(v) ?? v.url;
|
|
22
|
+
if (u == null)
|
|
23
|
+
throw new SeoError(`variant ${v.sku} has no URL: neither the variantUrl hook nor v.url (from the API) gave one`);
|
|
21
24
|
let parsed = null;
|
|
22
25
|
if (typeof u === "string") {
|
|
23
26
|
try {
|
|
@@ -57,7 +60,8 @@ function offerExtras(ctx) {
|
|
|
57
60
|
}
|
|
58
61
|
/**
|
|
59
62
|
* schema.org `Product` / `ProductGroup` for a product page (spec §6.2). Decision order: no offer → single (only for a
|
|
60
|
-
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price.
|
|
63
|
+
* proven singleton) → group. Never an `AggregateOffer`, never a guessed price. A priced `p.selectedVariant` is always
|
|
64
|
+
* in the group, appended when the list does not hold it (variant URLs spec §6).
|
|
61
65
|
*/
|
|
62
66
|
export function productJsonLd(p, ctx) {
|
|
63
67
|
rejectRedirect(p, `product ${p.sku}`);
|
|
@@ -66,19 +70,24 @@ export function productJsonLd(p, ctx) {
|
|
|
66
70
|
for (const [axis, prop] of Object.entries(variesBy))
|
|
67
71
|
if (!PROPERTIES.includes(prop))
|
|
68
72
|
throw new SeoError(`variesBy['${axis}'] = '${prop}' is not one of ${PROPERTIES.join(", ")}`);
|
|
69
|
-
if (typeof ctx.variantUrl !== "function")
|
|
70
|
-
throw new SeoError("variantUrl
|
|
73
|
+
if (ctx.variantUrl !== undefined && typeof ctx.variantUrl !== "function")
|
|
74
|
+
throw new SeoError("variantUrl must be a function");
|
|
71
75
|
const list = p.variants ?? ctx.variants ?? null;
|
|
72
76
|
const complete = p.variants !== null;
|
|
73
|
-
|
|
77
|
+
// Variant URLs spec §6: a priced selection is always emitted, exactly once (deduplicated by id against the list).
|
|
78
|
+
const sel = p.selectedVariant?.price != null ? p.selectedVariant : null;
|
|
79
|
+
const merged = sel !== null && !(list ?? []).some((v) => v.id === sel.id) ? [...(list ?? []), sel] : (list ?? []);
|
|
80
|
+
const sellable = merged.filter((v) => v.price !== null);
|
|
74
81
|
const description = jsonLdDescription(productDescription(p));
|
|
75
82
|
const images = pickImages(ctx.site, p.images, market.language, p.name).map((i) => i.url);
|
|
76
|
-
|
|
83
|
+
// Variant URLs spec §5.4: effective images = the variant's own set when it owns one (an owned empty set stays empty).
|
|
84
|
+
const effectiveImages = (v, alt) => v.hasOwnImages ? pickImages(ctx.site, v.images, market.language, alt).map((x) => x.url) : images;
|
|
85
|
+
const common = (type, image = images) => ({
|
|
77
86
|
"@context": "https://schema.org",
|
|
78
87
|
"@type": type,
|
|
79
88
|
name: p.name,
|
|
80
89
|
...(description !== null ? { description } : {}),
|
|
81
|
-
...(
|
|
90
|
+
...(image.length > 0 ? { image } : {}),
|
|
82
91
|
url: p.url,
|
|
83
92
|
});
|
|
84
93
|
const offer = (v, url) => ({
|
|
@@ -98,14 +107,16 @@ export function productJsonLd(p, ctx) {
|
|
|
98
107
|
const keyed = gtinJsonLd(g);
|
|
99
108
|
return Object.keys(keyed).length > 0 ? keyed : { gtin: g };
|
|
100
109
|
};
|
|
101
|
-
if (
|
|
110
|
+
if (sellable.length === 0)
|
|
102
111
|
return { ...common("Product"), sku: p.sku, ...(p.brand ? { brand: brandNode(p.brand.label) } : {}) };
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
// The singleton is decided on the list alone: a selection never turns a proven singleton into a group (§6).
|
|
113
|
+
const listSellable = (list ?? []).filter((v) => v.price !== null);
|
|
114
|
+
if (list !== null && list.length === 1 && p.options.length === 0 && listSellable.length === 1 && (complete || Number(p.variantCount) === 1)) {
|
|
115
|
+
const v = listSellable[0];
|
|
116
|
+
const [url] = checkVariantUrls([v], ctx);
|
|
117
|
+
return { ...common("Product", effectiveImages(v, p.name)), sku: v.sku, ...gtin(v), ...(v.brand ? { brand: brandNode(v.brand.label) } : {}), offers: offer(v, url) };
|
|
107
118
|
}
|
|
108
|
-
const urls = checkVariantUrls(sellable, ctx
|
|
119
|
+
const urls = checkVariantUrls(sellable, ctx);
|
|
109
120
|
// Spec §4.14: hoist only from the DTO's own list, and only when every emitted variant has the same non-null label.
|
|
110
121
|
const first = sellable[0].brand?.label;
|
|
111
122
|
const hoisted = complete && first != null && sellable.every((v) => v.brand?.label === first) ? first : null;
|
|
@@ -113,7 +124,8 @@ export function productJsonLd(p, ctx) {
|
|
|
113
124
|
const props = [...new Set(axes.map((a) => a.prop))];
|
|
114
125
|
return {
|
|
115
126
|
...common("ProductGroup"),
|
|
116
|
-
|
|
127
|
+
// VU8: the product id, which never changes and matches the feeds' item_group_id; a SKU can be renamed.
|
|
128
|
+
productGroupID: p.id,
|
|
117
129
|
...(props.length > 0 ? { variesBy: props.map((x) => `https://schema.org/${x}`) } : {}),
|
|
118
130
|
...(hoisted !== null ? { brand: brandNode(hoisted) } : {}),
|
|
119
131
|
hasVariant: sellable.map((v, i) => {
|
|
@@ -124,13 +136,13 @@ export function productJsonLd(p, ctx) {
|
|
|
124
136
|
if (value !== undefined && !Object.hasOwn(own, a.prop))
|
|
125
137
|
own[a.prop] = value;
|
|
126
138
|
}
|
|
127
|
-
const vImages =
|
|
139
|
+
const vImages = effectiveImages(v, name);
|
|
128
140
|
return {
|
|
129
141
|
"@type": "Product",
|
|
130
142
|
sku: v.sku,
|
|
131
143
|
...gtin(v),
|
|
132
144
|
name,
|
|
133
|
-
inProductGroupWithID: p.
|
|
145
|
+
inProductGroupWithID: p.id,
|
|
134
146
|
...own,
|
|
135
147
|
...(vImages.length > 0 ? { image: vImages } : {}),
|
|
136
148
|
...(hoisted === null && v.brand ? { brand: brandNode(v.brand.label) } : {}),
|
package/dist/seo/types.d.ts
CHANGED
|
@@ -36,8 +36,9 @@ export type SeoHead = {
|
|
|
36
36
|
meta: SeoMeta[];
|
|
37
37
|
};
|
|
38
38
|
export type ProductJsonLdOptions = SeoContext & {
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/** A distinct absolute http(s) URL that opens the page with this variant preselected (spec §4.12). Optional: defaults
|
|
40
|
+
* to the variant's own `url` (variant URLs spec §6); a hook given here wins. */
|
|
41
|
+
variantUrl?: (v: VariantDto) => string;
|
|
41
42
|
/** Option name (`options[].name`) → schema.org property, e.g. `{ Storlek: "size", Färg: "color" }`. */
|
|
42
43
|
variesBy?: Record<string, SchemaProperty>;
|
|
43
44
|
/** Used only when `p.variants` is `null` (more than 100 variants), e.g. the first page of `/variants`. */
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variant URL helpers (spec 2026-10-16 §4, §7, §8 step 2). The selector is the query parameter `variant`; its key is the
|
|
3
|
+
* variant id exactly as `VariantDto.id` returns it. A frontend builds, reads and redirects with these, never by hand.
|
|
4
|
+
*/
|
|
5
|
+
/** The fixed name of the variant selector parameter (VU3). */
|
|
6
|
+
export declare const VARIANT_PARAM = "variant";
|
|
7
|
+
/** What a request's `variant` parameter says: nothing, one numeric id, or anything else (empty, text, repeated). */
|
|
8
|
+
export type VariantParam = {
|
|
9
|
+
kind: "absent";
|
|
10
|
+
} | {
|
|
11
|
+
kind: "invalid";
|
|
12
|
+
raw: string[];
|
|
13
|
+
} | {
|
|
14
|
+
kind: "id";
|
|
15
|
+
value: string;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* A received query. A `string` is a raw search (`"?a=1&b=2"` or `"a=1&b=2"`) and is the only form that keeps the
|
|
19
|
+
* interleaving of different keys; a record is the Next.js `searchParams` shape.
|
|
20
|
+
*/
|
|
21
|
+
export type QueryInput = string | URLSearchParams | Record<string, string | string[] | undefined>;
|
|
22
|
+
/** The URL of one variant: the product URL with `variant=<id>` set by query handling (VU6: the product URL when the ids match).
|
|
23
|
+
* Prefer `v.url` when you have it: equal in meaning, not always byte-equal (the query is re-serialized). */
|
|
24
|
+
export declare function variantUrl(productUrl: string, productId: string, variantId: string): string;
|
|
25
|
+
/** Read the selector: every `variant` value in order; exactly one all-digit value is an id, anything else is invalid. */
|
|
26
|
+
export declare function readVariantParam(q: QueryInput): VariantParam;
|
|
27
|
+
/** The value to send as the API's `variant` query parameter: the id, `"-"` for an invalid selector, none when absent. */
|
|
28
|
+
export declare function variantQueryForApi(parsed: VariantParam): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Where a 301 to `destinationUrl` should point: the destination's path and query, plus every received parameter the
|
|
31
|
+
* destination does not set itself (destination wins), in incoming order and multiplicity. `variant` is always carried
|
|
32
|
+
* as received. Values are decoded and re-encoded (`a%20b` becomes `a+b`): names, values, multiplicity and order are kept.
|
|
33
|
+
*/
|
|
34
|
+
export declare function redirectTarget(destinationUrl: string, received: QueryInput): string;
|
package/dist/variant.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Variant URL helpers (spec 2026-10-16 §4, §7, §8 step 2). The selector is the query parameter `variant`; its key is the
|
|
3
|
+
* variant id exactly as `VariantDto.id` returns it. A frontend builds, reads and redirects with these, never by hand.
|
|
4
|
+
*/
|
|
5
|
+
/** The fixed name of the variant selector parameter (VU3). */
|
|
6
|
+
export const VARIANT_PARAM = "variant";
|
|
7
|
+
// The API's key policy: a key equals `variant` ASCII case-insensitively.
|
|
8
|
+
const isVariantKey = (k) => k.toLowerCase() === VARIANT_PARAM;
|
|
9
|
+
function entries(q) {
|
|
10
|
+
if (typeof q === "string")
|
|
11
|
+
return [...new URLSearchParams(q.startsWith("?") ? q.slice(1) : q)];
|
|
12
|
+
if (q instanceof URLSearchParams)
|
|
13
|
+
return [...q];
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const [k, v] of Object.entries(q)) {
|
|
16
|
+
if (v === undefined)
|
|
17
|
+
continue;
|
|
18
|
+
for (const item of Array.isArray(v) ? v : [v])
|
|
19
|
+
out.push([k, item]);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** The URL of one variant: the product URL with `variant=<id>` set by query handling (VU6: the product URL when the ids match).
|
|
24
|
+
* Prefer `v.url` when you have it: equal in meaning, not always byte-equal (the query is re-serialized). */
|
|
25
|
+
export function variantUrl(productUrl, productId, variantId) {
|
|
26
|
+
if (variantId === productId)
|
|
27
|
+
return productUrl;
|
|
28
|
+
const url = new URL(productUrl);
|
|
29
|
+
for (const k of [...new Set([...url.searchParams.keys()])])
|
|
30
|
+
if (isVariantKey(k))
|
|
31
|
+
url.searchParams.delete(k);
|
|
32
|
+
url.searchParams.append(VARIANT_PARAM, variantId);
|
|
33
|
+
return url.toString();
|
|
34
|
+
}
|
|
35
|
+
/** Read the selector: every `variant` value in order; exactly one all-digit value is an id, anything else is invalid. */
|
|
36
|
+
export function readVariantParam(q) {
|
|
37
|
+
const raw = entries(q).filter(([k]) => isVariantKey(k)).map(([, v]) => v);
|
|
38
|
+
if (raw.length === 0)
|
|
39
|
+
return { kind: "absent" };
|
|
40
|
+
if (raw.length === 1 && /^\d{1,19}$/.test(raw[0]))
|
|
41
|
+
return { kind: "id", value: raw[0] };
|
|
42
|
+
return { kind: "invalid", raw };
|
|
43
|
+
}
|
|
44
|
+
/** The value to send as the API's `variant` query parameter: the id, `"-"` for an invalid selector, none when absent. */
|
|
45
|
+
export function variantQueryForApi(parsed) {
|
|
46
|
+
switch (parsed.kind) {
|
|
47
|
+
case "id": return parsed.value;
|
|
48
|
+
case "invalid": return "-";
|
|
49
|
+
default: return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Where a 301 to `destinationUrl` should point: the destination's path and query, plus every received parameter the
|
|
54
|
+
* destination does not set itself (destination wins), in incoming order and multiplicity. `variant` is always carried
|
|
55
|
+
* as received. Values are decoded and re-encoded (`a%20b` becomes `a+b`): names, values, multiplicity and order are kept.
|
|
56
|
+
*/
|
|
57
|
+
export function redirectTarget(destinationUrl, received) {
|
|
58
|
+
const dest = new URL(destinationUrl);
|
|
59
|
+
const own = new Set(dest.searchParams.keys());
|
|
60
|
+
const out = new URLSearchParams();
|
|
61
|
+
for (const [k, v] of dest.searchParams)
|
|
62
|
+
if (!isVariantKey(k))
|
|
63
|
+
out.append(k, v);
|
|
64
|
+
for (const [k, v] of entries(received))
|
|
65
|
+
if (isVariantKey(k) || !own.has(k))
|
|
66
|
+
out.append(k, v);
|
|
67
|
+
const search = out.toString();
|
|
68
|
+
// A path starting with `//` would read as protocol-relative; keep one slash.
|
|
69
|
+
const path = dest.pathname.replace(/^\/{2,}/, "/");
|
|
70
|
+
return path + (search ? "?" + search : "");
|
|
71
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asstio-dev/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"test": "vitest run",
|
|
22
22
|
"test:browser": "playwright test -c browser/playwright.config.ts",
|
|
23
23
|
"check": "npm run generate && npm run typecheck-examples && npm test && git diff --exit-code -- src/schema.d.ts && test -z \"$(git status --porcelain -- src/schema.d.ts)\"",
|
|
24
|
-
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
|
|
24
|
+
"smoke": "node --input-type=module -e \"import(\\\"./dist/index.js\\\").then(m => { for (const f of [\\\"createStorefrontClient\\\", \\\"filterQuery\\\", \\\"encodeFilterValues\\\", \\\"decodeFilterValues\\\", \\\"pictureSources\\\", \\\"mediaUrl\\\", \\\"formatImage\\\", \\\"styleToCss\\\", \\\"startCheckout\\\", \\\"syncCheckout\\\", \\\"completeCheckout\\\", \\\"getCheckoutStatus\\\", \\\"liveShippingFor\\\", \\\"getOrder\\\", \\\"awaitCompletion\\\", \\\"mountCheckout\\\", \\\"productJsonLd\\\", \\\"gtinJsonLd\\\", \\\"breadcrumbJsonLd\\\", \\\"productHead\\\", \\\"collectionHead\\\", \\\"brandHead\\\", \\\"toNextMetadata\\\", \\\"jsonLdScript\\\", \\\"SeoError\\\", \\\"productFacts\\\", \\\"toGa4Item\\\", \\\"toGa4Ecommerce\\\", \\\"longestCategory\\\", \\\"AnalyticsError\\\", \\\"trackSearchEvents\\\", \\\"variantUrl\\\", \\\"readVariantParam\\\", \\\"variantQueryForApi\\\", \\\"redirectTarget\\\"]) if (typeof m[f] !== \\\"function\\\") throw new Error(\\\"missing export \\\" + f) })\""
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"openapi-fetch": "0.17.0"
|
package/src/schema.d.ts
CHANGED
|
@@ -546,6 +546,7 @@ export interface components {
|
|
|
546
546
|
selectedOptions: {
|
|
547
547
|
[key: string]: string;
|
|
548
548
|
};
|
|
549
|
+
url: null | string;
|
|
549
550
|
};
|
|
550
551
|
CategoryNodeDto: {
|
|
551
552
|
children: components["schemas"]["CategoryNodeDto"][];
|
|
@@ -923,6 +924,7 @@ export interface components {
|
|
|
923
924
|
};
|
|
924
925
|
OrderLineVariantDto: {
|
|
925
926
|
brand: null | components["schemas"]["BrandDto"];
|
|
927
|
+
url: null | string;
|
|
926
928
|
};
|
|
927
929
|
OrderShippingDto: {
|
|
928
930
|
carrier: string;
|
|
@@ -1011,6 +1013,8 @@ export interface components {
|
|
|
1011
1013
|
redirectTo: null | string;
|
|
1012
1014
|
related: components["schemas"]["RelatedGroupDto"][];
|
|
1013
1015
|
responsible: components["schemas"]["ResponsibleDto"];
|
|
1016
|
+
selectedVariant: null | components["schemas"]["VariantDto"];
|
|
1017
|
+
selectedVariantStatus: null | components["schemas"]["SelectedVariantStatus"];
|
|
1014
1018
|
seo: components["schemas"]["SeoDto"];
|
|
1015
1019
|
shortDescription: null | string;
|
|
1016
1020
|
sku: string;
|
|
@@ -1099,6 +1103,8 @@ export interface components {
|
|
|
1099
1103
|
name: string;
|
|
1100
1104
|
slug: string;
|
|
1101
1105
|
};
|
|
1106
|
+
/** @enum {unknown} */
|
|
1107
|
+
SelectedVariantStatus: "matched" | "notSoldHere" | "unknown";
|
|
1102
1108
|
SellabilityDto: {
|
|
1103
1109
|
mode: components["schemas"]["SiteSellabilityMode"];
|
|
1104
1110
|
};
|
|
@@ -1255,6 +1261,7 @@ export interface components {
|
|
|
1255
1261
|
};
|
|
1256
1262
|
sku: string;
|
|
1257
1263
|
stock: string;
|
|
1264
|
+
url: null | string;
|
|
1258
1265
|
};
|
|
1259
1266
|
VariantOptionDto: {
|
|
1260
1267
|
name: string;
|
|
@@ -3314,7 +3321,10 @@ export interface operations {
|
|
|
3314
3321
|
};
|
|
3315
3322
|
GetProduct: {
|
|
3316
3323
|
parameters: {
|
|
3317
|
-
query?:
|
|
3324
|
+
query?: {
|
|
3325
|
+
/** @description A variant id (`VariantDto.id`) to preselect. Answered in `selectedVariantStatus`; a value that is not one of this product's variants is `unknown`, never an error. */
|
|
3326
|
+
variant?: string;
|
|
3327
|
+
};
|
|
3318
3328
|
header: {
|
|
3319
3329
|
/** @description Publishable, preview or secret site key. Required on every request; secret keys are server-side only. */
|
|
3320
3330
|
"X-Site-Key": string;
|