@wocart/storefront 2.2.0 → 3.0.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,73 @@ 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.0.0 — 2026-11-01
9
+
10
+ The SDK reads exactly what platform v7.0.0 serves: `/store/config` and the
11
+ product reads name every field they answer, and the SDK asks for no field the
12
+ server no longer sends nor tolerates an answer from an older server. The
13
+ WhatsApp button speaks every language, and a theme reaches it one way.
14
+ Requires platform v7.0.0.
15
+
16
+ ### Added
17
+
18
+ - `whatsAppContact(config, page, words)`: the store's WhatsApp on one page —
19
+ the `wa.me` link with the page's message, and every text resolved: the
20
+ merchant's in the page's language, else the theme's `words`; the agent the
21
+ merchant's, else the store's name. Null when the store has none. Every
22
+ WhatsApp link a theme draws comes from it. Types `WhatsAppContact`,
23
+ `WhatsAppPage`, `WhatsAppWords`.
24
+ - `unavailableStoreConfig({ serverUrl, locale })`: the store a page renders
25
+ when its config cannot be read — nothing to buy, its one language described
26
+ by the runtime. A theme passes it as `resolveStoreRequest`'s `fallback`
27
+ instead of writing its own.
28
+ - Page types `search` (`/search`) and `contact` (`/contact`); like
29
+ `trackOrder`, they show the widget by the App's `pages` switch.
30
+ - `StoreConfig.contact`: `{ phone, email, address }`, each null until the
31
+ merchant writes it.
32
+ - `CartValidation.freeDelivery.by`: why delivery is free — `offer`, `coupon`
33
+ or `threshold` — or null.
34
+
35
+ ### Changed (breaking)
36
+
37
+ - The WhatsApp widget's texts arrive in the page's language, or null where
38
+ the merchant wrote none in it — never another language's.
39
+ - `ProductList.variantOptions` and `ProductList.tags` arrive parsed, as on a
40
+ product page.
41
+ - `Product.variants` holds only the active variants, in the merchant's
42
+ order: `ProductVariant` drops `productId`, `currency`, `barcode`,
43
+ `weightKg`, `active`, `position`, `createdAt` and `updatedAt`, and the
44
+ variant picker takes the variants as they come.
45
+ - Required, where they were optional for older servers:
46
+ `StoreConfig.themeSettings`, `ProductPage.total` (0 on a failed read, never
47
+ null), `StoreCollection.productCount` and `image`, `Offer.rewardUnitPrice`,
48
+ `Product.quantityPricing`, the product `metaTitle` and `metaDescription`,
49
+ `LandingPage.document`, `Review.verified` and `media`,
50
+ `ReviewSubmitResult.visibility`,
51
+ `CreatedOrder.couponDiscount`, a cart quote's `coupon`, a line's `offerId`,
52
+ `offerName` and `discount`, and a gift's `offerName`, `variantId` and
53
+ `variantLabel`. `ReviewInput.phone` is required.
54
+
55
+ ### Removed
56
+
57
+ - `StoreConfig.primaryColor`, `accentColor`, `bgColor`, `fontFamily`,
58
+ `fontUrl` and `announcementBar` (a theme's settings now), `themeId`,
59
+ `cartShippingMode`, `status`, `createdAt`, `updatedAt`, and `legalContact`
60
+ (`contact` replaces it; the delivery days stay on the store's legal
61
+ pages).
62
+ - `Product.costPrice`, `storeFeatured`, `deletedAt`, `visibility`, `status`,
63
+ `showInStore`, `publishedAt`, `createdAt`, `updatedAt` and
64
+ `shippingProfileId`, and `ProductListFilters.featured` (use
65
+ `collection: "featured"`).
66
+ - `localWhatsAppWidget`, `resolvePrefillText`, `buildWhatsAppHref`,
67
+ `stripTrackingParams`, `truncateAtWordBoundary` and `ResolvePrefillParams`:
68
+ `whatsAppContact` applies the message rules itself. Removed without a
69
+ deprecated minor first: the platform they served changes with this major.
70
+ - `productTags`: both product shapes carry their tags as a list.
71
+ - `STOREFRONT_ROUTES` and `StorefrontRoute` (use `STOREFRONT_PAGE_PATHS`),
72
+ `resolveActionRedirectDecision` and `PrgDecision` (use `runFormAction`), and
73
+ `ActionMiddlewareDeps.referer` and `pathname`.
74
+
8
75
  ## 2.2.0 — 2026-11-01
9
76
 
10
77
  ### 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
 
@@ -767,18 +771,17 @@ const onSale = await store.products.list({ onSale: true, limit: 8 });
767
771
  `store.collections.get(handle)` — title and description in the client's
768
772
  language, `null` for an unknown handle (answer a real 404) — and list it
769
773
  with `products.list({ collection: handle })`.
770
- - `featured: true` and `ProductList.storeFeatured` are deprecated: they mean
771
- the `featured` collection.
774
+ - The store's featured products are its `featured` collection:
775
+ `products.list({ collection: "featured" })`.
772
776
 
773
777
  A collection's page also has its size and a photo: `collections.get(handle)`
774
778
  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.
779
+ listed product's first photo, a `ProductImage`, null while it lists none with
780
+ one).
777
781
 
778
782
  **Pages, totals and order.** `products.page(filters)` answers one page,
779
783
  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":
784
+ (`total`) — enough for "page 2 of 5" or "36 products":
782
785
 
783
786
  ~~~ts
784
787
  const page = await store.products.page({ collection: "best-sellers", sort: "price-ascending", limit: 24, offset: 24 });
@@ -791,15 +794,13 @@ const page = await store.products.page({ collection: "best-sellers", sort: "pric
791
794
  `DEFAULT_COLLECTION_HANDLES` names the collections every store starts with,
792
795
  the likeliest to hold products first, for a page that needs one and was named
793
796
  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.
797
+ its own order: a collection's, a search's best match first, else newest.
796
798
 
797
799
  **Search.** `products.search(text, filters)` answers a page of the products
798
800
  whose name — in the client's language or the catalog's — or tags hold every
799
801
  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:
802
+ first. Blank text finds nothing without asking, and a failed search answers an
803
+ empty page (reported through `onError`), never the whole catalog:
803
804
 
804
805
  ~~~ts
805
806
  const results = await store.products.search(Astro.url.searchParams.get("q") ?? "", { limit: 24 });
@@ -807,9 +808,10 @@ const results = await store.products.search(Astro.url.searchParams.get("q") ?? "
807
808
 
808
809
  Product lists return ProductList objects. ProductList and Product are
809
810
  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.
811
+ Product. Both carry the same product fields — variantOptions and tags parsed,
812
+ metaTitle, metaDescription and reviewStats — and a ProductList adds its
813
+ coverImage, a Product its photos, active variants (in the merchant's order),
814
+ offers, quantity pricing and category.
813
815
 
814
816
  The limit is optional. An explicit limit of 0 is preserved, so do not replace
815
817
  it with a truthiness fallback.
@@ -1679,8 +1681,7 @@ with the error in place.
1679
1681
  Never redirect from the middleware, on success or on error. Astro 7 keeps an
1680
1682
  action result for the response being rendered only, so a redirect arrives at
1681
1683
  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.
1684
+ refused order shows no message.
1684
1685
 
1685
1686
  ### The request's store and language
1686
1687
 
@@ -1698,7 +1699,8 @@ theme builds its own context from it:
1698
1699
  // src/middleware.ts
1699
1700
  import { resolvePreviewStore, resolveStoreRequest, runFormAction } from "@wocart/storefront/astro";
1700
1701
 
1701
- const options = { client: createStore, fallback: FALLBACK_CONFIG }; // the theme's one client factory, and its shell config
1702
+ // The theme's one client factory, and the store it renders when the config cannot be read.
1703
+ const options = { client: createStore, fallback: unavailableStoreConfig({ serverUrl: env.COD_SERVER_URL }) };
1702
1704
 
1703
1705
  export const onRequest = defineMiddleware(async (context, next) => {
1704
1706
  if (context.url.pathname === THEME_PREVIEW_PATH) {
@@ -1717,7 +1719,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
1717
1719
 
1718
1720
  `client(locale?)` makes the request's client, in a language or the
1719
1721
  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
1722
+ read. `unavailableStoreConfig({ serverUrl, locale })` is that config, in your
1723
+ theme's default language (the platform's, Arabic, when you name none): no
1724
+ name, nothing to buy — cart, checkout, code by SMS, reviews and WhatsApp off —
1725
+ and an empty `id`, by which a theme tells it from a real store. It always has
1726
+ the config's whole shape, so never write your own. `embedded(pathname)` overrides which
1721
1727
  paths serve part of a page. `resolvePreviewStore(draft, options)` is the
1722
1728
  editor's preview: the draft's language when the store serves it, the draft's
1723
1729
  settings and text over the saved ones, tracking off.
@@ -1919,52 +1925,59 @@ image; `null` means the product genuinely has no photograph, so that is the
1919
1925
  moment to render a placeholder. The list model carries no gallery at all, so a
1920
1926
  card gets the cover or `null`.
1921
1927
 
1922
- ## WhatsApp prefill messages
1928
+ ## WhatsApp
1923
1929
 
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.
1930
+ The WhatsApp button App owns the store's WhatsApp: its number, its texts,
1931
+ which pages show the floating launcher, how it looks. Your theme owns only
1932
+ how it is drawn. Every link a theme draws to WhatsApp — the launcher, a
1933
+ footer or menu link, a contact card, the thank-you and tracking buttons —
1934
+ comes from one function, `whatsAppContact(config, page, words)`, and none is
1935
+ written by hand:
1933
1936
 
1934
1937
  ~~~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.
1938
+ import { showsWidgetOn, whatsAppContact } from "@wocart/storefront";
1939
+
1940
+ const whatsapp = whatsAppContact(
1941
+ config,
1942
+ { kind: "product", url: canonical, product: product.name },
1943
+ {
1944
+ launcher: null, // the launcher shows its icon alone unless the merchant names it
1945
+ caption: t("whatsapp.caption"),
1946
+ welcome: t("whatsapp.welcome"),
1947
+ cta: t("whatsapp.cta"),
1948
+ orderButton: t("whatsapp.order"),
1949
+ messages: { general: t("whatsapp.message"), product: t("whatsapp.messageProduct"), order: t("whatsapp.messageOrder") },
1950
+ },
1951
+ );
1952
+ const launcherShown = whatsapp !== null && showsWidgetOn(whatsapp.surfaces, store.pathname);
1953
+ // whatsapp.href, whatsapp.ctaLabel, whatsapp.welcomeMessage, whatsapp.agentName …
1954
+ ~~~
1955
+
1956
+ It answers `null` when the store has no WhatsApp (the App is off, or has no
1957
+ usable number). Otherwise every text is resolved for this page:
1958
+
1959
+ - **The merchant's text, else your words.** `/store/config` serves each of
1960
+ the App's texts in the page's language, or null where the merchant wrote
1961
+ none in it — never another language's. A null reads your `words`, which
1962
+ your theme has in every language it speaks. `caption`, `welcome` and
1963
+ `launcher` may be null in your words too: the panel then shows nothing
1964
+ there.
1965
+ - **The page's message.** `page.kind` is `product` (a product or landing
1966
+ page, with `product` its name), `order` (the thank-you or tracking page,
1967
+ with `order` its number) or `general`. The merchant's template for that
1968
+ kind is used, else their general one, else yours for that kind.
1969
+ - **The agent** is the merchant's, else the store's name.
1970
+
1971
+ The message follows three rules no theme re-implements: it is capped at
1972
+ `MAX_RESOLVED_PREFILL_LENGTH` characters, cut at a word boundary, because
1973
+ browsers and WhatsApp both truncate silently past a point; `{url}` never
1974
+ carries ad-click parameters (`utm_*`, `fbclid`); and the encoded `text=` keeps
1975
+ a query string the number's link already carries.
1976
+
1977
+ `PREFILL_TOKENS` is the token vocabulary the dashboard offers (`product`,
1978
+ `url`, `order`, `store`); an unknown placeholder is left verbatim so a
1979
+ template typo is visible rather than silently swallowed. The cap and the
1980
+ token list are the same ones the dashboard enforces.
1968
1981
 
1969
1982
  ## The sitemap
1970
1983
 
@@ -2,59 +2,33 @@ const isObject = (value) => typeof value === "object" && value !== null && !Arra
2
2
  const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
3
3
  const isInteger = (value) => typeof value === "number" && Number.isSafeInteger(value);
4
4
  const isNullableString = (value) => value === null || typeof value === "string";
5
- /** An optional field the schema reads as `.nullish()`: absent stays absent, null and the type are kept, anything else refuses. */
6
- function nullish(source, key, accepts) {
7
- if (!(key in source) || source[key] === undefined)
8
- return { ok: true, entry: {} };
9
- const value = source[key];
10
- return value === null || accepts(value) ? { ok: true, entry: { [key]: value } } : { ok: false, entry: {} };
11
- }
12
- const isString = (value) => typeof value === "string";
13
5
  const BLOCKERS = ["missing", "unlisted", "out_of_stock"];
6
+ const FREE_BY = ["offer", "coupon", "threshold"];
14
7
  function readQuoteLine(value) {
15
8
  if (!isObject(value))
16
9
  return null;
17
- const { productId, variantId, variantLabel, productName, quantity, unitPrice, lineTotal, maxQuantity, blocker } = value;
10
+ const { productId, variantId, variantLabel, productName, quantity, unitPrice, lineTotal, offerId, offerName, discount, maxQuantity, blocker } = value;
18
11
  if (typeof productId !== "string" || !isNullableString(variantId) || !isNullableString(variantLabel) || typeof productName !== "string")
19
12
  return null;
20
13
  if (!isInteger(quantity) || !isFiniteNumber(unitPrice) || !isFiniteNumber(lineTotal))
21
14
  return null;
15
+ if (!isNullableString(offerId) || !isNullableString(offerName) || !isFiniteNumber(discount))
16
+ return null;
22
17
  if (maxQuantity !== null && !isFiniteNumber(maxQuantity))
23
18
  return null;
24
19
  if (blocker !== null && !(typeof blocker === "string" && BLOCKERS.includes(blocker)))
25
20
  return null;
26
- const offerId = nullish(value, "offerId", isString);
27
- const offerName = nullish(value, "offerName", isString);
28
- const discount = nullish(value, "discount", isFiniteNumber);
29
- if (!offerId.ok || !offerName.ok || !discount.ok)
30
- return null;
31
- return {
32
- productId,
33
- variantId,
34
- variantLabel,
35
- productName,
36
- quantity,
37
- unitPrice,
38
- lineTotal,
39
- ...offerId.entry,
40
- ...offerName.entry,
41
- ...discount.entry,
42
- maxQuantity,
43
- blocker: blocker,
44
- };
21
+ return { productId, variantId, variantLabel, productName, quantity, unitPrice, lineTotal, offerId, offerName, discount, maxQuantity, blocker: blocker };
45
22
  }
46
23
  function readReward(value) {
47
24
  if (!isObject(value))
48
25
  return null;
49
- const { offerId, productId, productName, quantity } = value;
50
- if (typeof offerId !== "string" || !isNullableString(productId) || !isNullableString(productName) || !isInteger(quantity))
26
+ const { offerId, offerName, productId, productName, variantId, variantLabel, quantity } = value;
27
+ if (typeof offerId !== "string" || !isNullableString(offerName) || !isNullableString(productId) || !isNullableString(productName))
51
28
  return null;
52
- const offerName = nullish(value, "offerName", isString);
53
- const variantId = nullish(value, "variantId", isString);
54
- const variantLabel = nullish(value, "variantLabel", isString);
55
- if (!offerName.ok || !variantId.ok || !variantLabel.ok)
29
+ if (!isNullableString(variantId) || !isNullableString(variantLabel) || !isInteger(quantity))
56
30
  return null;
57
- return { offerId, ...offerName.entry, productId, productName, ...variantId.entry, ...variantLabel.entry, quantity };
31
+ return { offerId, offerName, productId, productName, variantId, variantLabel, quantity };
58
32
  }
59
33
  function readDelivery(value) {
60
34
  if (!isObject(value))
@@ -93,25 +67,24 @@ export function readCartQuote(value) {
93
67
  return null;
94
68
  if (free.threshold !== null && !isFiniteNumber(free.threshold))
95
69
  return null;
70
+ if (free.by !== null && !(typeof free.by === "string" && FREE_BY.includes(free.by)))
71
+ return null;
96
72
  let delivery = null;
97
73
  if ("delivery" in value && value.delivery !== undefined) {
98
74
  delivery = readDelivery(value.delivery);
99
75
  if (delivery === null)
100
76
  return null;
101
77
  }
102
- let coupon;
103
- if ("coupon" in value && value.coupon !== undefined) {
104
- coupon = value.coupon === null ? null : readCouponOutcome(value.coupon);
105
- if (coupon === null && value.coupon !== null)
106
- return null;
107
- }
78
+ const coupon = value.coupon === null ? null : readCouponOutcome(value.coupon);
79
+ if (coupon === null && value.coupon !== null)
80
+ return null;
108
81
  return {
109
82
  lines,
110
83
  subtotal: value.subtotal,
111
84
  rewards,
112
- freeDelivery: { fromOffer: free.fromOffer, threshold: free.threshold, qualified: free.qualified, remaining: free.remaining },
85
+ freeDelivery: { fromOffer: free.fromOffer, threshold: free.threshold, qualified: free.qualified, remaining: free.remaining, by: free.by },
113
86
  ...(delivery === null ? {} : { delivery }),
114
- ...(coupon === undefined ? {} : { coupon }),
87
+ coupon,
115
88
  };
116
89
  }
117
90
  /** The delivery rates of a value (`ShippingRates`), or null when it is not a rate table. */
package/dist/astro.d.ts CHANGED
@@ -4,8 +4,8 @@ export { createStorefrontRoutes, REVIEWS_PAGE_DEFAULT, REVIEWS_PAGE_MAX, } from
4
4
  export type { StorefrontApiRoute, StorefrontRoutes, StorefrontRoutesOptions, } from "./endpoints/storefront-routes.js";
5
5
  export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
6
6
  export type { PlaceOrderInput, PlaceOrderResult } from "./actions.js";
7
- export { runFormAction, resolveActionRedirectDecision } from "./middleware.js";
8
- export type { PrgDecision, ActionMiddlewareDeps, ActionDescriptor } from "./middleware.js";
7
+ export { runFormAction } from "./middleware.js";
8
+ export type { ActionMiddlewareDeps, ActionDescriptor } from "./middleware.js";
9
9
  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";
package/dist/astro.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { createProxyEndpoint } from "./endpoints/proxy.js";
2
2
  export { createStorefrontRoutes, REVIEWS_PAGE_DEFAULT, REVIEWS_PAGE_MAX, } from "./endpoints/storefront-routes.js";
3
3
  export { placeOrderInput, executePlaceOrder, collectForwardedHeaders, } from "./actions.js";
4
- export { runFormAction, resolveActionRedirectDecision } from "./middleware.js";
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";
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * const [config, products] = await Promise.all([
24
24
  * store.config.get(),
25
- * store.products.list({ featured: true, limit: 8 }),
25
+ * store.products.list({ collection: "featured", limit: 8 }),
26
26
  * ]);
27
27
  * ```
28
28
  */
@@ -22,7 +22,7 @@
22
22
  *
23
23
  * const [config, products] = await Promise.all([
24
24
  * store.config.get(),
25
- * store.products.list({ featured: true, limit: 8 }),
25
+ * store.products.list({ collection: "featured", limit: 8 }),
26
26
  * ]);
27
27
  * ```
28
28
  */
@@ -41,9 +41,8 @@ export interface ProductsNamespace {
41
41
  * The products a shopper's words find — every word in the product's name,
42
42
  * in the client's language or the catalog's, or in its tags — as a page,
43
43
  * with the listing filters, sort and paging. Without a `sort`, products
44
- * whose name holds every word come first. Blank words find nothing; a
45
- * server that predates search answers an empty page (reported through
46
- * `onError`), never the whole catalog.
44
+ * whose name holds every word come first. Blank words find nothing, never
45
+ * the whole catalog.
47
46
  */
48
47
  search(text: string, filters?: ProductListFilters): Promise<ProductPage>;
49
48
  /**
@@ -29,7 +29,6 @@ export function createProductsNamespace(core) {
29
29
  categoryId: filters?.categoryId || undefined,
30
30
  collection: filters?.collection || undefined,
31
31
  onSale: filters?.onSale ? "true" : undefined,
32
- featured: filters?.featured ? "true" : undefined,
33
32
  sort: filters?.sort || undefined,
34
33
  limit: filters?.limit === undefined ? undefined : String(filters.limit),
35
34
  offset: filters?.offset ? String(filters.offset) : undefined,
@@ -53,11 +52,11 @@ export function createProductsNamespace(core) {
53
52
  operation,
54
53
  });
55
54
  const rows = envelope?.data ?? [];
56
- return { items: rows.slice(0, size), hasNext: rows.length > size, total: envelope?.total ?? null };
55
+ return { items: rows.slice(0, size), hasNext: rows.length > size, total: envelope?.total ?? 0 };
57
56
  }
58
57
  catch (error) {
59
58
  reportDegradedRead(core.config, error, operation);
60
- return { items: [], hasNext: false, total: null };
59
+ return { items: [], hasNext: false, total: 0 };
61
60
  }
62
61
  };
63
62
  return {
@@ -75,7 +75,7 @@ const reviewsListInput = z.object({
75
75
  */
76
76
  const reviewSubmitInput = z.object({
77
77
  orderNumber: z.string().min(1).max(64),
78
- phone: z.string().min(1).max(32).optional(),
78
+ phone: z.string().min(1).max(32),
79
79
  productId: z.string().min(1).max(200),
80
80
  rating: z.number(),
81
81
  title: z.string().max(500).optional(),
package/dist/index.d.ts CHANGED
@@ -15,13 +15,15 @@ export { isMediaKey, mediaImageSrcset, mediaImageUrl, mediaUrl, pictureOf } from
15
15
  export type { MediaDelivery, MediaDeliveryKind, MediaImage, MediaImageParams, MediaImageSrcsetOptions, PickedImage, Picture } from "./utils/image.js";
16
16
  export { productImageSource } from "./utils/product-image.js";
17
17
  export type { ProductImageSourceInput } from "./utils/product-image.js";
18
- export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, localWhatsAppWidget, } from "./whatsapp.js";
19
- export type { PrefillToken, ResolvePrefillParams } from "./whatsapp.js";
20
- export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
18
+ export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, whatsAppContact } from "./whatsapp.js";
19
+ export type { PrefillToken, WhatsAppContact, WhatsAppPage, WhatsAppWords } from "./whatsapp.js";
20
+ export { STOREFRONT_PAGE_PATHS, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
21
21
  export { servedLanguage } from "./locales.js";
22
+ export { unavailableStoreConfig } from "./unavailable-store.js";
23
+ export type { UnavailableStoreOptions } from "./unavailable-store.js";
22
24
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
23
25
  export type { LocalePath, LocaleRouting } from "./locale-routing.js";
24
- export type { StorefrontPageMatch, StorefrontPageType, StorefrontPathParams, StorefrontRoute } from "./routes.js";
26
+ export type { StorefrontPageMatch, StorefrontPageType, StorefrontPathParams } from "./routes.js";
25
27
  export { addedLines, createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
26
28
  export { MAX_BASKET_LINES, MAX_BASKET_LINE_QUANTITY, basketLineKey } from "./basket-line.js";
27
29
  export type { BasketStore, BasketStorageAdapter, BasketStoreOptions, BasketListener, BasketSelectionLine, } from "./basket.js";
package/dist/index.js CHANGED
@@ -12,9 +12,10 @@
12
12
  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
- export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, localWhatsAppWidget, } from "./whatsapp.js";
16
- export { STOREFRONT_PAGE_PATHS, STOREFRONT_ROUTES, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
15
+ export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, whatsAppContact } from "./whatsapp.js";
16
+ export { STOREFRONT_PAGE_PATHS, matchStorefrontPath, showsWidgetOn, storefrontPath } from "./routes.js";
17
17
  export { servedLanguage } from "./locales.js";
18
+ export { unavailableStoreConfig } from "./unavailable-store.js";
18
19
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
19
20
  export { addedLines, createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
20
21
  export { MAX_BASKET_LINES, MAX_BASKET_LINE_QUANTITY, basketLineKey } from "./basket-line.js";