@wocart/storefront 2.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/USAGE.md +189 -95
  3. package/dist/answer-readers.js +16 -43
  4. package/dist/astro.d.ts +6 -2
  5. package/dist/astro.js +3 -1
  6. package/dist/client/index.d.ts +1 -1
  7. package/dist/client/index.js +1 -1
  8. package/dist/client/products.d.ts +2 -3
  9. package/dist/client/products.js +2 -3
  10. package/dist/endpoints/revalidate-endpoint.d.ts +37 -0
  11. package/dist/endpoints/revalidate-endpoint.js +32 -0
  12. package/dist/endpoints/storefront-routes.js +1 -1
  13. package/dist/index.d.ts +8 -4
  14. package/dist/index.js +4 -2
  15. package/dist/locale-routing.d.ts +11 -20
  16. package/dist/locale-routing.js +28 -8
  17. package/dist/middleware.d.ts +6 -26
  18. package/dist/middleware.js +0 -9
  19. package/dist/product-view.d.ts +0 -8
  20. package/dist/product-view.js +0 -17
  21. package/dist/revalidate.d.ts +6 -0
  22. package/dist/revalidate.js +6 -0
  23. package/dist/routes.d.ts +44 -27
  24. package/dist/routes.js +54 -13
  25. package/dist/schemas.d.ts +144 -222
  26. package/dist/schemas.js +77 -137
  27. package/dist/seo.js +2 -2
  28. package/dist/server.d.ts +1 -1
  29. package/dist/server.js +1 -1
  30. package/dist/store-request.d.ts +5 -4
  31. package/dist/store-request.js +1 -1
  32. package/dist/storefront-middleware.d.ts +88 -0
  33. package/dist/storefront-middleware.js +37 -0
  34. package/dist/thank-you.d.ts +32 -0
  35. package/dist/thank-you.js +33 -0
  36. package/dist/types.d.ts +76 -111
  37. package/dist/unavailable-store.d.ts +23 -0
  38. package/dist/unavailable-store.js +58 -0
  39. package/dist/variant-picker.d.ts +6 -2
  40. package/dist/variant-picker.js +6 -9
  41. package/dist/whatsapp.d.ts +59 -6
  42. package/dist/whatsapp.js +28 -10
  43. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,144 @@ 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
+
79
+ ## 3.0.0 — 2026-11-01
80
+
81
+ The SDK reads exactly what platform v7.0.0 serves: `/store/config` and the
82
+ product reads name every field they answer, and the SDK asks for no field the
83
+ server no longer sends nor tolerates an answer from an older server. The
84
+ WhatsApp button speaks every language, and a theme reaches it one way.
85
+ Requires platform v7.0.0.
86
+
87
+ ### Added
88
+
89
+ - `whatsAppContact(config, page, words)`: the store's WhatsApp on one page —
90
+ the `wa.me` link with the page's message, and every text resolved: the
91
+ merchant's in the page's language, else the theme's `words`; the agent the
92
+ merchant's, else the store's name. Null when the store has none. Every
93
+ WhatsApp link a theme draws comes from it. Types `WhatsAppContact`,
94
+ `WhatsAppPage`, `WhatsAppWords`.
95
+ - `unavailableStoreConfig({ serverUrl, locale })`: the store a page renders
96
+ when its config cannot be read — nothing to buy, its one language described
97
+ by the runtime. A theme passes it as `resolveStoreRequest`'s `fallback`
98
+ instead of writing its own.
99
+ - Page types `search` (`/search`) and `contact` (`/contact`); like
100
+ `trackOrder`, they show the widget by the App's `pages` switch.
101
+ - `StoreConfig.contact`: `{ phone, email, address }`, each null until the
102
+ merchant writes it.
103
+ - `CartValidation.freeDelivery.by`: why delivery is free — `offer`, `coupon`
104
+ or `threshold` — or null.
105
+
106
+ ### Changed (breaking)
107
+
108
+ - The WhatsApp widget's texts arrive in the page's language, or null where
109
+ the merchant wrote none in it — never another language's.
110
+ - `ProductList.variantOptions` and `ProductList.tags` arrive parsed, as on a
111
+ product page.
112
+ - `Product.variants` holds only the active variants, in the merchant's
113
+ order: `ProductVariant` drops `productId`, `currency`, `barcode`,
114
+ `weightKg`, `active`, `position`, `createdAt` and `updatedAt`, and the
115
+ variant picker takes the variants as they come.
116
+ - Required, where they were optional for older servers:
117
+ `StoreConfig.themeSettings`, `ProductPage.total` (0 on a failed read, never
118
+ null), `StoreCollection.productCount` and `image`, `Offer.rewardUnitPrice`,
119
+ `Product.quantityPricing`, the product `metaTitle` and `metaDescription`,
120
+ `LandingPage.document`, `Review.verified` and `media`,
121
+ `ReviewSubmitResult.visibility`,
122
+ `CreatedOrder.couponDiscount`, a cart quote's `coupon`, a line's `offerId`,
123
+ `offerName` and `discount`, and a gift's `offerName`, `variantId` and
124
+ `variantLabel`. `ReviewInput.phone` is required.
125
+
126
+ ### Removed
127
+
128
+ - `StoreConfig.primaryColor`, `accentColor`, `bgColor`, `fontFamily`,
129
+ `fontUrl` and `announcementBar` (a theme's settings now), `themeId`,
130
+ `cartShippingMode`, `status`, `createdAt`, `updatedAt`, and `legalContact`
131
+ (`contact` replaces it; the delivery days stay on the store's legal
132
+ pages).
133
+ - `Product.costPrice`, `storeFeatured`, `deletedAt`, `visibility`, `status`,
134
+ `showInStore`, `publishedAt`, `createdAt`, `updatedAt` and
135
+ `shippingProfileId`, and `ProductListFilters.featured` (use
136
+ `collection: "featured"`).
137
+ - `localWhatsAppWidget`, `resolvePrefillText`, `buildWhatsAppHref`,
138
+ `stripTrackingParams`, `truncateAtWordBoundary` and `ResolvePrefillParams`:
139
+ `whatsAppContact` applies the message rules itself. Removed without a
140
+ deprecated minor first: the platform they served changes with this major.
141
+ - `productTags`: both product shapes carry their tags as a list.
142
+ - `STOREFRONT_ROUTES` and `StorefrontRoute` (use `STOREFRONT_PAGE_PATHS`),
143
+ `resolveActionRedirectDecision` and `PrgDecision` (use `runFormAction`), and
144
+ `ActionMiddlewareDeps.referer` and `pathname`.
145
+
8
146
  ## 2.2.0 — 2026-11-01
9
147
 
10
148
  ### Added
package/USAGE.md CHANGED
@@ -112,6 +112,8 @@ break those links.
112
112
  | `checkout` | `/checkout` | the cart's checkout | required when the store turns the cart on |
113
113
  | `thankYou` | `/thank-you` | the order confirmation | required |
114
114
  | `trackOrder` | `/track-order` | where a shopper follows an order by its number and phone; `?order=<number>` fills the number in | required |
115
+ | `search` | `/search` | the products a shopper's words find; `?q=<words>` is the query | required — a menu can open it |
116
+ | `contact` | `/contact` | how to reach the store: its WhatsApp (`whatsAppContact`) and `config.contact` | required — a menu can open it |
115
117
 
116
118
  How a theme implements them, in Astro's own terms:
117
119
 
@@ -139,8 +141,8 @@ How a theme implements them, in Astro's own terms:
139
141
  `showsWidgetOn(surfaces, pathname)` is the platform's half of "should the
140
142
  WhatsApp widget show here": the merchant switches surfaces on; the page type
141
143
  says which surface a pathname is. `STOREFRONT_PAGE_PATHS` is the table above
142
- as data. `STOREFRONT_ROUTES` is deprecated: it lists six of the paths, without
143
- the home and the catalog.
144
+ as data. Order tracking, search and contact count as store pages: the App's
145
+ `pages` switch shows the widget on them.
144
146
 
145
147
  ## The five rules to remember
146
148
 
@@ -181,7 +183,7 @@ request-scoped client is the simplest safe default:
181
183
  ~~~ts
182
184
  const [config, featuredProducts] = await Promise.all([
183
185
  store.config.get(),
184
- store.products.list({ featured: true, limit: 8 }),
186
+ store.products.list({ collection: "featured", limit: 8 }),
185
187
  ]);
186
188
  ~~~
187
189
 
@@ -223,9 +225,10 @@ server directly.
223
225
  | `isWilayaId` | Whether a value is a wilaya id — the range `WilayaIdSchema` checks on the server, without a schema in the browser |
224
226
  | `readShippingRates`, `readCouponOutcome` | Read in the browser what the server handed it — the rates a page carries, a quote's coupon outcome — accepting exactly what `ShippingRatesSchema` / `CouponOutcomeSchema` accept, without Zod |
225
227
  | `normalizeAlgerianPhone`, `toLocalAlgerianMobile` | The one Algerian phone rule (accept/format) |
226
- | `localWhatsAppWidget`, `resolvePrefillText`, `buildWhatsAppHref`, `stripTrackingParams`, `truncateAtWordBoundary`, `PREFILL_TOKENS`, `MAX_RESOLVED_PREFILL_LENGTH` | the widget in the page's language, WhatsApp prefill text and the `wa.me` link |
228
+ | `whatsAppContact`, `PREFILL_TOKENS`, `MAX_RESOLVED_PREFILL_LENGTH` | The store's WhatsApp on one page: the `wa.me` link with the page's message and every text in the page's language — the only way a theme links to WhatsApp |
227
229
  | `storefrontPath`, `matchStorefrontPath`, `STOREFRONT_PAGE_PATHS` | The page types and their public paths: build a link, recognise a page |
228
- | `showsWidgetOn`, `STOREFRONT_ROUTES` | Which widget surface a pathname is (`STOREFRONT_ROUTES` deprecated) |
230
+ | `showsWidgetOn` | Which widget surface a pathname is |
231
+ | `unavailableStoreConfig` | The store a page renders when its config cannot be read — `resolveStoreRequest`'s `fallback` |
229
232
  | `productImageSource` | Which photo represents a product (cover → gallery fallback) |
230
233
  | `getProductView`, `getProductListView` | Pure price, discount, default-variant, and stock values for detail and list projections |
231
234
  | `getOfferTierView` | Pure offer-tier price, compare-price, savings, and free-shipping projection; display-only, never order authority |
@@ -372,8 +375,9 @@ Read methods use safe empty results so a theme can still render its shell:
372
375
  | orders.tracking(orderId) | null |
373
376
 
374
377
  These results do not mean the theme should crash. They mean the theme should
375
- show a deliberate fallback: a default brand shell, an empty catalog state, a
376
- not-found page, a delivery-rate fallback, or no conversion event.
378
+ show a deliberate fallback: the unavailable store (`unavailableStoreConfig`),
379
+ an empty catalog state, a not-found page, a delivery-rate fallback, or no
380
+ conversion event.
377
381
 
378
382
  ### When a read degrades: `onError`
379
383
 
@@ -515,29 +519,29 @@ const cartEnabled = config?.cartEnabled === true;
515
519
  const currencySymbol = config?.currencySymbol ?? "دج";
516
520
  ~~~
517
521
 
518
- StoreConfig contains the current store identity and presentation fields,
519
- including:
522
+ StoreConfig holds what a theme reads of the store, every merchant text in
523
+ the page's language (`locale`):
520
524
 
521
- - name, domain, logo (a MediaImage) and themeId (the colors, fontFamily and fontUrl
522
- fields are deprecated: colors and fonts are theme settings, read from
523
- themeSettings);
524
- - lang (the primary language), locales, contentOverrides, themeSettings,
525
- currency, currencySymbol, metaTitle, metaDescription, and ogImage (a
526
- MediaImage);
525
+ - id, name, domain and logo (a MediaImage);
526
+ - lang (the primary language), locale, locales, currency, currencySymbol,
527
+ metaTitle, metaDescription, and ogImage (a MediaImage);
527
528
  - media, the store's MediaDelivery, which every image helper takes;
528
- - reviewsEnabled (announcementBar is deprecated and always null: an
529
- announcement is a theme's own setting);
530
- - cartEnabled, freeShippingThreshold, and cartShippingMode;
529
+ - reviewsEnabled, cartEnabled and freeShippingThreshold;
530
+ - contentOverrides and themeSettings, the merchant's edits to your theme;
531
531
  - checkoutForm policy;
532
- - OTP and Turnstile settings;
532
+ - whatsapp, the WhatsApp button App — read it through `whatsAppContact`;
533
533
  - pixelId and conversionEvent;
534
- - public page links, legal contact information, and WhatsApp widget settings.
534
+ - pages (the footer's page links) and contact (`{ phone, email, address }`,
535
+ each null until the merchant writes it);
536
+ - OTP and Turnstile settings.
537
+
538
+ A store's colours, fonts and announcement are your theme's settings, never
539
+ config fields: declare them with `defineTheme({ settings })`.
535
540
 
536
541
  `themeSettings` holds the merchant's values for your theme's settings (the
537
542
  ones it declares with `defineTheme({ settings })` in `@wocart/theme`), by
538
543
  setting key, exactly as saved. It is sent only to a client created with
539
- `theme`, is `{}` when the store saved none, and is absent from a server that
540
- predates theme settings — `resolveThemeSettings` takes either. Never read it raw: resolve it
544
+ `theme`, and is `{}` when the store saved none. Never read it raw: resolve it
541
545
  against your declaration, which checks every value and falls back to your
542
546
  default for one it no longer accepts:
543
547
 
@@ -628,7 +632,14 @@ alternateLinks(path.pathname, routing, url.origin); // [{ hreflang: "ar",
628
632
  ~~~
629
633
 
630
634
  `localizeHref` leaves external URLs, fragments, endpoints, files and already
631
- 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
632
643
  browser language: offer the other languages as links and let them choose.
633
644
  `products.list({ limit, offset })` pages through the whole catalog, for a
634
645
  sitemap.
@@ -767,18 +778,17 @@ const onSale = await store.products.list({ onSale: true, limit: 8 });
767
778
  `store.collections.get(handle)` — title and description in the client's
768
779
  language, `null` for an unknown handle (answer a real 404) — and list it
769
780
  with `products.list({ collection: handle })`.
770
- - `featured: true` and `ProductList.storeFeatured` are deprecated: they mean
771
- the `featured` collection.
781
+ - The store's featured products are its `featured` collection:
782
+ `products.list({ collection: "featured" })`.
772
783
 
773
784
  A collection's page also has its size and a photo: `collections.get(handle)`
774
785
  answers `productCount` (what a shopper sees in it) and `image` (its first
775
- listed product's first photo, a `ProductImage`), both absent from a server
776
- that predates them.
786
+ listed product's first photo, a `ProductImage`, null while it lists none with
787
+ one).
777
788
 
778
789
  **Pages, totals and order.** `products.page(filters)` answers one page,
779
790
  whether another follows, and how many products the whole listing holds
780
- (`total`, null from a server that predates counts) — enough for "page 2 of 5"
781
- or "36 products":
791
+ (`total`) — enough for "page 2 of 5" or "36 products":
782
792
 
783
793
  ~~~ts
784
794
  const page = await store.products.page({ collection: "best-sellers", sort: "price-ascending", limit: 24, offset: 24 });
@@ -791,15 +801,13 @@ const page = await store.products.page({ collection: "best-sellers", sort: "pric
791
801
  `DEFAULT_COLLECTION_HANDLES` names the collections every store starts with,
792
802
  the likeliest to hold products first, for a page that needs one and was named
793
803
  none. Without one a listing keeps
794
- its own order: a collection's, a search's best match first, else newest. A
795
- server that predates sorting ignores it.
804
+ its own order: a collection's, a search's best match first, else newest.
796
805
 
797
806
  **Search.** `products.search(text, filters)` answers a page of the products
798
807
  whose name — in the client's language or the catalog's — or tags hold every
799
808
  word, with the same filters, sort and paging; names that hold every word come
800
- first. Blank text finds nothing without asking. Search has its own path, so a
801
- server that predates it answers an empty page (reported through `onError`),
802
- never the whole catalog:
809
+ first. Blank text finds nothing without asking, and a failed search answers an
810
+ empty page (reported through `onError`), never the whole catalog:
803
811
 
804
812
  ~~~ts
805
813
  const results = await store.products.search(Astro.url.searchParams.get("q") ?? "", { limit: 24 });
@@ -807,9 +815,10 @@ const results = await store.products.search(Astro.url.searchParams.get("q") ?? "
807
815
 
808
816
  Product lists return ProductList objects. ProductList and Product are
809
817
  different shapes: a list item is not a detail object and must not be cast to
810
- Product. ProductList carries list-oriented fields such as coverImage and
811
- reviewStats, while variantOptions and tags remain their raw serialized form.
812
- Read tags with `productTags(product)`, which gives a list from either shape.
818
+ Product. Both carry the same product fields — variantOptions and tags parsed,
819
+ metaTitle, metaDescription and reviewStats — and a ProductList adds its
820
+ coverImage, a Product its photos, active variants (in the merchant's order),
821
+ offers, quantity pricing and category.
813
822
 
814
823
  The limit is optional. An explicit limit of 0 is preserved, so do not replace
815
824
  it with a truthiness fallback.
@@ -1676,11 +1685,67 @@ The page reads the result with `Astro.getActionResult()`: on success it
1676
1685
  redirects to the thank-you page, and on a refusal it renders the form again
1677
1686
  with the error in place.
1678
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
+
1679
1702
  Never redirect from the middleware, on success or on error. Astro 7 keeps an
1680
1703
  action result for the response being rendered only, so a redirect arrives at
1681
1704
  the next page with no result: the thank-you redirect never fires, and a
1682
- refused order shows no message. resolveActionRedirectDecision, the earlier
1683
- name, is deprecated and now always answers next.
1705
+ refused order shows no message.
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.
1684
1749
 
1685
1750
  ### The request's store and language
1686
1751
 
@@ -1690,7 +1755,9 @@ from its path (`/fr/products/robe`); the primary's own prefix redirects for
1690
1755
  good to its one address, and a language the store stopped publishing
1691
1756
  redirects for now to the primary page; an endpoint (`/api/…`) or a server
1692
1757
  island takes the language of the page that asked, from its same-origin
1693
- `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
1694
1761
  request's client, config, language, routing and unprefixed path — and the
1695
1762
  theme builds its own context from it:
1696
1763
 
@@ -1698,7 +1765,8 @@ theme builds its own context from it:
1698
1765
  // src/middleware.ts
1699
1766
  import { resolvePreviewStore, resolveStoreRequest, runFormAction } from "@wocart/storefront/astro";
1700
1767
 
1701
- const options = { client: createStore, fallback: FALLBACK_CONFIG }; // the theme's one client factory, and its shell config
1768
+ // The theme's one client factory, and the store it renders when the config cannot be read.
1769
+ const options = { client: createStore, fallback: unavailableStoreConfig({ serverUrl: env.COD_SERVER_URL }) };
1702
1770
 
1703
1771
  export const onRequest = defineMiddleware(async (context, next) => {
1704
1772
  if (context.url.pathname === THEME_PREVIEW_PATH) {
@@ -1717,7 +1785,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
1717
1785
 
1718
1786
  `client(locale?)` makes the request's client, in a language or the
1719
1787
  store's primary; `fallback` is the config rendered when the store cannot be
1720
- read — a shell with the checkout off. `embedded(pathname)` overrides which
1788
+ read. `unavailableStoreConfig({ serverUrl, locale })` is that config, in your
1789
+ theme's default language (the platform's, Arabic, when you name none): no
1790
+ name, nothing to buy — cart, checkout, code by SMS, reviews and WhatsApp off —
1791
+ and an empty `id`, by which a theme tells it from a real store. It always has
1792
+ the config's whole shape, so never write your own. `embedded(pathname)` overrides which
1721
1793
  paths serve part of a page. `resolvePreviewStore(draft, options)` is the
1722
1794
  editor's preview: the draft's language when the store serves it, the draft's
1723
1795
  settings and text over the saved ones, tracking off.
@@ -1778,6 +1850,21 @@ export const POST = routes.otpSend;
1778
1850
  | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
1779
1851
  | `orderStatus` | POST | `{ orderNumber, phone }` | `{ data: ShopperOrderStatus }` |
1780
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
+
1781
1868
  What every route gets right:
1782
1869
 
1783
1870
  - every POST and PUT refuses a cross-origin request with 403;
@@ -1919,52 +2006,59 @@ image; `null` means the product genuinely has no photograph, so that is the
1919
2006
  moment to render a placeholder. The list model carries no gallery at all, so a
1920
2007
  card gets the cover or `null`.
1921
2008
 
1922
- ## WhatsApp prefill messages
2009
+ ## WhatsApp
1923
2010
 
1924
- The storefront's WhatsApp launcher hands the shopper to WhatsApp with a message
1925
- already written, and the thank-you page offers the same handoff for an order.
1926
- The template belongs to the merchant — the dashboard authors and previews it
1927
- within the platform's limits — while the page resolves it:
1928
-
1929
- The merchant writes the widget's texts once, in the store's primary language.
1930
- Read the widget with `localWhatsAppWidget(config)`: on a page in another
1931
- language its texts (labels, welcome, prefills) are null, so the theme shows its
1932
- own words in the shopper's language; the agent's name and the settings stay.
2011
+ The WhatsApp button App owns the store's WhatsApp: its number, its texts,
2012
+ which pages show the floating launcher, how it looks. Your theme owns only
2013
+ how it is drawn. Every link a theme draws to WhatsApp — the launcher, a
2014
+ footer or menu link, a contact card, the thank-you and tracking buttons —
2015
+ comes from one function, `whatsAppContact(config, page, words)`, and none is
2016
+ written by hand:
1933
2017
 
1934
2018
  ~~~ts
1935
- import {
1936
- buildWhatsAppHref,
1937
- localWhatsAppWidget,
1938
- resolvePrefillText,
1939
- } from "@wocart/storefront";
1940
-
1941
- const widget = localWhatsAppWidget(config);
1942
- if (!widget) return;
1943
-
1944
- const text = resolvePrefillText({
1945
- template: widget.prefillProduct ?? "Hello {store}, about {product}: {url}",
1946
- storeName: config.name,
1947
- productName: product.name, // falls back to the store name when absent
1948
- url: Astro.url.href, // ad-click parameters (`utm_*`, `fbclid`) stripped
1949
- orderNumber: null, // resolves to nothing when absent
1950
- });
1951
-
1952
- const href = buildWhatsAppHref(widget.href, text);
1953
- ~~~
1954
-
1955
- Three rules come with it, and a theme must not re-implement them:
1956
-
1957
- - the resolved message is capped at `MAX_RESOLVED_PREFILL_LENGTH` characters and
1958
- cut at a word boundary, because browsers and WhatsApp both truncate silently
1959
- past a point;
1960
- - `{url}` never carries ad-click parameters;
1961
- - the encoded `text=` is appended to the base href, preserving a query string
1962
- the href already carries.
1963
-
1964
- `PREFILL_TOKENS` is the token vocabulary the dashboard offers (`product`, `url`,
1965
- `order`, `store`); an unknown placeholder is left verbatim so a template typo is
1966
- visible rather than silently swallowed. The cap and the token list are the
1967
- same ones the dashboard enforces.
2019
+ import { showsWidgetOn, whatsAppContact } from "@wocart/storefront";
2020
+
2021
+ const whatsapp = whatsAppContact(
2022
+ config,
2023
+ { kind: "product", url: canonical, product: product.name },
2024
+ {
2025
+ launcher: null, // the launcher shows its icon alone unless the merchant names it
2026
+ caption: t("whatsapp.caption"),
2027
+ welcome: t("whatsapp.welcome"),
2028
+ cta: t("whatsapp.cta"),
2029
+ orderButton: t("whatsapp.order"),
2030
+ messages: { general: t("whatsapp.message"), product: t("whatsapp.messageProduct"), order: t("whatsapp.messageOrder") },
2031
+ },
2032
+ );
2033
+ const launcherShown = whatsapp !== null && showsWidgetOn(whatsapp.surfaces, store.pathname);
2034
+ // whatsapp.href, whatsapp.ctaLabel, whatsapp.welcomeMessage, whatsapp.agentName …
2035
+ ~~~
2036
+
2037
+ It answers `null` when the store has no WhatsApp (the App is off, or has no
2038
+ usable number). Otherwise every text is resolved for this page:
2039
+
2040
+ - **The merchant's text, else your words.** `/store/config` serves each of
2041
+ the App's texts in the page's language, or null where the merchant wrote
2042
+ none in it — never another language's. A null reads your `words`, which
2043
+ your theme has in every language it speaks. `caption`, `welcome` and
2044
+ `launcher` may be null in your words too: the panel then shows nothing
2045
+ there.
2046
+ - **The page's message.** `page.kind` is `product` (a product or landing
2047
+ page, with `product` its name), `order` (the thank-you or tracking page,
2048
+ with `order` its number) or `general`. The merchant's template for that
2049
+ kind is used, else their general one, else yours for that kind.
2050
+ - **The agent** is the merchant's, else the store's name.
2051
+
2052
+ The message follows three rules no theme re-implements: it is capped at
2053
+ `MAX_RESOLVED_PREFILL_LENGTH` characters, cut at a word boundary, because
2054
+ browsers and WhatsApp both truncate silently past a point; `{url}` never
2055
+ carries ad-click parameters (`utm_*`, `fbclid`); and the encoded `text=` keeps
2056
+ a query string the number's link already carries.
2057
+
2058
+ `PREFILL_TOKENS` is the token vocabulary the dashboard offers (`product`,
2059
+ `url`, `order`, `store`); an unknown placeholder is left verbatim so a
2060
+ template typo is visible rather than silently swallowed. The cap and the
2061
+ token list are the same ones the dashboard enforces.
1968
2062
 
1969
2063
  ## The sitemap
1970
2064
 
@@ -2021,22 +2115,22 @@ editor — is `@wocart/theme`'s (its README, "Preview").
2021
2115
  ## Showing a publish at once (revalidation)
2022
2116
 
2023
2117
  A theme that caches its pages (route rules with `maxAge` and `tags`) shows a
2024
- merchant's publish when the cache expires. To show it at once, the theme
2025
- 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:
2026
2121
 
2027
2122
  ```ts
2028
2123
  // src/pages/wocart-revalidate.ts — REVALIDATE_PATH
2029
- import type { APIRoute } from "astro";
2030
- import { readRevalidateRequest } from "@wocart/storefront/server";
2124
+ import { revalidateEndpoint } from "@wocart/storefront/astro";
2031
2125
  export const prerender = false;
2032
- export const POST: APIRoute = async (context) => {
2033
- const revalidation = await readRevalidateRequest(context.request, { publicKey: WOCART_PREVIEW_KEY, theme: THEME_KEY });
2034
- if (!revalidation.ok) return new Response(null, { status: revalidation.status });
2035
- await context.cache.invalidate({ tags: ["pages"] }); // the tag every cached page carries
2036
- return new Response(null, { status: 204 });
2037
- };
2126
+ export const { POST, ALL } = revalidateEndpoint({ publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY });
2038
2127
  ```
2039
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
+
2040
2134
  The request is a `POST` from the platform's server with
2041
2135
  `Authorization: Bearer <token>`: signed with the same key as previews, but
2042
2136
  made only for revalidation (`use: "revalidate"`), for this storefront's