@wocart/storefront 3.0.0 → 3.1.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 CHANGED
@@ -5,6 +5,77 @@ follows [semantic versioning](https://semver.org/): breaking changes ship only
5
5
  in a new major version, and an export is marked `@deprecated` for at least one
6
6
  minor release before a major removes it.
7
7
 
8
+ ## 3.1.0 — 2026-11-03
9
+
10
+ The theme's request lifecycle, revalidation route, API paths and thank-you
11
+ address come from the SDK, and a landing page is single-language. Additions
12
+ and fixes only; requires platform v7.0.0, like 3.0.0.
13
+
14
+ ### Added
15
+
16
+ - `createStorefrontMiddleware(options)` in `@wocart/storefront/astro`: the
17
+ theme's whole `onRequest` in one call. A prerendered file and the
18
+ revalidation route pass through; a request that already has a store keeps
19
+ it; the editor's preview opens only for a verified token and never runs an
20
+ action; any other page gets its store and language, its redirect, its
21
+ form-called action and its unprefixed route. The preview key may be a
22
+ function, read on each request (a secret from `astro:env/server` can be
23
+ set after the module loads). Types `PreviewLocals`,
24
+ `StorefrontMiddleware`, `StorefrontMiddlewareContext`,
25
+ `StorefrontMiddlewareOptions`, `StorefrontActionContext`.
26
+ - `revalidateEndpoint({ publicKey, theme })` in `@wocart/storefront/astro`:
27
+ the theme's `/wocart-revalidate` route, `{ POST, ALL }`. `POST` drops every
28
+ page cached under `PAGES_CACHE_TAG` for a verified platform token (204) and
29
+ nothing otherwise; `ALL` answers 405. Types `RevalidateEndpointContext`,
30
+ `RevalidateEndpointOptions`.
31
+ - `STOREFRONT_API_PATHS` and `storefrontApiPath(name, params?)`: where a
32
+ theme mounts each of `createStorefrontRoutes`' routes and where its browser
33
+ scripts call them (`/api/communes/:wilayaId`, …), keyed like
34
+ `StorefrontRoutes` and checked against it. Defaults, not requirements:
35
+ every browser helper still takes its `endpoint`. Types `StorefrontApiName`,
36
+ `StorefrontApiParams`.
37
+ - `PAGES_CACHE_TAG` (`"pages"`) in `@wocart/storefront/server`: the cache
38
+ tag every cached page carries and a revalidation drops, spelled once.
39
+ - `thankYouHref(order, language?)` and `readThankYou(url)`: the thank-you
40
+ page's address for a placed order (`/thank-you?order=…&total=…&oid=…`, in
41
+ the request's language when given) and its reading back, so no theme spells
42
+ the query itself and an editor can preview the page with a sample order.
43
+ Type `ThankYouOrder`.
44
+ - `SINGLE_LANGUAGE_PAGE_TYPES` (`["landing"]`) and
45
+ `isSingleLanguagePage(pathname)`: the pages that exist in the store's
46
+ primary language only.
47
+
48
+ ### Fixed
49
+
50
+ - A page's `notFound()` kept the shopper's language only in themes that
51
+ remembered to keep the store on the rewrite to `/404`;
52
+ `createStorefrontMiddleware` always keeps it.
53
+ - A landing page is single-language: `resolveLocalePath` and
54
+ `resolveStoreRequest` send `/fr/lp/x?…` to `/lp/x?…` (301, the query kept,
55
+ the other language never read); `localizeHref` leaves a landing link
56
+ unprefixed; `alternateLinks` lists no alternates for it; an endpoint a
57
+ landing page asks for answers in the primary language.
58
+
59
+ ### Theme migration
60
+
61
+ - Replace the body of `src/middleware.ts` with `createStorefrontMiddleware`,
62
+ passing the client factory, the fallback config, the preview route and key
63
+ (`THEME_PREVIEW_PATH`, `() => WOCART_PREVIEW_KEY`, the theme's key), the
64
+ context builder and `getActionContext`. Delete any check of
65
+ `locals.store` on a rewrite and any landing-page language redirect of the
66
+ theme's own.
67
+ - `src/pages/wocart-revalidate.ts` becomes
68
+ `export const { POST, ALL } = revalidateEndpoint({ publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY })`;
69
+ tag cached routes with `PAGES_CACHE_TAG` rather than the literal.
70
+ - A theme's own table of `/api/…` paths becomes `STOREFRONT_API_PATHS`;
71
+ a route with a parameter is called through `storefrontApiPath`.
72
+ - Replace a theme's own thank-you query helpers with `thankYouHref` and
73
+ `readThankYou`; the query keeps its names (`order`, `total`, `oid`), so
74
+ links already sent keep working. A total that is not a whole number reads
75
+ as `null`.
76
+ - A language menu built from `localizeHref` should list no languages on a
77
+ page where `isSingleLanguagePage(pathname)` is true.
78
+
8
79
  ## 3.0.0 — 2026-11-01
9
80
 
10
81
  The SDK reads exactly what platform v7.0.0 serves: `/store/config` and the
package/USAGE.md CHANGED
@@ -632,7 +632,14 @@ alternateLinks(path.pathname, routing, url.origin); // [{ hreflang: "ar",
632
632
  ~~~
633
633
 
634
634
  `localizeHref` leaves external URLs, fragments, endpoints, files and already
635
- prefixed links alone, so it is safe on any `href`. Never redirect a shopper by
635
+ prefixed links alone, so it is safe on any `href`.
636
+
637
+ A landing page (`/lp/:slug`) exists in the primary language only:
638
+ `SINGLE_LANGUAGE_PAGE_TYPES` names its page type and
639
+ `isSingleLanguagePage(pathname)` tells such a page from any other.
640
+ `resolveLocalePath` sends `/fr/lp/x` to `/lp/x`, `localizeHref` leaves a
641
+ landing link unprefixed, and `alternateLinks` lists nothing for it; a
642
+ language menu should list no languages there either. Never redirect a shopper by
636
643
  browser language: offer the other languages as links and let them choose.
637
644
  `products.list({ limit, offset })` pages through the whole catalog, for a
638
645
  sitemap.
@@ -1678,11 +1685,68 @@ The page reads the result with `Astro.getActionResult()`: on success it
1678
1685
  redirects to the thank-you page, and on a refusal it renders the form again
1679
1686
  with the error in place.
1680
1687
 
1688
+ The thank-you address is the SDK's: `thankYouHref(order, { locale, routing })`
1689
+ builds `/fr/thank-you?order=…&total=…&oid=…` from the placed order (its
1690
+ number, total and id, never the shopper's details), and the thank-you page
1691
+ reads it back with `readThankYou(Astro.url)`: `{ orderNumber, orderId, total }`,
1692
+ each empty or null when the address lacks it. An editor previews the page
1693
+ with a sample order by building the same address.
1694
+
1695
+ ~~~ts
1696
+ import { readThankYou, thankYouHref } from "@wocart/storefront";
1697
+
1698
+ if (result?.data?.orderNumber) return Astro.redirect(thankYouHref(result.data, { locale, routing }));
1699
+ const { orderNumber, total } = readThankYou(Astro.url); // on /thank-you
1700
+ ~~~
1701
+
1681
1702
  Never redirect from the middleware, on success or on error. Astro 7 keeps an
1682
1703
  action result for the response being rendered only, so a redirect arrives at
1683
1704
  the next page with no result: the thank-you redirect never fires, and a
1684
1705
  refused order shows no message.
1685
1706
 
1707
+ ### The whole middleware in one call
1708
+
1709
+ `createStorefrontMiddleware` is a theme's whole `onRequest`: the store and
1710
+ language of every request, its redirects, the editor's preview and the
1711
+ form-called action, in the order below.
1712
+
1713
+ ~~~ts
1714
+ // src/middleware.ts
1715
+ import { getActionContext } from "astro:actions";
1716
+ import { createStorefrontMiddleware } from "@wocart/storefront/astro";
1717
+ import { unavailableStoreConfig } from "@wocart/storefront";
1718
+ import { THEME_PREVIEW_PATH } from "@wocart/theme";
1719
+
1720
+ export const onRequest = createStorefrontMiddleware({
1721
+ client: createStore,
1722
+ fallback: unavailableStoreConfig({ serverUrl, locale: "ar" }),
1723
+ preview: { path: THEME_PREVIEW_PATH, publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY },
1724
+ context: (store) => buildContext(store),
1725
+ getActionContext,
1726
+ });
1727
+ ~~~
1728
+
1729
+ 1. A prerendered file and the revalidation route (`REVALIDATE_PATH`) pass
1730
+ through with no store.
1731
+ 2. A request that already has a store keeps it: `notFound()` rewrites to
1732
+ `/404`, Astro runs the middleware again, and the 404 stays in the
1733
+ shopper's language.
1734
+ 3. The preview route reads the draft with `readPreviewRequest`; a refused
1735
+ token answers its status with `Cache-Control: no-store`. A verified one
1736
+ sets `locals.store` from `resolvePreviewStore` and `locals.preview` to
1737
+ `{ claims, draft, token }`, and never runs an action.
1738
+ 4. Any other request resolves its store with `resolveStoreRequest`, answers
1739
+ its redirect, sets `locals.store` to the theme's `context(store)`, runs a
1740
+ form-called action with `runFormAction`, then renders the unprefixed
1741
+ route.
1742
+
1743
+ Pass the preview key as a function: a secret from `astro:env/server` can be
1744
+ set after the module loads, and the function reads it on each request.
1745
+ `astro:actions` exists only inside an Astro project, so the theme passes
1746
+ `getActionContext` in. A theme that needs a step of its own composes this
1747
+ handler with Astro's `sequence()`. The sections below are the parts it
1748
+ calls, for a theme that wires them itself.
1749
+
1686
1750
  ### The request's store and language
1687
1751
 
1688
1752
  `resolveStoreRequest` decides which store a request renders and in which
@@ -1691,7 +1755,9 @@ from its path (`/fr/products/robe`); the primary's own prefix redirects for
1691
1755
  good to its one address, and a language the store stopped publishing
1692
1756
  redirects for now to the primary page; an endpoint (`/api/…`) or a server
1693
1757
  island takes the language of the page that asked, from its same-origin
1694
- `Referer`, and is never redirected or rewritten. It answers data — the
1758
+ `Referer`, and is never redirected or rewritten; a landing page asked for
1759
+ under a language prefix redirects for good to its unprefixed address, the
1760
+ query kept. It answers data — the
1695
1761
  request's client, config, language, routing and unprefixed path — and the
1696
1762
  theme builds its own context from it:
1697
1763
 
@@ -1784,6 +1850,21 @@ export const POST = routes.otpSend;
1784
1850
  | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
1785
1851
  | `orderStatus` | POST | `{ orderNumber, phone }` | `{ data: ShopperOrderStatus }` |
1786
1852
 
1853
+ Mount each route at its path in `STOREFRONT_API_PATHS` (`src/pages/api/otp/send.ts`
1854
+ for `otpSend`, `src/pages/api/communes/[wilayaId].ts` for
1855
+ `/api/communes/:wilayaId`), and call it from the browser through the same
1856
+ table, so the files and the scripts never drift:
1857
+
1858
+ ~~~ts
1859
+ import { STOREFRONT_API_PATHS, createQuoter, storefrontApiPath } from "@wocart/storefront";
1860
+
1861
+ const quoter = createQuoter({ endpoint: STOREFRONT_API_PATHS.cartValidate });
1862
+ const communes = await fetch(storefrontApiPath("communes", { wilayaId: 16 })); // /api/communes/16
1863
+ ~~~
1864
+
1865
+ `orderStatus` has no path there: a theme follows an order with the
1866
+ `orderStatus` action.
1867
+
1787
1868
  What every route gets right:
1788
1869
 
1789
1870
  - every POST and PUT refuses a cross-origin request with 403;
@@ -2034,22 +2115,22 @@ editor — is `@wocart/theme`'s (its README, "Preview").
2034
2115
  ## Showing a publish at once (revalidation)
2035
2116
 
2036
2117
  A theme that caches its pages (route rules with `maxAge` and `tags`) shows a
2037
- merchant's publish when the cache expires. To show it at once, the theme
2038
- answers the platform's revalidation request and drops its pages:
2118
+ merchant's publish when the cache expires. To show it at once, every cached
2119
+ route carries `PAGES_CACHE_TAG` (`"pages"`, from `@wocart/storefront/server`)
2120
+ and the theme exports the SDK's revalidation route, which drops them:
2039
2121
 
2040
2122
  ```ts
2041
2123
  // src/pages/wocart-revalidate.ts — REVALIDATE_PATH
2042
- import type { APIRoute } from "astro";
2043
- import { readRevalidateRequest } from "@wocart/storefront/server";
2124
+ import { revalidateEndpoint } from "@wocart/storefront/astro";
2044
2125
  export const prerender = false;
2045
- export const POST: APIRoute = async (context) => {
2046
- const revalidation = await readRevalidateRequest(context.request, { publicKey: WOCART_PREVIEW_KEY, theme: THEME_KEY });
2047
- if (!revalidation.ok) return new Response(null, { status: revalidation.status });
2048
- await context.cache.invalidate({ tags: ["pages"] }); // the tag every cached page carries
2049
- return new Response(null, { status: 204 });
2050
- };
2126
+ export const { POST, ALL } = revalidateEndpoint({ publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY });
2051
2127
  ```
2052
2128
 
2129
+ `POST` answers 204 after `cache.invalidate({ tags: [PAGES_CACHE_TAG] })` for
2130
+ a verified token, and the token's refusal status otherwise, dropping
2131
+ nothing; `ALL` answers any other method 405. No answer is cached. A theme
2132
+ that wires it itself reads the request with `readRevalidateRequest`.
2133
+
2053
2134
  The request is a `POST` from the platform's server with
2054
2135
  `Authorization: Bearer <token>`: signed with the same key as previews, but
2055
2136
  made only for revalidation (`use: "revalidate"`), for this storefront's
package/dist/astro.d.ts CHANGED
@@ -10,3 +10,7 @@ export { lookupOrder, orderLookupInput } from "./order-lookup.js";
10
10
  export type { OrderLookup, OrderLookupInput } from "./order-lookup.js";
11
11
  export { notFound, resolvePreviewStore, resolveStoreRequest } from "./store-request.js";
12
12
  export type { NotFoundContext, ResolvedStore, StoreRequestOptions, StoreRequestResolution } from "./store-request.js";
13
+ export { createStorefrontMiddleware } from "./storefront-middleware.js";
14
+ export type { PreviewLocals, StorefrontActionContext, StorefrontMiddleware, StorefrontMiddlewareContext, StorefrontMiddlewareOptions, } from "./storefront-middleware.js";
15
+ export { revalidateEndpoint } from "./endpoints/revalidate-endpoint.js";
16
+ export type { RevalidateEndpointContext, RevalidateEndpointOptions } from "./endpoints/revalidate-endpoint.js";
package/dist/astro.js CHANGED
@@ -4,3 +4,5 @@ export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./
4
4
  export { runFormAction } from "./middleware.js";
5
5
  export { lookupOrder, orderLookupInput } from "./order-lookup.js";
6
6
  export { notFound, resolvePreviewStore, resolveStoreRequest } from "./store-request.js";
7
+ export { createStorefrontMiddleware } from "./storefront-middleware.js";
8
+ export { revalidateEndpoint } from "./endpoints/revalidate-endpoint.js";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The revalidation route a theme exports, ready-made:
3
+ *
4
+ * ```ts
5
+ * // src/pages/wocart-revalidate.ts
6
+ * import { revalidateEndpoint } from "@wocart/storefront/astro";
7
+ *
8
+ * export const prerender = false;
9
+ * export const { POST, ALL } = revalidateEndpoint({ publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY });
10
+ * ```
11
+ *
12
+ * `POST` verifies the platform's token (`readRevalidateRequest`), drops every
13
+ * page cached under `PAGES_CACHE_TAG` and answers 204; a refused token
14
+ * answers its status and drops nothing. `ALL` answers any other method 405.
15
+ * Neither answer is ever cached.
16
+ */
17
+ import { type RevalidateRequestOptions } from "../revalidate.js";
18
+ /** What the route uses of Astro's `APIContext`. */
19
+ export interface RevalidateEndpointContext {
20
+ request: Request;
21
+ cache: {
22
+ invalidate(options: {
23
+ tags: string[];
24
+ }): Promise<void>;
25
+ };
26
+ }
27
+ export interface RevalidateEndpointOptions {
28
+ /** The deployment's preview public key; a function reads it on each request. Absent: every request answers 404. */
29
+ publicKey: RevalidateRequestOptions["publicKey"] | (() => RevalidateRequestOptions["publicKey"]);
30
+ /** This theme's key. */
31
+ theme: string;
32
+ }
33
+ /** The theme's `/wocart-revalidate` route: `POST` drops the cached pages for a verified token, `ALL` answers 405. */
34
+ export declare function revalidateEndpoint(options: RevalidateEndpointOptions): {
35
+ POST: (context: RevalidateEndpointContext) => Promise<Response>;
36
+ ALL: () => Response;
37
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The revalidation route a theme exports, ready-made:
3
+ *
4
+ * ```ts
5
+ * // src/pages/wocart-revalidate.ts
6
+ * import { revalidateEndpoint } from "@wocart/storefront/astro";
7
+ *
8
+ * export const prerender = false;
9
+ * export const { POST, ALL } = revalidateEndpoint({ publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY });
10
+ * ```
11
+ *
12
+ * `POST` verifies the platform's token (`readRevalidateRequest`), drops every
13
+ * page cached under `PAGES_CACHE_TAG` and answers 204; a refused token
14
+ * answers its status and drops nothing. `ALL` answers any other method 405.
15
+ * Neither answer is ever cached.
16
+ */
17
+ import { PAGES_CACHE_TAG, readRevalidateRequest } from "../revalidate.js";
18
+ const noStore = (status, headers = {}) => new Response(null, { status, headers: { "Cache-Control": "no-store", ...headers } });
19
+ /** The theme's `/wocart-revalidate` route: `POST` drops the cached pages for a verified token, `ALL` answers 405. */
20
+ export function revalidateEndpoint(options) {
21
+ return {
22
+ POST: async (context) => {
23
+ const publicKey = typeof options.publicKey === "function" ? options.publicKey() : options.publicKey;
24
+ const revalidation = await readRevalidateRequest(context.request, { publicKey, theme: options.theme });
25
+ if (!revalidation.ok)
26
+ return noStore(revalidation.status);
27
+ await context.cache.invalidate({ tags: [PAGES_CACHE_TAG] });
28
+ return noStore(204);
29
+ },
30
+ ALL: () => noStore(405, { Allow: "POST" }),
31
+ };
32
+ }
package/dist/index.d.ts CHANGED
@@ -17,13 +17,13 @@ export { productImageSource } from "./utils/product-image.js";
17
17
  export type { ProductImageSourceInput } from "./utils/product-image.js";
18
18
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, whatsAppContact } from "./whatsapp.js";
19
19
  export type { PrefillToken, WhatsAppContact, WhatsAppPage, WhatsAppWords } from "./whatsapp.js";
20
- export { STOREFRONT_PAGE_PATHS, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
20
+ export { SINGLE_LANGUAGE_PAGE_TYPES, STOREFRONT_API_PATHS, STOREFRONT_PAGE_PATHS, isSingleLanguagePage, matchStorefrontPath, showsWidgetOn, storefrontApiPath, storefrontPath } from "./routes.js";
21
21
  export { servedLanguage } from "./locales.js";
22
22
  export { unavailableStoreConfig } from "./unavailable-store.js";
23
23
  export type { UnavailableStoreOptions } from "./unavailable-store.js";
24
24
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
25
25
  export type { LocalePath, LocaleRouting } from "./locale-routing.js";
26
- export type { StorefrontPageMatch, StorefrontPageType, StorefrontPathParams } from "./routes.js";
26
+ export type { StorefrontApiName, StorefrontApiParams, StorefrontPageMatch, StorefrontPageType, StorefrontPathParams } from "./routes.js";
27
27
  export { addedLines, createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
28
28
  export { MAX_BASKET_LINES, MAX_BASKET_LINE_QUANTITY, basketLineKey } from "./basket-line.js";
29
29
  export type { BasketStore, BasketStorageAdapter, BasketStoreOptions, BasketListener, BasketSelectionLine, } from "./basket.js";
@@ -38,6 +38,8 @@ export { readCartQuote, readCouponOutcome, readShippingRates } from "./answer-re
38
38
  export { createQuoter } from "./quote.js";
39
39
  export type { Quoter, QuoterOptions, QuoteResult } from "./quote.js";
40
40
  export { isOrderNumber, ORDER_NUMBER_PATTERN } from "./order-number.js";
41
+ export { readThankYou, thankYouHref } from "./thank-you.js";
42
+ export type { ThankYouOrder } from "./thank-you.js";
41
43
  export { DEFAULT_COLLECTION_HANDLES, PRODUCT_SORTS, SHOPPER_ORDER_JOURNEY, SHOPPER_ORDER_STAGES } from "./catalog.js";
42
44
  export { SEO_TEXT_LIMITS, canonicalUrl, jsonLd, productMeta, productStructuredData } from "./seo.js";
43
45
  export type { CanonicalPage, PageMeta, ProductStructuredDataOptions } from "./seo.js";
package/dist/index.js CHANGED
@@ -13,7 +13,7 @@ export { formatMoney } from "./utils/money.js";
13
13
  export { isMediaKey, mediaImageSrcset, mediaImageUrl, mediaUrl, pictureOf } from "./utils/image.js";
14
14
  export { productImageSource } from "./utils/product-image.js";
15
15
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, whatsAppContact } from "./whatsapp.js";
16
- export { STOREFRONT_PAGE_PATHS, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
16
+ export { SINGLE_LANGUAGE_PAGE_TYPES, STOREFRONT_API_PATHS, STOREFRONT_PAGE_PATHS, isSingleLanguagePage, matchStorefrontPath, showsWidgetOn, storefrontApiPath, storefrontPath } from "./routes.js";
17
17
  export { servedLanguage } from "./locales.js";
18
18
  export { unavailableStoreConfig } from "./unavailable-store.js";
19
19
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
@@ -27,6 +27,7 @@ export { isWilayaId } from "./wilaya-id.js";
27
27
  export { readCartQuote, readCouponOutcome, readShippingRates } from "./answer-readers.js";
28
28
  export { createQuoter } from "./quote.js";
29
29
  export { isOrderNumber, ORDER_NUMBER_PATTERN } from "./order-number.js";
30
+ export { readThankYou, thankYouHref } from "./thank-you.js";
30
31
  export { DEFAULT_COLLECTION_HANDLES, PRODUCT_SORTS, SHOPPER_ORDER_JOURNEY, SHOPPER_ORDER_STAGES } from "./catalog.js";
31
32
  export { SEO_TEXT_LIMITS, canonicalUrl, jsonLd, productMeta, productStructuredData } from "./seo.js";
32
33
  export { REVIEW_REFUSAL_CODES, REVIEW_TEXT_LIMITS } from "./reviews.js";
@@ -1,16 +1,3 @@
1
- /**
2
- * Language-prefixed storefront URLs — Shopify's
3
- * subfolders: the primary language keeps today's URLs (`/products/x`), every
4
- * other published language lives under its code (`/fr/products/x`), and the
5
- * primary has exactly one address (`/ar/products/x` redirects to
6
- * `/products/x`).
7
- *
8
- * Pure functions over the store's own languages (`StoreConfig.lang` and
9
- * `StoreConfig.locales`): a theme reads the request's language with
10
- * `resolveLocalePath`, builds every internal link with `localizeHref`, and
11
- * lists a page's language versions with `alternateLinks` — the counterpart of
12
- * Shopify's `request.locale.root_url` and `routes`.
13
- */
14
1
  import type { StoreConfig } from "./types.js";
15
2
  /** The store's languages as URLs need them. */
16
3
  export interface LocaleRouting {
@@ -27,7 +14,7 @@ export type LocalePath =
27
14
  locale: string;
28
15
  pathname: string;
29
16
  }
30
- /** The primary under a prefix: send the shopper to its one address. */
17
+ /** The primary under a prefix, or a single-language page under any: send the shopper to its one address. */
31
18
  | {
32
19
  kind: "redirect";
33
20
  location: string;
@@ -35,23 +22,27 @@ export type LocalePath =
35
22
  /**
36
23
  * Which language a request is for, and the path it renders. A first segment
37
24
  * that is a published language other than the primary selects it; the
38
- * primary's own prefix redirects; anything else — an unpublished or unknown
39
- * code included — is an ordinary path in the primary language, so it renders
40
- * or 404s exactly as before.
25
+ * primary's own prefix redirects, and so does any prefix on a page that
26
+ * exists in the primary language only (`isSingleLanguagePage`: `/fr/lp/x` →
27
+ * `/lp/x`); anything else — an unpublished or unknown code included — is an
28
+ * ordinary path in the primary language, so it renders or 404s exactly as
29
+ * before.
41
30
  */
42
31
  export declare function resolveLocalePath(pathname: string, routing: LocaleRouting): LocalePath;
43
32
  /**
44
33
  * An internal link in `locale`: `/products/x` → `/fr/products/x`, `/` → `/fr`.
45
34
  * Left unchanged: the primary language, external and protocol-relative URLs,
46
- * fragments, `mailto:`/`tel:`, endpoints and files, and a link that already
47
- * carries a language prefix. Query strings and fragments are kept.
35
+ * fragments, `mailto:`/`tel:`, endpoints and files, a page that exists in the
36
+ * primary language only (a landing page), and a link that already carries a
37
+ * language prefix. Query strings and fragments are kept.
48
38
  */
49
39
  export declare function localizeHref(href: string, locale: string, routing: LocaleRouting): string;
50
40
  /**
51
41
  * Every published language's URL for one page, primary first, plus
52
42
  * `x-default` (the primary) — the `<link rel="alternate" hreflang>` set.
53
43
  * `pathname` is the page's unprefixed path; `origin` makes the URLs absolute,
54
- * as search engines require.
44
+ * as search engines require. None for a page that exists in the primary
45
+ * language only.
55
46
  */
56
47
  export declare function alternateLinks(pathname: string, routing: LocaleRouting, origin: string): {
57
48
  hreflang: string;
@@ -1,3 +1,17 @@
1
+ /**
2
+ * Language-prefixed storefront URLs — Shopify's
3
+ * subfolders: the primary language keeps today's URLs (`/products/x`), every
4
+ * other published language lives under its code (`/fr/products/x`), and the
5
+ * primary has exactly one address (`/ar/products/x` redirects to
6
+ * `/products/x`).
7
+ *
8
+ * Pure functions over the store's own languages (`StoreConfig.lang` and
9
+ * `StoreConfig.locales`): a theme reads the request's language with
10
+ * `resolveLocalePath`, builds every internal link with `localizeHref`, and
11
+ * lists a page's language versions with `alternateLinks` — the counterpart of
12
+ * Shopify's `request.locale.root_url` and `routes`.
13
+ */
14
+ import { isSingleLanguagePage } from "./routes.js";
1
15
  export function localeRouting(config) {
2
16
  const published = config.locales.map((language) => language.code);
3
17
  return {
@@ -12,16 +26,18 @@ function isPagePath(pathname) {
12
26
  /**
13
27
  * Which language a request is for, and the path it renders. A first segment
14
28
  * that is a published language other than the primary selects it; the
15
- * primary's own prefix redirects; anything else — an unpublished or unknown
16
- * code included — is an ordinary path in the primary language, so it renders
17
- * or 404s exactly as before.
29
+ * primary's own prefix redirects, and so does any prefix on a page that
30
+ * exists in the primary language only (`isSingleLanguagePage`: `/fr/lp/x` →
31
+ * `/lp/x`); anything else — an unpublished or unknown code included — is an
32
+ * ordinary path in the primary language, so it renders or 404s exactly as
33
+ * before.
18
34
  */
19
35
  export function resolveLocalePath(pathname, routing) {
20
36
  const match = /^\/([^/]+)(\/.*)?$/.exec(pathname);
21
37
  const segment = match?.[1];
22
38
  if (segment !== undefined && routing.published.includes(segment)) {
23
39
  const rest = match?.[2] ?? "/";
24
- if (segment === routing.primary)
40
+ if (segment === routing.primary || isSingleLanguagePage(rest))
25
41
  return { kind: "redirect", location: rest };
26
42
  return { kind: "page", locale: segment, pathname: rest };
27
43
  }
@@ -30,8 +46,9 @@ export function resolveLocalePath(pathname, routing) {
30
46
  /**
31
47
  * An internal link in `locale`: `/products/x` → `/fr/products/x`, `/` → `/fr`.
32
48
  * Left unchanged: the primary language, external and protocol-relative URLs,
33
- * fragments, `mailto:`/`tel:`, endpoints and files, and a link that already
34
- * carries a language prefix. Query strings and fragments are kept.
49
+ * fragments, `mailto:`/`tel:`, endpoints and files, a page that exists in the
50
+ * primary language only (a landing page), and a link that already carries a
51
+ * language prefix. Query strings and fragments are kept.
35
52
  */
36
53
  export function localizeHref(href, locale, routing) {
37
54
  if (locale === routing.primary || !href.startsWith("/") || href.startsWith("//"))
@@ -39,7 +56,7 @@ export function localizeHref(href, locale, routing) {
39
56
  const split = /^([^?#]*)(.*)$/.exec(href);
40
57
  const pathname = split[1];
41
58
  const rest = split[2];
42
- if (!isPagePath(pathname))
59
+ if (!isPagePath(pathname) || isSingleLanguagePage(pathname))
43
60
  return href;
44
61
  const first = /^\/([^/]+)/.exec(pathname)?.[1];
45
62
  if (first !== undefined && routing.published.includes(first))
@@ -50,9 +67,12 @@ export function localizeHref(href, locale, routing) {
50
67
  * Every published language's URL for one page, primary first, plus
51
68
  * `x-default` (the primary) — the `<link rel="alternate" hreflang>` set.
52
69
  * `pathname` is the page's unprefixed path; `origin` makes the URLs absolute,
53
- * as search engines require.
70
+ * as search engines require. None for a page that exists in the primary
71
+ * language only.
54
72
  */
55
73
  export function alternateLinks(pathname, routing, origin) {
74
+ if (isSingleLanguagePage(pathname))
75
+ return [];
56
76
  const base = origin.replace(/\/+$/, "");
57
77
  const links = routing.published.map((locale) => ({
58
78
  hreflang: locale,
@@ -1,5 +1,11 @@
1
1
  /** Where a theme that can drop its cached pages answers a revalidation. */
2
2
  export declare const REVALIDATE_PATH = "/wocart-revalidate";
3
+ /**
4
+ * The cache tag every cached page carries, and the one a revalidation drops:
5
+ * a publish (pages, settings or text) can change any page, so a theme tags
6
+ * every cached route with it (`routeRules` in `astro.config.mjs`).
7
+ */
8
+ export declare const PAGES_CACHE_TAG = "pages";
3
9
  /** What a verified revalidation token says. */
4
10
  export interface RevalidateClaims {
5
11
  /** The storefront origin the token was made for. */
@@ -17,6 +17,12 @@
17
17
  import { timingRefusal, verifiedPayload } from "./signed-token.js";
18
18
  /** Where a theme that can drop its cached pages answers a revalidation. */
19
19
  export const REVALIDATE_PATH = "/wocart-revalidate";
20
+ /**
21
+ * The cache tag every cached page carries, and the one a revalidation drops:
22
+ * a publish (pages, settings or text) can change any page, so a theme tags
23
+ * every cached route with it (`routeRules` in `astro.config.mjs`).
24
+ */
25
+ export const PAGES_CACHE_TAG = "pages";
20
26
  /** The longest a revalidation token may live. */
21
27
  const MAX_LIFETIME_SECONDS = 5 * 60;
22
28
  const refuse = (status, reason) => ({ ok: false, status, reason });
package/dist/routes.d.ts CHANGED
@@ -1,19 +1,3 @@
1
- /**
2
- * The storefront's page types and their public paths.
3
- *
4
- * A page type is what a URL *means* — a product, a category, a store page, a
5
- * landing page — and the path is where it lives. The paths are a platform
6
- * contract, not a theme decision: the platform builds them without running
7
- * the theme (the dashboard shows a page's public link, the server names the
8
- * landing page in a conversion's source URL), so a theme renders each page
9
- * type it supports at its path and builds every link to one with
10
- * `storefrontPath` — never a hand-written string. Any other route is the
11
- * theme's own (`/about`, `/offers/summer` …): Astro's file routing owns those,
12
- * and nothing here constrains them.
13
- *
14
- * Language prefixes are applied on top: `localizeHref(storefrontPath(…),
15
- * locale, routing)`.
16
- */
17
1
  import type { WidgetSurfaces } from "./types.js";
18
2
  /** Each page type's public path; `:handle` / `:slug` mark its one parameter. */
19
3
  export declare const STOREFRONT_PAGE_PATHS: {
@@ -52,6 +36,39 @@ export interface StorefrontPageMatch {
52
36
  * not a path to render.
53
37
  */
54
38
  export declare function storefrontPath<T extends StorefrontPageType>(type: T, params?: StorefrontPathParams<T>): string;
39
+ /**
40
+ * Where a theme mounts each of `createStorefrontRoutes`' routes
41
+ * (`src/pages/api/…`), and where its browser scripts call them: one table,
42
+ * keyed like `StorefrontRoutes`, so the route files and the scripts never
43
+ * drift. Defaults, not requirements: every browser helper still takes its
44
+ * `endpoint`. `:wilayaId` and `:productId` mark a route's one parameter.
45
+ * `orderStatus` has no path here: a theme follows an order through its
46
+ * `orderStatus` action, not a route.
47
+ */
48
+ export declare const STOREFRONT_API_PATHS: {
49
+ readonly otpSend: "/api/otp/send";
50
+ readonly otpVerify: "/api/otp/verify";
51
+ readonly cartValidate: "/api/cart/validate";
52
+ readonly abandonedUpsert: "/api/abandoned/upsert";
53
+ readonly abandonedConvert: "/api/abandoned/convert";
54
+ readonly communes: "/api/communes/:wilayaId";
55
+ readonly reviewsList: "/api/reviews/:productId";
56
+ readonly reviewEligibility: "/api/reviews/eligibility";
57
+ readonly reviewUpload: "/api/reviews/upload";
58
+ readonly reviewSubmit: "/api/reviews/submit";
59
+ };
60
+ /** A storefront route of `createStorefrontRoutes`. */
61
+ export type StorefrontApiName = keyof typeof STOREFRONT_API_PATHS;
62
+ /** The parameter a route's path needs: `{ wilayaId }` for the communes, `{ productId }` for a product's reviews, nothing otherwise. */
63
+ export type StorefrontApiParams<N extends StorefrontApiName> = ParamOf<(typeof STOREFRONT_API_PATHS)[N]> extends never ? Record<string, never> : {
64
+ [K in ParamOf<(typeof STOREFRONT_API_PATHS)[N]>]: string | number;
65
+ };
66
+ /**
67
+ * A storefront route's same-origin path, its parameter encoded:
68
+ * `storefrontApiPath("communes", { wilayaId: 16 })` → `/api/communes/16`.
69
+ * Throws when the parameter is missing.
70
+ */
71
+ export declare function storefrontApiPath<N extends StorefrontApiName>(name: N, params?: StorefrontApiParams<N>): string;
55
72
  /**
56
73
  * Which page type a pathname is, and its parameter (decoded) — or null for
57
74
  * any other path. Exact matches only: `/products/` is the catalog,
@@ -60,6 +77,15 @@ export declare function storefrontPath<T extends StorefrontPageType>(type: T, pa
60
77
  * language prefix (`resolveLocalePath` gives it).
61
78
  */
62
79
  export declare function matchStorefrontPath(rawPathname: string): StorefrontPageMatch | null;
80
+ /**
81
+ * The page types that exist in the store's primary language only. A landing
82
+ * page is one campaign — a stack of images and one order form, written once
83
+ * for the ads that point at it — so it has no `/fr/lp/…` address, no language
84
+ * menu and no alternate links.
85
+ */
86
+ export declare const SINGLE_LANGUAGE_PAGE_TYPES: readonly StorefrontPageType[];
87
+ /** Whether a pathname (without its language prefix) is a page that exists in the primary language only. */
88
+ export declare function isSingleLanguagePage(rawPathname: string): boolean;
63
89
  /**
64
90
  * Whether the widget belongs on a pathname: the merchant answers "which
65
91
  * surfaces" (`surfaces[surface]`); this answers which surface a pathname *is*,
package/dist/routes.js CHANGED
@@ -23,13 +23,45 @@ export const STOREFRONT_PAGE_PATHS = {
23
23
  * not a path to render.
24
24
  */
25
25
  export function storefrontPath(type, params) {
26
- return STOREFRONT_PAGE_PATHS[type].replace(/:([a-z]+)/g, (_, name) => {
26
+ return fillPath(STOREFRONT_PAGE_PATHS[type], `storefrontPath("${type}")`, params);
27
+ }
28
+ function fillPath(template, caller, params) {
29
+ return template.replace(/:([a-zA-Z]+)/g, (_, name) => {
27
30
  const value = params?.[name];
28
- if (!value)
29
- throw new Error(`storefrontPath("${type}") needs "${name}"`);
30
- return encodeURIComponent(value);
31
+ if (value === undefined || value === "")
32
+ throw new Error(`${caller} needs "${name}"`);
33
+ return encodeURIComponent(String(value));
31
34
  });
32
35
  }
36
+ /**
37
+ * Where a theme mounts each of `createStorefrontRoutes`' routes
38
+ * (`src/pages/api/…`), and where its browser scripts call them: one table,
39
+ * keyed like `StorefrontRoutes`, so the route files and the scripts never
40
+ * drift. Defaults, not requirements: every browser helper still takes its
41
+ * `endpoint`. `:wilayaId` and `:productId` mark a route's one parameter.
42
+ * `orderStatus` has no path here: a theme follows an order through its
43
+ * `orderStatus` action, not a route.
44
+ */
45
+ export const STOREFRONT_API_PATHS = {
46
+ otpSend: "/api/otp/send",
47
+ otpVerify: "/api/otp/verify",
48
+ cartValidate: "/api/cart/validate",
49
+ abandonedUpsert: "/api/abandoned/upsert",
50
+ abandonedConvert: "/api/abandoned/convert",
51
+ communes: "/api/communes/:wilayaId",
52
+ reviewsList: "/api/reviews/:productId",
53
+ reviewEligibility: "/api/reviews/eligibility",
54
+ reviewUpload: "/api/reviews/upload",
55
+ reviewSubmit: "/api/reviews/submit",
56
+ };
57
+ /**
58
+ * A storefront route's same-origin path, its parameter encoded:
59
+ * `storefrontApiPath("communes", { wilayaId: 16 })` → `/api/communes/16`.
60
+ * Throws when the parameter is missing.
61
+ */
62
+ export function storefrontApiPath(name, params) {
63
+ return fillPath(STOREFRONT_API_PATHS[name], `storefrontApiPath("${name}")`, params);
64
+ }
33
65
  const MATCHERS = Object.entries(STOREFRONT_PAGE_PATHS).map(([type, path]) => {
34
66
  const names = [];
35
67
  const source = path.replace(/:([a-z]+)/g, (_, name) => {
@@ -60,6 +92,18 @@ export function matchStorefrontPath(rawPathname) {
60
92
  }
61
93
  return null;
62
94
  }
95
+ /**
96
+ * The page types that exist in the store's primary language only. A landing
97
+ * page is one campaign — a stack of images and one order form, written once
98
+ * for the ads that point at it — so it has no `/fr/lp/…` address, no language
99
+ * menu and no alternate links.
100
+ */
101
+ export const SINGLE_LANGUAGE_PAGE_TYPES = ["landing"];
102
+ /** Whether a pathname (without its language prefix) is a page that exists in the primary language only. */
103
+ export function isSingleLanguagePage(rawPathname) {
104
+ const match = matchStorefrontPath(rawPathname);
105
+ return match !== null && SINGLE_LANGUAGE_PAGE_TYPES.includes(match.type);
106
+ }
63
107
  /** The WhatsApp widget surface each page type belongs to (the home surface is the root). */
64
108
  const WIDGET_SURFACE = {
65
109
  home: "home",
package/dist/server.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { StoreClient } from "./client/index.js";
3
3
  export type { ClientResult } from "./client/core.js";
4
4
  export { StorefrontError, isStorefrontError } from "./client/errors.js";
5
5
  export { previewConfig, previewResponseHeaders, readPreviewRequest, verifyPreviewToken } from "./preview.js";
6
- export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
6
+ export { PAGES_CACHE_TAG, 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
9
  export { catalogPaths, sitemapXml } from "./sitemap.js";
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  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
- export { REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
4
+ export { PAGES_CACHE_TAG, REVALIDATE_PATH, readRevalidateRequest } from "./revalidate.js";
5
5
  export { catalogPaths, sitemapXml } from "./sitemap.js";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The request lifecycle every theme's `src/middleware.ts` runs, in one call:
3
+ *
4
+ * ```ts
5
+ * // src/middleware.ts
6
+ * import { getActionContext } from "astro:actions";
7
+ * import { createStorefrontMiddleware } from "@wocart/storefront/astro";
8
+ * import { THEME_PREVIEW_PATH } from "@wocart/theme";
9
+ *
10
+ * export const onRequest = createStorefrontMiddleware({
11
+ * client: createStore,
12
+ * fallback: unavailableStoreConfig({ serverUrl, locale: "ar" }),
13
+ * preview: { path: THEME_PREVIEW_PATH, publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY },
14
+ * context: (store) => buildStoreContext(store),
15
+ * getActionContext,
16
+ * });
17
+ * ```
18
+ *
19
+ * In order:
20
+ *
21
+ * - A prerendered file (the manifest, the theme's styles) and the
22
+ * revalidation route (`REVALIDATE_PATH`) need no store.
23
+ * - A request that already has a store keeps it: a page's `notFound()`
24
+ * rewrites to `/404` and Astro runs the middleware again, and the 404 must
25
+ * stay in the language the shopper asked for.
26
+ * - The editor's preview (`preview.path`) renders the store as the draft
27
+ * would leave it, after its token is verified; it never runs a form action.
28
+ * - Any other request resolves its store and language
29
+ * (`resolveStoreRequest`), answers its redirect if it has one, builds the
30
+ * theme's context, runs a form-called action for the page this POST renders
31
+ * (`runFormAction`), then renders the unprefixed route.
32
+ *
33
+ * `astro:actions` is an Astro virtual module, so the theme passes
34
+ * `getActionContext` in. The theme's context becomes `locals.store`; a
35
+ * verified preview's claims, draft and token become `locals.preview`.
36
+ */
37
+ import type { StoreClient } from "./client/index.js";
38
+ import { type ActionDescriptor, type ActionHandlerResult } from "./middleware.js";
39
+ import { type PreviewClaims, type PreviewDraft, type PreviewRequestOptions } from "./preview.js";
40
+ import { type ResolvedStore } from "./store-request.js";
41
+ import type { StoreConfig } from "./types.js";
42
+ /** What a verified preview leaves on `locals.preview`. */
43
+ export interface PreviewLocals {
44
+ claims: PreviewClaims;
45
+ draft: PreviewDraft;
46
+ token: string;
47
+ }
48
+ /** What the middleware uses of Astro's `APIContext`. */
49
+ export interface StorefrontMiddlewareContext {
50
+ isPrerendered: boolean;
51
+ url: URL;
52
+ request: Request;
53
+ /** `App.Locals`: the middleware writes `store` and, on a preview, `preview`. */
54
+ locals: object;
55
+ redirect(location: string, status: 301 | 302): Response;
56
+ }
57
+ /** What the middleware uses of `getActionContext(context)`. */
58
+ export interface StorefrontActionContext<TResult extends ActionHandlerResult, TSerialized> {
59
+ action?: ActionDescriptor<TResult> | undefined;
60
+ setActionResult: (name: string, serialized: TSerialized) => void;
61
+ serializeActionResult: (result: TResult) => TSerialized;
62
+ }
63
+ export interface StorefrontMiddlewareOptions<TContext extends StorefrontMiddlewareContext, TStore, TResult extends ActionHandlerResult, TSerialized> {
64
+ /** The theme's one client factory: in `locale`, or in the store's primary language when absent. */
65
+ client: (locale?: string) => StoreClient;
66
+ /** What renders when the store's config cannot be read: `unavailableStoreConfig(…)`. */
67
+ fallback: StoreConfig;
68
+ /**
69
+ * The editor's preview route (`THEME_PREVIEW_PATH`) and how its token is
70
+ * checked; no `publicKey`: the route answers 404. Pass the key as a
71
+ * function to read it on each request: a secret from `astro:env/server`
72
+ * can be set after the module loads.
73
+ */
74
+ preview: {
75
+ path: string;
76
+ publicKey: PreviewRequestOptions["publicKey"] | (() => PreviewRequestOptions["publicKey"]);
77
+ } & Pick<PreviewRequestOptions, "theme" | "maxBytes">;
78
+ /** The theme's context for a resolved store; it becomes `locals.store`. */
79
+ context: (store: ResolvedStore) => TStore;
80
+ /** Astro's `getActionContext`, from `astro:actions`. */
81
+ getActionContext: (context: TContext) => StorefrontActionContext<TResult, TSerialized>;
82
+ /** Whether a path serves part of a page rather than a page. Default: `/api/` and `/_server-islands/`. */
83
+ embedded?: (pathname: string) => boolean;
84
+ }
85
+ /** An Astro middleware handler (`onRequest`). */
86
+ export type StorefrontMiddleware<TContext extends StorefrontMiddlewareContext> = (context: TContext, next: (rewrite?: string) => Promise<Response>) => Promise<Response>;
87
+ /** The theme's `onRequest`: store, language, redirects, preview and form actions, as described above. */
88
+ export declare function createStorefrontMiddleware<TContext extends StorefrontMiddlewareContext, TStore, TResult extends ActionHandlerResult, TSerialized>(options: StorefrontMiddlewareOptions<TContext, TStore, TResult, TSerialized>): StorefrontMiddleware<TContext>;
@@ -0,0 +1,37 @@
1
+ import { runFormAction } from "./middleware.js";
2
+ import { readPreviewRequest } from "./preview.js";
3
+ import { REVALIDATE_PATH } from "./revalidate.js";
4
+ import { resolvePreviewStore, resolveStoreRequest } from "./store-request.js";
5
+ /** The theme's `onRequest`: store, language, redirects, preview and form actions, as described above. */
6
+ export function createStorefrontMiddleware(options) {
7
+ const store = { client: options.client, fallback: options.fallback, ...(options.embedded ? { embedded: options.embedded } : {}) };
8
+ const { path: previewPath, publicKey, ...previewOptions } = options.preview;
9
+ return async (context, next) => {
10
+ const locals = context.locals;
11
+ if (context.isPrerendered)
12
+ return next();
13
+ if (context.url.pathname === REVALIDATE_PATH)
14
+ return next();
15
+ if (locals.store !== undefined)
16
+ return next();
17
+ if (context.url.pathname === previewPath) {
18
+ const preview = await readPreviewRequest(context.request, { ...previewOptions, publicKey: typeof publicKey === "function" ? publicKey() : publicKey });
19
+ if (!preview.ok)
20
+ return new Response(null, { status: preview.status, headers: { "Cache-Control": "no-store" } });
21
+ locals.store = options.context(await resolvePreviewStore(preview.draft, store));
22
+ locals.preview = { claims: preview.claims, draft: preview.draft, token: preview.token };
23
+ return next();
24
+ }
25
+ const request = await resolveStoreRequest({ url: context.url, referer: context.request.headers.get("Referer") }, store);
26
+ if (request.kind === "redirect")
27
+ return context.redirect(request.location, request.status);
28
+ locals.store = options.context(request);
29
+ const { action, setActionResult, serializeActionResult } = options.getActionContext(context);
30
+ await runFormAction({
31
+ ...(action ? { action: { calledFrom: action.calledFrom, name: action.name, handler: action.handler } } : {}),
32
+ setActionResult,
33
+ serializeActionResult,
34
+ });
35
+ return request.rewrite === null ? next() : next(request.rewrite);
36
+ };
37
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The thank-you page's address for a placed order, and its reading back. The
3
+ * query carries the order's number, total and id — never the shopper's
4
+ * details — so the page can thank, show the total and fire a conversion
5
+ * without reading the order again. One spelling, here: a theme writes it
6
+ * after `placeOrder`, its thank-you page reads it, and an editor previews
7
+ * the page with a sample order by building the same address.
8
+ */
9
+ import { type LocaleRouting } from "./locale-routing.js";
10
+ /** What the thank-you page reads from its address. */
11
+ export interface ThankYouOrder {
12
+ /** The order number the shopper is given; empty when the address has none. */
13
+ orderNumber: string;
14
+ /** The order's id, for a conversion's deduplication; empty when the address has none. */
15
+ orderId: string;
16
+ /** The order's total in the store's currency; null when missing or not a number. */
17
+ total: number | null;
18
+ }
19
+ /**
20
+ * The thank-you page for a placed order: `/thank-you?order=…&total=…&oid=…`,
21
+ * in the request's language when `language` is given (`/fr/thank-you?…`).
22
+ */
23
+ export declare function thankYouHref(order: {
24
+ orderNumber: string;
25
+ total: number;
26
+ orderId?: string | null;
27
+ }, language?: {
28
+ locale: string;
29
+ routing: LocaleRouting;
30
+ }): string;
31
+ /** The order a thank-you address names: `thankYouHref`'s query, read back and trimmed. */
32
+ export declare function readThankYou(url: URL | URLSearchParams): ThankYouOrder;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The thank-you page's address for a placed order, and its reading back. The
3
+ * query carries the order's number, total and id — never the shopper's
4
+ * details — so the page can thank, show the total and fire a conversion
5
+ * without reading the order again. One spelling, here: a theme writes it
6
+ * after `placeOrder`, its thank-you page reads it, and an editor previews
7
+ * the page with a sample order by building the same address.
8
+ */
9
+ import { localizeHref } from "./locale-routing.js";
10
+ import { storefrontPath } from "./routes.js";
11
+ const ORDER_NUMBER = "order";
12
+ const TOTAL = "total";
13
+ const ORDER_ID = "oid";
14
+ /**
15
+ * The thank-you page for a placed order: `/thank-you?order=…&total=…&oid=…`,
16
+ * in the request's language when `language` is given (`/fr/thank-you?…`).
17
+ */
18
+ export function thankYouHref(order, language) {
19
+ const query = new URLSearchParams({ [ORDER_NUMBER]: order.orderNumber, [TOTAL]: String(order.total), [ORDER_ID]: order.orderId ?? "" });
20
+ const path = storefrontPath("thankYou");
21
+ return `${language ? localizeHref(path, language.locale, language.routing) : path}?${query.toString()}`;
22
+ }
23
+ /** The order a thank-you address names: `thankYouHref`'s query, read back and trimmed. */
24
+ export function readThankYou(url) {
25
+ const params = url instanceof URLSearchParams ? url : url.searchParams;
26
+ const raw = params.get(TOTAL)?.trim() ?? "";
27
+ const total = /^-?\d+$/.test(raw) ? Number.parseInt(raw, 10) : Number.NaN;
28
+ return {
29
+ orderNumber: (params.get(ORDER_NUMBER) ?? "").trim(),
30
+ orderId: (params.get(ORDER_ID) ?? "").trim(),
31
+ total: Number.isFinite(total) ? total : null,
32
+ };
33
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",