@wocart/storefront 1.19.0 → 1.20.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/CHANGELOG.md +65 -0
- package/USAGE.md +286 -4
- package/dist/abandoned-checkout.d.ts +45 -0
- package/dist/abandoned-checkout.js +81 -3
- package/dist/answer-readers.d.ts +11 -4
- package/dist/answer-readers.js +114 -0
- package/dist/astro.d.ts +2 -0
- package/dist/astro.js +1 -0
- package/dist/basket.d.ts +11 -1
- package/dist/basket.js +17 -0
- package/dist/catalog.d.ts +18 -0
- package/dist/catalog.js +27 -0
- package/dist/client/orders.d.ts +10 -1
- package/dist/client/orders.js +8 -1
- package/dist/client/products.d.ts +13 -3
- package/dist/client/products.js +35 -12
- package/dist/coupon.d.ts +16 -1
- package/dist/coupon.js +37 -0
- package/dist/endpoints/storefront-routes.d.ts +8 -2
- package/dist/endpoints/storefront-routes.js +12 -2
- package/dist/index.d.ts +15 -7
- package/dist/index.js +9 -4
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +4 -0
- package/dist/order-number.d.ts +9 -0
- package/dist/order-number.js +11 -0
- package/dist/product-view.d.ts +8 -0
- package/dist/product-view.js +26 -4
- package/dist/quote.d.ts +27 -0
- package/dist/quote.js +43 -0
- package/dist/review-capture.d.ts +77 -0
- package/dist/review-capture.js +172 -0
- package/dist/review-media.d.ts +8 -75
- package/dist/review-media.js +7 -170
- package/dist/review-session.d.ts +97 -0
- package/dist/review-session.js +157 -0
- package/dist/reviews.d.ts +33 -0
- package/dist/reviews.js +41 -0
- package/dist/schemas.d.ts +98 -0
- package/dist/schemas.js +31 -0
- package/dist/seo.d.ts +47 -0
- package/dist/seo.js +87 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/sitemap.d.ts +22 -0
- package/dist/sitemap.js +51 -0
- package/dist/store-request.d.ts +96 -0
- package/dist/store-request.js +102 -0
- package/dist/types.d.ts +50 -2
- package/dist/utils/image.d.ts +48 -0
- package/dist/utils/image.js +51 -0
- package/package.json +1 -1
package/dist/schemas.js
CHANGED
|
@@ -86,6 +86,10 @@ const productBaseFields = {
|
|
|
86
86
|
currency: z.string(),
|
|
87
87
|
price: z.number(),
|
|
88
88
|
compareAtPrice: z.number().nullable(),
|
|
89
|
+
/**
|
|
90
|
+
* @deprecated Always null: what the merchant paid is never served to a
|
|
91
|
+
* storefront. Older servers sent it; a later major drops it. Do not read it.
|
|
92
|
+
*/
|
|
89
93
|
costPrice: z.number().nullable(),
|
|
90
94
|
type: z.enum(["PHYSICAL", "DIGITAL"]),
|
|
91
95
|
hasVariants: z.boolean(),
|
|
@@ -109,6 +113,10 @@ const productBaseFields = {
|
|
|
109
113
|
createdAt: z.string().datetime(),
|
|
110
114
|
updatedAt: z.string().datetime(),
|
|
111
115
|
shippingProfileId: z.string().nullable().optional(),
|
|
116
|
+
/** The title search engines and shared links show, in the client's language; null or absent: use `productMeta(product)`, which falls back to the name. */
|
|
117
|
+
metaTitle: z.string().nullish(),
|
|
118
|
+
/** The description search engines and shared links show; null or absent: `productMeta(product)` shortens the product's own. */
|
|
119
|
+
metaDescription: z.string().nullish(),
|
|
112
120
|
};
|
|
113
121
|
const variantOptionSchema = z.object({
|
|
114
122
|
name: z.string(),
|
|
@@ -124,6 +132,7 @@ const variantOptionSchema = z.object({
|
|
|
124
132
|
const rawProductFields = {
|
|
125
133
|
...productBaseFields,
|
|
126
134
|
variantOptions: z.string().nullable(),
|
|
135
|
+
/** The tags as stored JSON text; read them with `productTags(product)`, which a product page's list also goes through. */
|
|
127
136
|
tags: z.string().nullable(),
|
|
128
137
|
coverImage: ProductImageSchema.nullable(),
|
|
129
138
|
reviewStats: reviewStatsSchema.nullable(),
|
|
@@ -170,6 +179,10 @@ export const StoreCollectionSchema = z.object({
|
|
|
170
179
|
handle: z.string(),
|
|
171
180
|
title: z.string(),
|
|
172
181
|
description: z.string().nullable(),
|
|
182
|
+
/** How many products a shopper sees in it; absent from a server that predates it. */
|
|
183
|
+
productCount: z.number().int().min(0).nullish(),
|
|
184
|
+
/** Its first listed product's first photo; absent from a server that predates it. */
|
|
185
|
+
image: ProductImageSchema.nullish(),
|
|
173
186
|
});
|
|
174
187
|
export const StoreMenuSchema = z.object({
|
|
175
188
|
handle: z.string(),
|
|
@@ -413,6 +426,24 @@ export const OrderTrackingSchema = z.object({
|
|
|
413
426
|
pixelId: z.string().nullable(),
|
|
414
427
|
event: z.enum(["Purchase", "Lead"]).nullable(),
|
|
415
428
|
});
|
|
429
|
+
/**
|
|
430
|
+
* Where a shopper's order is — POST /store/orders/status. `stage` is an open
|
|
431
|
+
* string: a newer server may name a step this release does not know.
|
|
432
|
+
*/
|
|
433
|
+
export const ShopperOrderStatusSchema = z.object({
|
|
434
|
+
orderNumber: z.string(),
|
|
435
|
+
placedAt: z.string(),
|
|
436
|
+
stage: z.string(),
|
|
437
|
+
deliveryType: z.enum(["home", "stop_desk"]),
|
|
438
|
+
lines: z.array(z.object({ name: z.string(), variantLabel: z.string().nullable(), quantity: z.number().int() })),
|
|
439
|
+
total: z.number(),
|
|
440
|
+
tracking: z.object({ number: z.string(), carrier: z.string().nullable() }).nullable(),
|
|
441
|
+
});
|
|
442
|
+
/** A page of products with the whole listing's size; `total` is absent from a server that predates it. */
|
|
443
|
+
export const ProductPageEnvelopeSchema = z.object({
|
|
444
|
+
data: z.array(ProductListSchema),
|
|
445
|
+
total: z.number().int().min(0).optional(),
|
|
446
|
+
});
|
|
416
447
|
/**
|
|
417
448
|
* The `{ id }` success payload of the write endpoints that only mint a row:
|
|
418
449
|
* POST /store/reviews and POST /store/abandoned.
|
package/dist/seo.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Product, ProductList } from "./types.js";
|
|
2
|
+
/** The lengths a search-engine title and description may have: about what a search result shows. */
|
|
3
|
+
export declare const SEO_TEXT_LIMITS: {
|
|
4
|
+
readonly titleMax: 60;
|
|
5
|
+
readonly descriptionMax: 160;
|
|
6
|
+
};
|
|
7
|
+
/** A page's `<title>` text and `<meta name="description">`; description null when there is nothing to say. */
|
|
8
|
+
export interface PageMeta {
|
|
9
|
+
title: string;
|
|
10
|
+
description: string | null;
|
|
11
|
+
}
|
|
12
|
+
type MetaSource = Pick<ProductList, "name" | "description" | "descriptionFormat" | "descriptionPlain"> & {
|
|
13
|
+
metaTitle?: string | null | undefined;
|
|
14
|
+
metaDescription?: string | null | undefined;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* A product's title and description for search engines and shared links:
|
|
18
|
+
* what the merchant wrote for them, else its name and the start of its own
|
|
19
|
+
* description as plain text — never markup — within `SEO_TEXT_LIMITS`.
|
|
20
|
+
* The store's name is the theme's to add to the title.
|
|
21
|
+
*/
|
|
22
|
+
export declare function productMeta(product: MetaSource): PageMeta;
|
|
23
|
+
export interface ProductStructuredDataOptions {
|
|
24
|
+
/** The page's absolute URL. */
|
|
25
|
+
url: string;
|
|
26
|
+
/** The store's name, as the brand when the product has no other. */
|
|
27
|
+
storeName?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The schema.org `Product` of a product page, for a
|
|
31
|
+
* `<script type="application/ld+json">` written with `jsonLd()`: name,
|
|
32
|
+
* description, photos, SKU, its price and stock as an `Offer` — an
|
|
33
|
+
* `AggregateOffer` with the lowest and highest price when its variants cost
|
|
34
|
+
* different amounts — and its rating once it has reviews.
|
|
35
|
+
*/
|
|
36
|
+
export declare function productStructuredData(product: Product, options: ProductStructuredDataOptions): Record<string, unknown>;
|
|
37
|
+
/**
|
|
38
|
+
* Structured data as the text of a `<script type="application/ld+json">`:
|
|
39
|
+
* JSON with `<`, `>`, `&` and the two line separators escaped, so no value — a
|
|
40
|
+
* product name holding `</script>` — can end the script or start markup.
|
|
41
|
+
*
|
|
42
|
+
* ```astro
|
|
43
|
+
* <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url }))} />
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function jsonLd(value: unknown): string;
|
|
47
|
+
export {};
|
package/dist/seo.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a product page tells search engines and shared links: its title and
|
|
3
|
+
* description, and its structured data — decided once, so every theme ranks
|
|
4
|
+
* the same products the same way.
|
|
5
|
+
*/
|
|
6
|
+
import { getProductView } from "./product-view.js";
|
|
7
|
+
import { initialVariant } from "./variant-picker.js";
|
|
8
|
+
/** The lengths a search-engine title and description may have: about what a search result shows. */
|
|
9
|
+
export const SEO_TEXT_LIMITS = { titleMax: 60, descriptionMax: 160 };
|
|
10
|
+
const collapse = (text) => text.replace(/\s+/g, " ").trim();
|
|
11
|
+
/** `text` within `max` characters, cut at a word with an ellipsis when it is longer. */
|
|
12
|
+
function shorten(text, max) {
|
|
13
|
+
if (text.length <= max)
|
|
14
|
+
return text;
|
|
15
|
+
const cut = text.slice(0, max - 1);
|
|
16
|
+
const space = cut.lastIndexOf(" ");
|
|
17
|
+
return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A product's title and description for search engines and shared links:
|
|
21
|
+
* what the merchant wrote for them, else its name and the start of its own
|
|
22
|
+
* description as plain text — never markup — within `SEO_TEXT_LIMITS`.
|
|
23
|
+
* The store's name is the theme's to add to the title.
|
|
24
|
+
*/
|
|
25
|
+
export function productMeta(product) {
|
|
26
|
+
const title = collapse(product.metaTitle ?? "") || collapse(product.name);
|
|
27
|
+
const written = collapse(product.metaDescription ?? "");
|
|
28
|
+
if (written)
|
|
29
|
+
return { title, description: written };
|
|
30
|
+
const plain = product.descriptionPlain ?? (product.descriptionFormat === "text" ? product.description : null);
|
|
31
|
+
const text = collapse(plain ?? "");
|
|
32
|
+
return { title, description: text ? shorten(text, SEO_TEXT_LIMITS.descriptionMax) : null };
|
|
33
|
+
}
|
|
34
|
+
const AVAILABILITY = { inStock: "https://schema.org/InStock", outOfStock: "https://schema.org/OutOfStock" };
|
|
35
|
+
/**
|
|
36
|
+
* The schema.org `Product` of a product page, for a
|
|
37
|
+
* `<script type="application/ld+json">` written with `jsonLd()`: name,
|
|
38
|
+
* description, photos, SKU, its price and stock as an `Offer` — an
|
|
39
|
+
* `AggregateOffer` with the lowest and highest price when its variants cost
|
|
40
|
+
* different amounts — and its rating once it has reviews.
|
|
41
|
+
*/
|
|
42
|
+
export function productStructuredData(product, options) {
|
|
43
|
+
const meta = productMeta(product);
|
|
44
|
+
const view = getProductView(product);
|
|
45
|
+
const variants = product.hasVariants ? product.variants.filter((variant) => variant.active) : [];
|
|
46
|
+
const prices = variants.map((variant) => variant.price);
|
|
47
|
+
const sellable = product.hasVariants
|
|
48
|
+
? variants.some((variant) => !product.trackInventory || variant.inventory > 0)
|
|
49
|
+
: !view.isOutOfStock;
|
|
50
|
+
const availability = sellable ? AVAILABILITY.inStock : AVAILABILITY.outOfStock;
|
|
51
|
+
const ranged = prices.length > 1 && Math.min(...prices) !== Math.max(...prices);
|
|
52
|
+
const offers = ranged
|
|
53
|
+
? { "@type": "AggregateOffer", url: options.url, priceCurrency: product.currency, lowPrice: Math.min(...prices), highPrice: Math.max(...prices), offerCount: prices.length, availability }
|
|
54
|
+
: { "@type": "Offer", url: options.url, priceCurrency: product.currency, price: view.basePrice, availability, itemCondition: "https://schema.org/NewCondition" };
|
|
55
|
+
const sku = product.sku ?? (product.hasVariants ? initialVariant(product)?.sku : null) ?? null;
|
|
56
|
+
const images = [...product.images].sort((a, b) => a.position - b.position).map((image) => image.src);
|
|
57
|
+
const rating = product.reviewStats && product.reviewStats.reviewCount > 0 ? product.reviewStats : null;
|
|
58
|
+
return {
|
|
59
|
+
"@context": "https://schema.org",
|
|
60
|
+
"@type": "Product",
|
|
61
|
+
name: collapse(product.name),
|
|
62
|
+
...(meta.description ? { description: meta.description } : {}),
|
|
63
|
+
...(images.length > 0 ? { image: images } : {}),
|
|
64
|
+
...(sku ? { sku } : {}),
|
|
65
|
+
url: options.url,
|
|
66
|
+
...(options.storeName ? { brand: { "@type": "Brand", name: options.storeName } } : {}),
|
|
67
|
+
offers,
|
|
68
|
+
...(rating ? { aggregateRating: { "@type": "AggregateRating", ratingValue: rating.avgRating, reviewCount: rating.reviewCount } } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Structured data as the text of a `<script type="application/ld+json">`:
|
|
73
|
+
* JSON with `<`, `>`, `&` and the two line separators escaped, so no value — a
|
|
74
|
+
* product name holding `</script>` — can end the script or start markup.
|
|
75
|
+
*
|
|
76
|
+
* ```astro
|
|
77
|
+
* <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url }))} />
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function jsonLd(value) {
|
|
81
|
+
return JSON.stringify(value)
|
|
82
|
+
.replace(/</g, "\\u003c")
|
|
83
|
+
.replace(/>/g, "\\u003e")
|
|
84
|
+
.replace(/&/g, "\\u0026")
|
|
85
|
+
.replace(/\u2028/g, "\\u2028")
|
|
86
|
+
.replace(/\u2029/g, "\\u2029");
|
|
87
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -6,3 +6,4 @@ export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPrevie
|
|
|
6
6
|
export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
|
|
7
7
|
export type { RevalidateClaims, RevalidateRequest, RevalidateRequestOptions } from "./revalidate.js";
|
|
8
8
|
export type { PreviewClaims, PreviewDraft, PreviewRequest, PreviewRequestOptions, PreviewTokenCheck, PreviewTokenOptions, PreviewTokenRefusal, } from "./preview.js";
|
|
9
|
+
export { catalogPaths, sitemapXml } from "./sitemap.js";
|
package/dist/server.js
CHANGED
|
@@ -2,3 +2,4 @@ export { createStoreClient } from "./client/index.js";
|
|
|
2
2
|
export { StorefrontError, isStorefrontError } from "./client/errors.js";
|
|
3
3
|
export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
|
|
4
4
|
export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
|
|
5
|
+
export { catalogPaths, sitemapXml } from "./sitemap.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront's sitemap: every page a shopper can land on, in every
|
|
3
|
+
* published language, as search engines read multilingual sites — one
|
|
4
|
+
* `<url>` per page per language, each naming all its language versions and
|
|
5
|
+
* `x-default`. Landing pages are left out: they are ad destinations, often
|
|
6
|
+
* unlisted from the store.
|
|
7
|
+
*/
|
|
8
|
+
import type { StoreClient } from "./client/index.js";
|
|
9
|
+
import { type LocaleRouting } from "./locale-routing.js";
|
|
10
|
+
import type { StoreConfig } from "./types.js";
|
|
11
|
+
/**
|
|
12
|
+
* The sitemap of `paths` — unprefixed page paths (`/products/robe`) — on
|
|
13
|
+
* `origin`, in every language `routing` publishes. A path given twice is
|
|
14
|
+
* listed once.
|
|
15
|
+
*/
|
|
16
|
+
export declare function sitemapXml(paths: readonly string[], routing: LocaleRouting, origin: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Every page of the store a sitemap lists, unprefixed: home, the catalog,
|
|
19
|
+
* each category, each product (the whole catalog, read page by page) and each
|
|
20
|
+
* published text page.
|
|
21
|
+
*/
|
|
22
|
+
export declare function catalogPaths(client: StoreClient, config: Pick<StoreConfig, "pages">): Promise<string[]>;
|
package/dist/sitemap.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { PRODUCT_PAGE_MAX } from "./client/products.js";
|
|
2
|
+
import { alternateLinks, localizeHref } from "./locale-routing.js";
|
|
3
|
+
import { storefrontPath } from "./routes.js";
|
|
4
|
+
/** The most catalog pages `catalogPaths` reads: a sitemap holds 50,000 addresses at most. */
|
|
5
|
+
const MAX_CATALOG_PAGES = 500;
|
|
6
|
+
const escapeXml = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
7
|
+
/**
|
|
8
|
+
* The sitemap of `paths` — unprefixed page paths (`/products/robe`) — on
|
|
9
|
+
* `origin`, in every language `routing` publishes. A path given twice is
|
|
10
|
+
* listed once.
|
|
11
|
+
*/
|
|
12
|
+
export function sitemapXml(paths, routing, origin) {
|
|
13
|
+
const base = origin.replace(/\/+$/, "");
|
|
14
|
+
const multilingual = routing.published.length > 1;
|
|
15
|
+
const urls = [...new Set(paths)].flatMap((path) => {
|
|
16
|
+
const alternates = multilingual
|
|
17
|
+
? alternateLinks(path, routing, base).map((link) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(link.hreflang)}" href="${escapeXml(link.href)}"/>`)
|
|
18
|
+
: [];
|
|
19
|
+
return routing.published.map((locale) => [" <url>", ` <loc>${escapeXml(`${base}${localizeHref(path, locale, routing)}`)}</loc>`, ...alternates, " </url>"].join("\n"));
|
|
20
|
+
});
|
|
21
|
+
return [
|
|
22
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
23
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
|
24
|
+
...urls,
|
|
25
|
+
"</urlset>",
|
|
26
|
+
"",
|
|
27
|
+
].join("\n");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Every page of the store a sitemap lists, unprefixed: home, the catalog,
|
|
31
|
+
* each category, each product (the whole catalog, read page by page) and each
|
|
32
|
+
* published text page.
|
|
33
|
+
*/
|
|
34
|
+
export async function catalogPaths(client, config) {
|
|
35
|
+
const products = [];
|
|
36
|
+
const categories = client.categories.list();
|
|
37
|
+
for (let page = 0, offset = 0; page < MAX_CATALOG_PAGES; page++) {
|
|
38
|
+
const result = await client.products.page({ limit: PRODUCT_PAGE_MAX, offset });
|
|
39
|
+
products.push(...result.items.map((product) => storefrontPath("product", { handle: product.handle })));
|
|
40
|
+
if (!result.hasNext || result.items.length === 0)
|
|
41
|
+
break;
|
|
42
|
+
offset += result.items.length;
|
|
43
|
+
}
|
|
44
|
+
return [
|
|
45
|
+
storefrontPath("home"),
|
|
46
|
+
storefrontPath("catalog"),
|
|
47
|
+
...(await categories).map((category) => storefrontPath("category", { slug: category.slug })),
|
|
48
|
+
...products,
|
|
49
|
+
...config.pages.map((page) => storefrontPath("page", { slug: page.slug })),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which store a request renders, in which language — the rules every theme's
|
|
3
|
+
* middleware needs, decided once:
|
|
4
|
+
*
|
|
5
|
+
* - A page takes its language from its path (`/fr/products/robe`). The
|
|
6
|
+
* primary's own prefix redirects permanently to its one address (`/ar/x` →
|
|
7
|
+
* `/x`); a language the store stopped publishing redirects temporarily to the
|
|
8
|
+
* primary page, since it may publish it again.
|
|
9
|
+
* - An endpoint (`/api/…`) or a server island (`/_server-islands/…`) serves part
|
|
10
|
+
* of a page in a request with no language in its URL: it takes the language
|
|
11
|
+
* of the page that asked, from its same-origin `Referer`, and is never
|
|
12
|
+
* redirected or rewritten.
|
|
13
|
+
* - An editor's preview renders the store as the draft would leave it, in the
|
|
14
|
+
* language the draft asks for when the store serves it.
|
|
15
|
+
*
|
|
16
|
+
* The theme keeps what is its own: the client it creates (one per request),
|
|
17
|
+
* the config it falls back to when the store cannot be read, and the context
|
|
18
|
+
* it builds from what these return.
|
|
19
|
+
*/
|
|
20
|
+
import type { StoreClient } from "./client/index.js";
|
|
21
|
+
import { type LocaleRouting } from "./locale-routing.js";
|
|
22
|
+
import { type PreviewDraft } from "./preview.js";
|
|
23
|
+
import type { StoreConfig } from "./types.js";
|
|
24
|
+
export interface StoreRequestOptions {
|
|
25
|
+
/** The request's client in `locale`, or in the store's primary language when absent. */
|
|
26
|
+
client: (locale?: string) => StoreClient;
|
|
27
|
+
/** What renders when the store's config cannot be read: a shell with the checkout switched off. */
|
|
28
|
+
fallback: StoreConfig;
|
|
29
|
+
/** Whether a path serves part of a page rather than a page. Default: `/api/` and `/_server-islands/`. */
|
|
30
|
+
embedded?: (pathname: string) => boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The store a request renders. */
|
|
33
|
+
export interface ResolvedStore {
|
|
34
|
+
/** The client in this request's language; every read of the page goes through it. */
|
|
35
|
+
client: StoreClient;
|
|
36
|
+
config: StoreConfig;
|
|
37
|
+
/** The language this request renders in. */
|
|
38
|
+
locale: string;
|
|
39
|
+
/** The store's languages, as links need them. */
|
|
40
|
+
routing: LocaleRouting;
|
|
41
|
+
/** The path without its language prefix: the route the page renders. */
|
|
42
|
+
pathname: string;
|
|
43
|
+
}
|
|
44
|
+
export type StoreRequestResolution = ({
|
|
45
|
+
kind: "page";
|
|
46
|
+
/** An endpoint or server island: never redirected, never rewritten. */
|
|
47
|
+
embedded: boolean;
|
|
48
|
+
/** The path and query to render instead of the request's (`/fr/x` renders `/x`); null to render it as it is. */
|
|
49
|
+
rewrite: string | null;
|
|
50
|
+
} & ResolvedStore) | {
|
|
51
|
+
kind: "redirect";
|
|
52
|
+
/** Where the shopper goes, the request's query kept. */
|
|
53
|
+
location: string;
|
|
54
|
+
status: 301 | 302;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* The store a request renders and the language it renders in, or the redirect
|
|
58
|
+
* it answers:
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const request = await resolveStoreRequest(
|
|
62
|
+
* { url: context.url, referer: context.request.headers.get("Referer") },
|
|
63
|
+
* { client: createStore, fallback: FALLBACK_CONFIG },
|
|
64
|
+
* );
|
|
65
|
+
* if (request.kind === "redirect") return context.redirect(request.location, request.status);
|
|
66
|
+
* context.locals.store = buildContext(request);
|
|
67
|
+
* return request.rewrite ? next(request.rewrite) : next();
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function resolveStoreRequest(request: {
|
|
71
|
+
url: URL;
|
|
72
|
+
referer: string | null;
|
|
73
|
+
}, options: StoreRequestOptions): Promise<StoreRequestResolution>;
|
|
74
|
+
/**
|
|
75
|
+
* The store an editor's draft previews: its theme settings and text edits
|
|
76
|
+
* over the saved ones, conversion tracking off, in the language the draft
|
|
77
|
+
* asks for when the store serves it — else the primary. `pathname` is the
|
|
78
|
+
* route the draft previews (`draft.path`), else the home page.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolvePreviewStore(draft: PreviewDraft, options: Omit<StoreRequestOptions, "embedded">): Promise<ResolvedStore>;
|
|
81
|
+
/** What `notFound` uses of a page's `Astro` global. */
|
|
82
|
+
export interface NotFoundContext {
|
|
83
|
+
cache?: {
|
|
84
|
+
set(options: false): void;
|
|
85
|
+
};
|
|
86
|
+
response: {
|
|
87
|
+
status: number;
|
|
88
|
+
};
|
|
89
|
+
rewrite(path: string): Promise<Response>;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Answers a page whose resource does not exist — `return notFound(Astro)` —
|
|
93
|
+
* with the theme's not-found page (`/404`) and status 404, and never caches
|
|
94
|
+
* it: a product published a minute later must show, not the cached refusal.
|
|
95
|
+
*/
|
|
96
|
+
export declare function notFound(context: NotFoundContext, page?: string): Promise<Response>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { localeRouting, resolveLocalePath } from "./locale-routing.js";
|
|
2
|
+
import { previewConfig } from "./preview.js";
|
|
3
|
+
const defaultEmbedded = (pathname) => pathname.startsWith("/api/") || pathname.startsWith("/_server-islands/");
|
|
4
|
+
async function primaryStore(options) {
|
|
5
|
+
const client = options.client();
|
|
6
|
+
const config = (await client.config.get()) ?? options.fallback;
|
|
7
|
+
return { client, config, routing: localeRouting(config) };
|
|
8
|
+
}
|
|
9
|
+
async function resolvePath(path, primary, options) {
|
|
10
|
+
const { routing } = primary;
|
|
11
|
+
const resolved = resolveLocalePath(path, routing);
|
|
12
|
+
if (resolved.kind === "redirect")
|
|
13
|
+
return { kind: "redirect", location: resolved.location, status: 301 };
|
|
14
|
+
if (resolved.locale === routing.primary)
|
|
15
|
+
return { kind: "page", client: primary.client, config: primary.config, locale: resolved.locale, routing, pathname: resolved.pathname };
|
|
16
|
+
const client = options.client(resolved.locale);
|
|
17
|
+
const config = (await client.config.get()) ?? primary.config;
|
|
18
|
+
if (config.locale !== resolved.locale)
|
|
19
|
+
return { kind: "redirect", location: resolved.pathname, status: 302 };
|
|
20
|
+
return { kind: "page", client, config, locale: resolved.locale, routing, pathname: resolved.pathname };
|
|
21
|
+
}
|
|
22
|
+
/** The page an embedded request serves part of: its same-origin `Referer`'s path, else the home page. */
|
|
23
|
+
function askingPage(url, referer) {
|
|
24
|
+
if (referer === null)
|
|
25
|
+
return "/";
|
|
26
|
+
try {
|
|
27
|
+
const page = new URL(referer);
|
|
28
|
+
return page.origin === url.origin ? page.pathname : "/";
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return "/";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The store a request renders and the language it renders in, or the redirect
|
|
36
|
+
* it answers:
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* const request = await resolveStoreRequest(
|
|
40
|
+
* { url: context.url, referer: context.request.headers.get("Referer") },
|
|
41
|
+
* { client: createStore, fallback: FALLBACK_CONFIG },
|
|
42
|
+
* );
|
|
43
|
+
* if (request.kind === "redirect") return context.redirect(request.location, request.status);
|
|
44
|
+
* context.locals.store = buildContext(request);
|
|
45
|
+
* return request.rewrite ? next(request.rewrite) : next();
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveStoreRequest(request, options) {
|
|
49
|
+
const { url } = request;
|
|
50
|
+
const embedded = (options.embedded ?? defaultEmbedded)(url.pathname);
|
|
51
|
+
const primary = await primaryStore(options);
|
|
52
|
+
let resolved = await resolvePath(embedded ? askingPage(url, request.referer) : url.pathname, primary, options);
|
|
53
|
+
if (resolved.kind === "redirect") {
|
|
54
|
+
if (!embedded)
|
|
55
|
+
return { kind: "redirect", location: `${resolved.location}${url.search}`, status: resolved.status };
|
|
56
|
+
// The asking page's own address redirects: serve the part as its target would.
|
|
57
|
+
for (let hops = 0; resolved.kind === "redirect" && hops < 3; hops++)
|
|
58
|
+
resolved = await resolvePath(resolved.location, primary, options);
|
|
59
|
+
if (resolved.kind === "redirect")
|
|
60
|
+
resolved = { kind: "page", client: primary.client, config: primary.config, locale: primary.routing.primary, routing: primary.routing, pathname: "/" };
|
|
61
|
+
}
|
|
62
|
+
const { client, config, locale, routing, pathname } = resolved;
|
|
63
|
+
return {
|
|
64
|
+
kind: "page",
|
|
65
|
+
embedded,
|
|
66
|
+
rewrite: embedded || pathname === url.pathname ? null : `${pathname}${url.search}`,
|
|
67
|
+
client,
|
|
68
|
+
config,
|
|
69
|
+
locale,
|
|
70
|
+
routing,
|
|
71
|
+
pathname,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The store an editor's draft previews: its theme settings and text edits
|
|
76
|
+
* over the saved ones, conversion tracking off, in the language the draft
|
|
77
|
+
* asks for when the store serves it — else the primary. `pathname` is the
|
|
78
|
+
* route the draft previews (`draft.path`), else the home page.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolvePreviewStore(draft, options) {
|
|
81
|
+
const primary = await primaryStore(options);
|
|
82
|
+
const { routing } = primary;
|
|
83
|
+
const pathname = draft.path ?? "/";
|
|
84
|
+
const asked = draft.locale;
|
|
85
|
+
if (asked !== undefined && asked !== routing.primary && routing.published.includes(asked)) {
|
|
86
|
+
const client = options.client(asked);
|
|
87
|
+
const config = await client.config.get();
|
|
88
|
+
if (config !== null && config.locale === asked)
|
|
89
|
+
return { client, config: previewConfig(config, draft), locale: asked, routing, pathname };
|
|
90
|
+
}
|
|
91
|
+
return { client: primary.client, config: previewConfig(primary.config, draft), locale: routing.primary, routing, pathname };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Answers a page whose resource does not exist — `return notFound(Astro)` —
|
|
95
|
+
* with the theme's not-found page (`/404`) and status 404, and never caches
|
|
96
|
+
* it: a product published a minute later must show, not the cached refusal.
|
|
97
|
+
*/
|
|
98
|
+
export function notFound(context, page = "/404") {
|
|
99
|
+
context.cache?.set(false);
|
|
100
|
+
context.response.status = 404;
|
|
101
|
+
return context.rewrite(page);
|
|
102
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { COUPON_REFUSAL_REASONS } from "./coupon.js";
|
|
2
|
+
import type { PRODUCT_SORTS, SHOPPER_ORDER_STAGES } from "./catalog.js";
|
|
1
3
|
import { z } from "zod";
|
|
2
4
|
import { ProductSchema, ProductListSchema, CategorySchema, ProductImageSchema, ProductVariantSchema, OfferSchema, QuantityPricingSchema, PageDocumentPayloadSchema } from "./schemas.js";
|
|
3
5
|
export type Product = z.infer<typeof ProductSchema>;
|
|
@@ -221,6 +223,10 @@ export interface StoreCollection {
|
|
|
221
223
|
/** In the client's language when the store translated it, else the primary. */
|
|
222
224
|
title: string;
|
|
223
225
|
description: string | null;
|
|
226
|
+
/** How many products a shopper sees in it; absent from a server that predates it. */
|
|
227
|
+
productCount?: number | null | undefined;
|
|
228
|
+
/** Its first listed product's first photo, until a merchant can pick one; absent from a server that predates it. */
|
|
229
|
+
image?: ProductImage | null | undefined;
|
|
224
230
|
}
|
|
225
231
|
/** A navigation menu — GET /store/menus/{handle}. */
|
|
226
232
|
export interface StoreMenu {
|
|
@@ -366,7 +372,7 @@ export interface CartValidation {
|
|
|
366
372
|
* Why a code does not apply. Open: a server may name a reason this SDK has not
|
|
367
373
|
* seen yet, and a theme then shows its general sentence.
|
|
368
374
|
*/
|
|
369
|
-
export type CouponRefusalReason =
|
|
375
|
+
export type CouponRefusalReason = (typeof COUPON_REFUSAL_REASONS)[number] | (string & {});
|
|
370
376
|
export interface CouponRefusal {
|
|
371
377
|
reason: CouponRefusalReason;
|
|
372
378
|
/** The dinars (`below_minimum_subtotal`) or items (`below_minimum_quantity`) still missing. */
|
|
@@ -480,6 +486,39 @@ export interface OrderTracking {
|
|
|
480
486
|
pixelId: string | null;
|
|
481
487
|
event: "Purchase" | "Lead" | null;
|
|
482
488
|
}
|
|
489
|
+
/**
|
|
490
|
+
* Where an order is, in a shopper's words: `placed` (not confirmed yet),
|
|
491
|
+
* `confirmed`, `preparing`, `shipped`, `out_for_delivery`, `delivered`,
|
|
492
|
+
* `returned` (refused at the door) or `cancelled`. Open: a newer server may
|
|
493
|
+
* name another step, which a theme shows with its general words.
|
|
494
|
+
*/
|
|
495
|
+
export type ShopperOrderStage = (typeof SHOPPER_ORDER_STAGES)[number] | (string & {});
|
|
496
|
+
/** What `orders.status()` answers: what a shopper may know of their order, never its address or notes. */
|
|
497
|
+
export interface ShopperOrderStatus {
|
|
498
|
+
orderNumber: string;
|
|
499
|
+
/** When it was placed (ISO 8601). */
|
|
500
|
+
placedAt: string;
|
|
501
|
+
stage: ShopperOrderStage;
|
|
502
|
+
deliveryType: "home" | "stop_desk";
|
|
503
|
+
/** As sold, in the catalog's language. */
|
|
504
|
+
lines: {
|
|
505
|
+
name: string;
|
|
506
|
+
variantLabel: string | null;
|
|
507
|
+
quantity: number;
|
|
508
|
+
}[];
|
|
509
|
+
/** What the shopper pays on delivery. */
|
|
510
|
+
total: number;
|
|
511
|
+
/** The carrier's tracking number once the parcel is with it; null before, or with the store's own driver. */
|
|
512
|
+
tracking: {
|
|
513
|
+
number: string;
|
|
514
|
+
carrier: string | null;
|
|
515
|
+
} | null;
|
|
516
|
+
}
|
|
517
|
+
/** What `orders.status()` sends: the order's number and the phone it was placed with. */
|
|
518
|
+
export interface ShopperOrderProof {
|
|
519
|
+
orderNumber: string;
|
|
520
|
+
phone: string;
|
|
521
|
+
}
|
|
483
522
|
/** POST /store/otp/send success payload. */
|
|
484
523
|
export type OtpSendResult = {
|
|
485
524
|
status: "sent";
|
|
@@ -581,16 +620,25 @@ export interface ProductListFilters {
|
|
|
581
620
|
onSale?: boolean;
|
|
582
621
|
/** @deprecated Use `collection: "featured"`. */
|
|
583
622
|
featured?: boolean;
|
|
623
|
+
/**
|
|
624
|
+
* The order. Absent: the listing's own — a collection's, a search's best
|
|
625
|
+
* match first, else newest. A server that predates sorting ignores it.
|
|
626
|
+
*/
|
|
627
|
+
sort?: ProductSort;
|
|
584
628
|
/** Server-side cap; the server clamps anything larger. */
|
|
585
629
|
limit?: number;
|
|
586
630
|
/** Products to skip — with `limit`, pages through the whole catalog. */
|
|
587
631
|
offset?: number;
|
|
588
632
|
}
|
|
589
|
-
/**
|
|
633
|
+
/** How a listing can be ordered: `PRODUCT_SORTS`. */
|
|
634
|
+
export type ProductSort = (typeof PRODUCT_SORTS)[number];
|
|
635
|
+
/** One page of products from `products.page()` or `products.search()`. */
|
|
590
636
|
export interface ProductPage {
|
|
591
637
|
items: ProductList[];
|
|
592
638
|
/** True when another page follows this one. */
|
|
593
639
|
hasNext: boolean;
|
|
640
|
+
/** How many products the whole listing holds, across its pages; null from a server that predates counts. */
|
|
641
|
+
total: number | null;
|
|
594
642
|
}
|
|
595
643
|
/** `products.reviews.list()` paging — named for the same reason. */
|
|
596
644
|
export interface ReviewListPagination {
|
package/dist/utils/image.d.ts
CHANGED
|
@@ -65,6 +65,54 @@ export declare function mediaImageUrl(originalUrl: string, params: MediaImagePar
|
|
|
65
65
|
* makes an empty one), whitespace, control characters, a backslash, `?` or `#`.
|
|
66
66
|
*/
|
|
67
67
|
export declare function mediaUrl(key: string, mediaDomain?: string): string | null;
|
|
68
|
+
export interface MediaImageSrcsetOptions {
|
|
69
|
+
/** The store's media domain; without one there is no srcset. */
|
|
70
|
+
mediaDomain?: string;
|
|
71
|
+
/** Height ÷ width: given, each size is cropped to it (`fit=cover`); absent, the photo keeps its shape (`fit=scale-down`). */
|
|
72
|
+
ratio?: number;
|
|
73
|
+
/** Encoder quality, 1–100; Cloudflare's default when absent. */
|
|
74
|
+
quality?: number;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The same photo at several widths, with width descriptors
|
|
78
|
+
* (`… 360w, … 720w`), for an `<img>` whose `sizes` says how wide it shows —
|
|
79
|
+
* a phone then downloads a phone-sized file. Each size is converted to the
|
|
80
|
+
* best format the browser takes (`format=auto`).
|
|
81
|
+
*
|
|
82
|
+
* @returns `null` when there is no media domain, the URL is not on it, or no
|
|
83
|
+
* width is a positive whole number.
|
|
84
|
+
*/
|
|
85
|
+
export declare function mediaImageSrcset(src: string, widths: readonly number[], options?: MediaImageSrcsetOptions): string | null;
|
|
86
|
+
/** An image a merchant picked, by its media key — what a theme's `image` field resolves to. */
|
|
87
|
+
export interface PickedImage {
|
|
88
|
+
key: string;
|
|
89
|
+
alt?: string;
|
|
90
|
+
width?: number;
|
|
91
|
+
height?: number;
|
|
92
|
+
/** The point to keep in view when the photo is cropped, each axis 0–1. */
|
|
93
|
+
focal?: {
|
|
94
|
+
x: number;
|
|
95
|
+
y: number;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** A picked image ready for an `<img>`: its URL, its text alternative, its size, and where a crop centres. */
|
|
99
|
+
export interface Picture {
|
|
100
|
+
src: string;
|
|
101
|
+
alt: string;
|
|
102
|
+
width: number | undefined;
|
|
103
|
+
height: number | undefined;
|
|
104
|
+
/** CSS `object-position` from the focal point (`30% 70%`); undefined without one. */
|
|
105
|
+
position: string | undefined;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A picked image as a theme draws it: its URL on the store's media domain,
|
|
109
|
+
* its alt text (empty when the merchant wrote none), its size, and its focal
|
|
110
|
+
* point as `object-position`, so a cropped photo keeps what matters in view.
|
|
111
|
+
*
|
|
112
|
+
* @returns `null` when no image is picked, the key cannot name an object, or
|
|
113
|
+
* the store has no media domain.
|
|
114
|
+
*/
|
|
115
|
+
export declare function pictureOf(image: PickedImage | null | undefined, mediaDomain?: string): Picture | null;
|
|
68
116
|
/**
|
|
69
117
|
* Predefined image sizes for common use cases
|
|
70
118
|
*/
|