@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.
- package/CHANGELOG.md +138 -0
- package/USAGE.md +189 -95
- package/dist/answer-readers.js +16 -43
- package/dist/astro.d.ts +6 -2
- package/dist/astro.js +3 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/products.d.ts +2 -3
- package/dist/client/products.js +2 -3
- package/dist/endpoints/revalidate-endpoint.d.ts +37 -0
- package/dist/endpoints/revalidate-endpoint.js +32 -0
- package/dist/endpoints/storefront-routes.js +1 -1
- package/dist/index.d.ts +8 -4
- package/dist/index.js +4 -2
- package/dist/locale-routing.d.ts +11 -20
- package/dist/locale-routing.js +28 -8
- package/dist/middleware.d.ts +6 -26
- package/dist/middleware.js +0 -9
- package/dist/product-view.d.ts +0 -8
- package/dist/product-view.js +0 -17
- package/dist/revalidate.d.ts +6 -0
- package/dist/revalidate.js +6 -0
- package/dist/routes.d.ts +44 -27
- package/dist/routes.js +54 -13
- package/dist/schemas.d.ts +144 -222
- package/dist/schemas.js +77 -137
- package/dist/seo.js +2 -2
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/dist/store-request.d.ts +5 -4
- package/dist/store-request.js +1 -1
- package/dist/storefront-middleware.d.ts +88 -0
- package/dist/storefront-middleware.js +37 -0
- package/dist/thank-you.d.ts +32 -0
- package/dist/thank-you.js +33 -0
- package/dist/types.d.ts +76 -111
- package/dist/unavailable-store.d.ts +23 -0
- package/dist/unavailable-store.js +58 -0
- package/dist/variant-picker.d.ts +6 -2
- package/dist/variant-picker.js +6 -9
- package/dist/whatsapp.d.ts +59 -6
- package/dist/whatsapp.js +28 -10
- 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.
|
|
143
|
-
|
|
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({
|
|
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
|
-
| `
|
|
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
|
|
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:
|
|
376
|
-
not-found page, a delivery-rate fallback, or no
|
|
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
|
|
519
|
-
|
|
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
|
|
522
|
-
|
|
523
|
-
|
|
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
|
|
529
|
-
|
|
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
|
-
-
|
|
532
|
+
- whatsapp, the WhatsApp button App — read it through `whatsAppContact`;
|
|
533
533
|
- pixelId and conversionEvent;
|
|
534
|
-
-
|
|
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
|
|
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`.
|
|
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
|
-
-
|
|
771
|
-
|
|
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
|
|
776
|
-
|
|
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
|
|
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.
|
|
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
|
|
801
|
-
|
|
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.
|
|
811
|
-
|
|
812
|
-
|
|
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.
|
|
1683
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
2009
|
+
## WhatsApp
|
|
1923
2010
|
|
|
1924
|
-
The
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
`
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
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,
|
|
2025
|
-
|
|
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
|
|
2030
|
-
import { readRevalidateRequest } from "@wocart/storefront/server";
|
|
2124
|
+
import { revalidateEndpoint } from "@wocart/storefront/astro";
|
|
2031
2125
|
export const prerender = false;
|
|
2032
|
-
export const POST
|
|
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
|