@wocart/storefront 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/USAGE.md +313 -11
  3. package/dist/abandoned-checkout.d.ts +45 -0
  4. package/dist/abandoned-checkout.js +81 -3
  5. package/dist/answer-readers.d.ts +23 -0
  6. package/dist/answer-readers.js +155 -0
  7. package/dist/astro.d.ts +2 -0
  8. package/dist/astro.js +1 -0
  9. package/dist/basket-line.d.ts +15 -0
  10. package/dist/basket-line.js +17 -0
  11. package/dist/basket.d.ts +12 -2
  12. package/dist/basket.js +18 -1
  13. package/dist/catalog.d.ts +18 -0
  14. package/dist/catalog.js +27 -0
  15. package/dist/client/cart.js +1 -1
  16. package/dist/client/categories.js +1 -1
  17. package/dist/client/collections.js +1 -1
  18. package/dist/client/config.js +1 -1
  19. package/dist/client/geo.js +1 -1
  20. package/dist/client/menus.js +1 -1
  21. package/dist/client/orders.d.ts +10 -1
  22. package/dist/client/orders.js +9 -2
  23. package/dist/client/page-data.js +1 -1
  24. package/dist/client/pages.js +1 -1
  25. package/dist/client/products.d.ts +13 -3
  26. package/dist/client/products.js +36 -13
  27. package/dist/client/shipping.js +1 -1
  28. package/dist/coupon.d.ts +16 -1
  29. package/dist/coupon.js +37 -0
  30. package/dist/domain.d.ts +0 -70
  31. package/dist/domain.js +1 -70
  32. package/dist/endpoints/storefront-routes.d.ts +8 -2
  33. package/dist/endpoints/storefront-routes.js +14 -3
  34. package/dist/index.d.ts +18 -6
  35. package/dist/index.js +12 -3
  36. package/dist/operations.d.ts +72 -0
  37. package/dist/operations.js +70 -0
  38. package/dist/order-number.d.ts +9 -0
  39. package/dist/order-number.js +11 -0
  40. package/dist/otp.d.ts +8 -1
  41. package/dist/otp.js +3 -0
  42. package/dist/product-view.d.ts +8 -0
  43. package/dist/product-view.js +26 -4
  44. package/dist/quote.d.ts +27 -0
  45. package/dist/quote.js +43 -0
  46. package/dist/review-capture.d.ts +77 -0
  47. package/dist/review-capture.js +172 -0
  48. package/dist/review-media.d.ts +8 -75
  49. package/dist/review-media.js +7 -170
  50. package/dist/review-session.d.ts +97 -0
  51. package/dist/review-session.js +157 -0
  52. package/dist/reviews.d.ts +33 -0
  53. package/dist/reviews.js +41 -0
  54. package/dist/schemas.d.ts +98 -0
  55. package/dist/schemas.js +31 -0
  56. package/dist/seo.d.ts +47 -0
  57. package/dist/seo.js +87 -0
  58. package/dist/server.d.ts +1 -0
  59. package/dist/server.js +1 -0
  60. package/dist/sitemap.d.ts +22 -0
  61. package/dist/sitemap.js +51 -0
  62. package/dist/store-request.d.ts +96 -0
  63. package/dist/store-request.js +102 -0
  64. package/dist/types.d.ts +50 -2
  65. package/dist/utils/image.d.ts +48 -0
  66. package/dist/utils/image.js +51 -0
  67. package/dist/wilaya-id.d.ts +1 -0
  68. package/dist/wilaya-id.js +10 -0
  69. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,102 @@ 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
+ ## 1.20.0 — 2026-10-24
9
+
10
+ What every theme used to write itself is now the package's: the order form's
11
+ quote, abandoned-checkout tracking, the review form, the request's store and
12
+ language, the sitemap, search-engine metadata and images sized for the
13
+ screen. Search, sorting, listing totals, a shopper's order status and product
14
+ SEO fields arrive with the server that answers them; on an older server each
15
+ degrades safely.
16
+
17
+ ### Added
18
+
19
+ - **The order form, from the browser.** `createQuoter({ endpoint })` asks the
20
+ theme's cart-validate route and answers `fresh` (the quote), `stale` (a newer
21
+ question was asked meanwhile) or `failed`. `readCartQuote(value)` reads a
22
+ quote exactly as `CartValidationSchema` does, without Zod.
23
+ - **Coupons.** `couponRefusalTone(reason)` says whether a refusal is a `hint`
24
+ (the basket does not meet the code yet) or `refused` (the code cannot be
25
+ used); `COUPON_HINT_REASONS` and `COUPON_REFUSAL_REASONS` list them.
26
+ - **Abandoned checkouts.** `createBrowserAbandonedCheckoutTracker({ endpoint })`
27
+ wires the tracker to the page — session storage, timers, Meta cookies, a
28
+ keepalive POST and a beacon on exit. `convertAbandonedCheckout(...)` marks the
29
+ tab's checkout converted from the thank-you page;
30
+ `ABANDONED_CHECKOUT_SESSION_KEY` names where its id lives.
31
+ - **Reviews.** `createReviewSession({ routes })` in `@wocart/storefront/review-media`
32
+ proves the purchase, sends each photo (shrunk) or voice note with its
33
+ progress and retries, and submits; every refusal comes back as a code from
34
+ `REVIEW_REFUSAL_CODES`. `REVIEW_TEXT_LIMITS` gives the lengths the server
35
+ accepts; `isOrderNumber(value)` and `ORDER_NUMBER_PATTERN` the shape of an
36
+ order number.
37
+ - **The basket.** `basket.addSelections(line, selections)` adds an offer chosen
38
+ piece by piece — one line per variant, as one change, all or nothing.
39
+ - **Request and preview.** `resolveStoreRequest` and `resolvePreviewStore` in
40
+ `@wocart/storefront/astro` decide the request's store and language (path
41
+ prefixes, the asking page's language for endpoints and server islands, the
42
+ redirects) and the draft's store. `notFound(Astro)` answers a missing
43
+ resource with the not-found page, never cached.
44
+ - **Sitemap.** `sitemapXml(paths, routing, origin)` and `catalogPaths(client,
45
+ config)` in `@wocart/storefront/server`.
46
+ - **Images.** `mediaImageSrcset(src, widths, options)` gives width descriptors
47
+ for `sizes`; `pictureOf(image, mediaDomain)` turns a picked image into its
48
+ URL, alt, size and focal point.
49
+ - **Search engines.** `productMeta(product)`, `productStructuredData(product,
50
+ options)` and `jsonLd(value)`; `SEO_TEXT_LIMITS`. Products carry the
51
+ merchant's `metaTitle` and `metaDescription`, in the client's language.
52
+ - **Catalog.** `products.search(text, filters)`; `sort` on listings
53
+ (`PRODUCT_SORTS`); `ProductPage.total`; a collection's `productCount` and
54
+ `image`; `DEFAULT_COLLECTION_HANDLES`; `productTags(product)` reads tags as a
55
+ list from a listing or a product page.
56
+ - **Order status.** `orders.status({ orderNumber, phone })` and the
57
+ `orderStatus` storefront route: where a shopper's order is
58
+ (`SHOPPER_ORDER_STAGES`), its lines, total and tracking number — never its
59
+ address.
60
+
61
+ ### Changed
62
+
63
+ - `getProductView(product)` reads the default variant's stock for
64
+ `isOutOfStock` and `isLowStock` on a product with variants, not the
65
+ product's total.
66
+
67
+ ### Deprecated
68
+
69
+ - `ProductSchema.costPrice` and `ProductListSchema.costPrice`: always null —
70
+ what the merchant paid is never served to a storefront. A later major drops
71
+ them.
72
+
73
+ ## 1.19.0 — 2026-10-21
74
+
75
+ What a theme's browser code takes from the package brings no Zod. Validation
76
+ stays where data enters — a shopper's request at a route, the platform's answer
77
+ in the client — on the server; the browser reads what those checked.
78
+
79
+ ### Added
80
+
81
+ - `isWilayaId(value)`: whether a value is a wilaya id, the range
82
+ `WilayaIdSchema` checks on the server, read from the same `WILAYAS` — for the
83
+ browser, which checks a selected wilaya without a schema.
84
+ - `readShippingRates(value)` and `readCouponOutcome(value)`: the delivery rates
85
+ a page carries and a quote's coupon outcome, read in the browser. Each accepts
86
+ exactly what `ShippingRatesSchema` / `CouponOutcomeSchema` accept and returns
87
+ what they would, without Zod — a page cached apart from its scripts can still
88
+ hold another version's shape. A property test holds each to its schema.
89
+ - `mapOtpResponse` takes no `schema` for an answer of the SDK's own
90
+ `otpSend` / `otpVerify` routes, whose client already checked it on the
91
+ server. An answer with no `data` object is still a `MALFORMED_RESPONSE`
92
+ failure. Passing a schema works as before.
93
+
94
+ ### Changed
95
+
96
+ - The basket's limits and line identity (`MAX_BASKET_LINES`,
97
+ `MAX_BASKET_LINE_QUANTITY`, `basketLineKey`) and the operation tables
98
+ (`STOREFRONT_OPERATIONS`, `STOREFRONT_RETRY_POLICIES`) moved out of the
99
+ module that builds the schemas. The exports are the same; a browser bundle
100
+ that uses the basket or names an operation no longer carries Zod.
101
+ - A test bundles every browser-facing export through the package entry the
102
+ way a theme's Vite build does and fails, naming the export, if Zod appears.
103
+
8
104
  ## 1.18.0 — 2026-10-21
9
105
 
10
106
  The basket carries the offer a shopper chose for a line, so a quantity tier
package/USAGE.md CHANGED
@@ -218,6 +218,8 @@ server directly.
218
218
  | `mediaUrl(key, mediaDomain)` | The public URL of a stored media key — what a theme's `image` field holds (`{ key: "products/…" }`): `mediaUrl(image.key, MEDIA_DOMAIN)`; `null` with no media domain or a key that cannot name an object. The key is the identity; the URL is derived at render |
219
219
  | `createImageHelpers(mediaDomain)`, `getCardImage`, `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`, `getLogoImage` | The bound image toolkit (`IMAGE_SIZES` holds the presets) |
220
220
  | `WILAYAS`, `ALGERIA_COMMUNES`, `communesForWilaya` | Reference data: 58 wilayas, 1,551 communes, one wilaya's rows |
221
+ | `isWilayaId` | Whether a value is a wilaya id — the range `WilayaIdSchema` checks on the server, without a schema in the browser |
222
+ | `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 |
221
223
  | `normalizeAlgerianPhone`, `toLocalAlgerianMobile` | The one Algerian phone rule (accept/format) |
222
224
  | `resolvePrefillText`, `buildWhatsAppHref`, `stripTrackingParams`, `truncateAtWordBoundary`, `PREFILL_TOKENS`, `MAX_RESOLVED_PREFILL_LENGTH` | WhatsApp prefill text and the `wa.me` link |
223
225
  | `storefrontPath`, `matchStorefrontPath`, `STOREFRONT_PAGE_PATHS` | The page types and their public paths: build a link, recognise a page |
@@ -314,6 +316,20 @@ Browser code reaches the platform only through same-origin routes you own
314
316
  browser-safe by construction: it exports no client factory and no function that
315
317
  carries the key, which the package's own contract test asserts.
316
318
 
319
+ **Validation runs where data enters, on the server.** The SDK checks a
320
+ shopper's request at its routes and the platform's answer in its client, with
321
+ the schemas this package exports. What its routes and server islands hand the
322
+ browser has passed those checks, so browser code reads it by its type and never
323
+ imports a schema: a schema in a browser script ships Zod — about 27 KB
324
+ compressed — to every shopper's page. The browser-facing values (the basket,
325
+ the variant picker, the OTP machine, tracking, routes, phones, wilayas) are
326
+ free of schemas, and a test bundles them the way a theme's build does to keep
327
+ them so. A page can be cached apart from its scripts, so where the browser
328
+ reads a value another version may have shaped, it uses a reader —
329
+ `readShippingRates`, `readCouponOutcome` — that accepts exactly what the
330
+ schema accepts, held to it by a property test, and Zod stays on the server. A theme with an endpoint of its own still validates it — on the
331
+ server, where the request arrives.
332
+
317
333
  ### The namespaces
318
334
 
319
335
  | Namespace | Use it for | Important result shapes |
@@ -677,6 +693,36 @@ The picker reads only `variations`, `active`, `inventory`, `position` and
677
693
  `isDefault` of each variant (`PickerVariant`), so a page may hand it a
678
694
  lighter copy.
679
695
 
696
+ `getProductView(product)` opens on the default variant: its price, and its
697
+ stock for `isOutOfStock` and `isLowStock`, so a product whose first size is
698
+ nearly gone says so even when another size has plenty.
699
+
700
+ ### Search engines and shared links
701
+
702
+ A product carries the title and description its merchant wrote for search
703
+ engines (`metaTitle`, `metaDescription`, in the client's language; null when
704
+ none was written). `productMeta(product)` gives what a page puts in its
705
+ `<title>` and `<meta name="description">`: what the merchant wrote, else the
706
+ product's name and the start of its description as plain text — never markup
707
+ — within `SEO_TEXT_LIMITS` (60 and 160 characters). Add the store's name to
708
+ the title as the theme's design wants.
709
+
710
+ `productStructuredData(product, { url, storeName })` gives its schema.org
711
+ `Product` for rich results: photos, SKU, its price and stock as an `Offer`, or
712
+ an `AggregateOffer` when its variants cost different amounts, and its rating
713
+ once reviewed. Write it with `jsonLd(value)`, which escapes what could end the
714
+ script:
715
+
716
+ ~~~astro
717
+ ---
718
+ import { jsonLd, productMeta, productStructuredData } from "@wocart/storefront";
719
+ const meta = productMeta(product);
720
+ ---
721
+ <title>{meta.title} | {store.config.name}</title>
722
+ {meta.description && <meta name="description" content={meta.description} />}
723
+ <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url: Astro.url.href, storeName: store.config.name }))} />
724
+ ~~~
725
+
680
726
  ### Product lists
681
727
 
682
728
  ~~~ts
@@ -709,10 +755,46 @@ const onSale = await store.products.list({ onSale: true, limit: 8 });
709
755
  - `featured: true` and `ProductList.storeFeatured` are deprecated: they mean
710
756
  the `featured` collection.
711
757
 
758
+ A collection's page also has its size and a photo: `collections.get(handle)`
759
+ answers `productCount` (what a shopper sees in it) and `image` (its first
760
+ listed product's first photo, a `ProductImage`), both absent from a server
761
+ that predates them.
762
+
763
+ **Pages, totals and order.** `products.page(filters)` answers one page,
764
+ whether another follows, and how many products the whole listing holds
765
+ (`total`, null from a server that predates counts) — enough for "page 2 of 5"
766
+ or "36 products":
767
+
768
+ ~~~ts
769
+ const page = await store.products.page({ collection: "best-sellers", sort: "price-ascending", limit: 24, offset: 24 });
770
+ // page.items, page.hasNext, page.total
771
+ ~~~
772
+
773
+ `sort` is one of `PRODUCT_SORTS` — `newest`, `price-ascending`,
774
+ `price-descending` — for a theme's sort control.
775
+
776
+ `DEFAULT_COLLECTION_HANDLES` names the collections every store starts with,
777
+ the likeliest to hold products first, for a page that needs one and was named
778
+ none. Without one a listing keeps
779
+ its own order: a collection's, a search's best match first, else newest. A
780
+ server that predates sorting ignores it.
781
+
782
+ **Search.** `products.search(text, filters)` answers a page of the products
783
+ whose name — in the client's language or the catalog's — or tags hold every
784
+ word, with the same filters, sort and paging; names that hold every word come
785
+ first. Blank text finds nothing without asking. Search has its own path, so a
786
+ server that predates it answers an empty page (reported through `onError`),
787
+ never the whole catalog:
788
+
789
+ ~~~ts
790
+ const results = await store.products.search(Astro.url.searchParams.get("q") ?? "", { limit: 24 });
791
+ ~~~
792
+
712
793
  Product lists return ProductList objects. ProductList and Product are
713
794
  different shapes: a list item is not a detail object and must not be cast to
714
795
  Product. ProductList carries list-oriented fields such as coverImage and
715
796
  reviewStats, while variantOptions and tags remain their raw serialized form.
797
+ Read tags with `productTags(product)`, which gives a list from either shape.
716
798
 
717
799
  The limit is optional. An explicit limit of 0 is preserved, so do not replace
718
800
  it with a truthiness fallback.
@@ -795,6 +877,41 @@ const { blob, durationMs } = await recorder.stop();
795
877
  `canRecordVoice()` says whether to show a record button at all; a shopper can
796
878
  also attach an audio file. Send a recording with its `durationMs`.
797
879
 
880
+ **The whole flow, from the browser.** `createReviewSession` runs the three
881
+ steps through the theme's own review routes (see the storefront routes
882
+ below): it keeps the proof and the upload grant between them, shrinks a
883
+ photo before sending it, reports an upload's progress, tries again after a
884
+ dropped connection or a server failure (twice, after 2 s then 4 s), and
885
+ answers every refusal as a code for the form to word. It never throws.
886
+
887
+ ~~~ts
888
+ import { createReviewSession } from "@wocart/storefront/review-media";
889
+ import { REVIEW_REFUSAL_CODES, REVIEW_TEXT_LIMITS, isOrderNumber } from "@wocart/storefront";
890
+
891
+ const session = createReviewSession({
892
+ routes: { eligibility: "/api/reviews/eligibility", upload: "/api/reviews/upload", submit: "/api/reviews/submit" },
893
+ });
894
+
895
+ const found = await session.find({ orderNumber, phone });
896
+ if (found.status === "refused") return say(found.code); // null: no answer came
897
+
898
+ const upload = session.upload(file, { kind: "image", onProgress: (share) => bar.style.inlineSize = `${share * 100}%` });
899
+ remove.onclick = () => upload.cancel();
900
+ const sent = await upload.result; // { status: "sent", uploadId } | refused | cancelled
901
+
902
+ const submitted = await session.submit({ productId, rating, body, title, uploads: [uploadId] });
903
+ // submitted.status === "submitted": submitted.result.visibility
904
+ ~~~
905
+
906
+ `REVIEW_REFUSAL_CODES` lists every code a step can be refused with — word
907
+ each one, and keep a general sentence for a code a newer server adds. A
908
+ malformed order number is refused before any request, with the server's own
909
+ `VALIDATION_FAILED`; `isOrderNumber(value)` and `ORDER_NUMBER_PATTERN` let a
910
+ form say so as the shopper types. `REVIEW_TEXT_LIMITS` holds the lengths the
911
+ server accepts (`bodyMin`, `bodyMax`, `titleMax`) for the form's hints and
912
+ `maxlength`. `session.reset()` forgets the order and cancels what is still
913
+ sending, when the shopper changes order.
914
+
798
915
  ## Categories, legal pages, and landing pages
799
916
 
800
917
  ### Categories
@@ -964,6 +1081,10 @@ const communes = await store.geo.communes(wilayaId); // server rows, cached
964
1081
  // zero-network rows for the checkout's locality field:
965
1082
  import { communesForWilaya } from "@wocart/storefront";
966
1083
  const rows = communesForWilaya(wilayaId); // Commune[] — [] if unknown
1084
+
1085
+ // a selected wilaya, checked in the browser before its communes are asked for:
1086
+ import { isWilayaId } from "@wocart/storefront";
1087
+ if (isWilayaId(Number(select.value))) loadCommunes(Number(select.value));
967
1088
  ~~~
968
1089
 
969
1090
  Wilayas are local and do not use the network. `geo.communes()` reads the
@@ -1054,6 +1175,20 @@ The basket persists under `DEFAULT_BASKET_STORAGE_KEY` and holds at most
1054
1175
  `MAX_BASKET_LINES` lines of at most `MAX_BASKET_LINE_QUANTITY` units each —
1055
1176
  the same caps the server re-checks.
1056
1177
 
1178
+ An offer chosen piece by piece — three pyjamas in two sizes — goes in with
1179
+ `addSelections`: the product's line, then the per-piece choice exactly as an
1180
+ order's `variantSelections` carries it. Each variant becomes one line holding
1181
+ its pieces, all carrying the offer, in one change (listeners hear once). It
1182
+ is all or nothing: when the basket has no room for every new line, nothing is
1183
+ added, so an offer is never half in the basket.
1184
+
1185
+ ~~~ts
1186
+ basket.addSelections(
1187
+ { productId, productName, unitPrice, offerId: "three-for-two", href, image },
1188
+ [{ variantId: "6m", variantLabel: "6 mois" }, { variantId: "12m", variantLabel: "12 mois" }, { variantId: "6m", variantLabel: "6 mois" }],
1189
+ );
1190
+ ~~~
1191
+
1057
1192
  ### Single-product order versus basket order
1058
1193
 
1059
1194
  An order is either:
@@ -1137,6 +1272,31 @@ the code's field.
1137
1272
  A shareable link carries a code in the query (`COUPON_URL_PARAM`, `?coupon=RAMADAN10`);
1138
1273
  `couponFromUrl(url)` reads it so the theme can fill the code box.
1139
1274
 
1275
+ How a refusal reads is the platform's too: `couponRefusalTone(reason)` is
1276
+ `"hint"` when the code is good but this basket does not meet it yet — a phone
1277
+ to type, a product under an offer, a minimum not reached, delivery already
1278
+ free (`COUPON_HINT_REASONS`) — and `"refused"` when the code itself cannot be
1279
+ used, including a reason this release does not know. `COUPON_REFUSAL_REASONS`
1280
+ lists every reason the platform names, for a theme to word each one.
1281
+
1282
+ **Asking from the browser.** An order form asks again whenever the shopper
1283
+ changes something, and only the latest question counts. `createQuoter` posts
1284
+ to the theme's cart-validate route and says which answer to show:
1285
+
1286
+ ~~~ts
1287
+ import { createQuoter } from "@wocart/storefront";
1288
+
1289
+ const quoter = createQuoter({ endpoint: "/api/cart/validate" });
1290
+ const answer = await quoter.quote({ items: basket.toOrderItems() }, { destination, couponCode, phone });
1291
+ if (answer.status === "fresh") render(answer.quote); // a CartValidation
1292
+ else if (answer.status === "failed") showError(); // the latest question got no usable answer
1293
+ // "stale": a newer question was asked meanwhile — keep what is shown
1294
+ ~~~
1295
+
1296
+ `readCartQuote(value)` reads a quote the way the quoter does, for a quote a
1297
+ page carries: exactly what `CartValidationSchema` accepts, without a
1298
+ validator in the shopper's page.
1299
+
1140
1300
  ### OTP
1141
1301
 
1142
1302
  Use OTP only when config.otpEnabled says the store requires or offers it.
@@ -1182,12 +1342,12 @@ The SDK exports `createOtpMachine` to manage the complete interactive WhatsApp
1182
1342
  verification lifecycle without managing timers and attempts manually. It talks
1183
1343
  to your own same-origin OTP routes through a transport; `mapOtpResponse` turns
1184
1344
  each route's response into the SDK's typed result, and `otpTransportFailure`
1185
- covers a request that never completed:
1345
+ covers a request that never completed. The routes are the SDK's own
1346
+ (`createStorefrontRoutes`), whose client checked the answer on the server, so
1347
+ `mapOtpResponse` takes no schema here; pass one only for an endpoint of your own:
1186
1348
 
1187
1349
  ~~~ts
1188
1350
  import {
1189
- OtpSendResultSchema,
1190
- OtpVerifyResultSchema,
1191
1351
  STOREFRONT_OPERATIONS,
1192
1352
  createOtpMachine,
1193
1353
  createOtpTransport,
@@ -1195,14 +1355,14 @@ import {
1195
1355
  otpTransportFailure,
1196
1356
  } from "@wocart/storefront";
1197
1357
 
1198
- async function post(path, body, schema, operation) {
1358
+ async function post(path, body, operation) {
1199
1359
  try {
1200
1360
  const response = await fetch(path, {
1201
1361
  method: "POST",
1202
1362
  headers: { "Content-Type": "application/json" },
1203
1363
  body: JSON.stringify(body),
1204
1364
  });
1205
- return await mapOtpResponse(response, { schema, operation, fallbackError: "Please try again" });
1365
+ return await mapOtpResponse(response, { operation, fallbackError: "Please try again" });
1206
1366
  } catch (error) {
1207
1367
  return otpTransportFailure(error, operation, "Please try again");
1208
1368
  }
@@ -1211,9 +1371,9 @@ async function post(path, body, schema, operation) {
1211
1371
  const otp = createOtpMachine({
1212
1372
  transport: createOtpTransport({
1213
1373
  send: (phone) =>
1214
- post("/api/otp/send", { phone }, OtpSendResultSchema, STOREFRONT_OPERATIONS.ordersOtpSend),
1374
+ post("/api/otp/send", { phone }, STOREFRONT_OPERATIONS.ordersOtpSend),
1215
1375
  verify: (phone, requestId, code) =>
1216
- post("/api/otp/verify", { phone, requestId, code }, OtpVerifyResultSchema, STOREFRONT_OPERATIONS.ordersOtpVerify),
1376
+ post("/api/otp/verify", { phone, requestId, code }, STOREFRONT_OPERATIONS.ordersOtpVerify),
1217
1377
  }),
1218
1378
  initialPhone: "0551234567",
1219
1379
  });
@@ -1243,6 +1403,24 @@ tracking returns pixelId and an event of Purchase or Lead, or null. If it is
1243
1403
  null, fire no conversion event. Do not infer an event from a successful HTTP
1244
1404
  response alone.
1245
1405
 
1406
+ **Where an order is, for the shopper.** A shopper has no account: the order's
1407
+ number and the phone it was placed with prove it is theirs, as for a review.
1408
+
1409
+ ~~~ts
1410
+ const found = await store.orders.status({ orderNumber, phone }, forwardedHeaders);
1411
+ if (found.success) {
1412
+ // found.data.stage: placed, confirmed, preparing, shipped, out_for_delivery,
1413
+ // delivered, returned (refused at the door) or cancelled — SHOPPER_ORDER_STAGES
1414
+ // found.data.lines, found.data.total, found.data.placedAt
1415
+ // found.data.tracking: { number, carrier } once the carrier has the parcel
1416
+ }
1417
+ // ORDER_NOT_FOUND: a wrong number or a wrong phone, answered the same.
1418
+ // ORDER_LOOKUP_RATE_LIMITED: too many tries for the order or the shopper.
1419
+ ~~~
1420
+
1421
+ It never answers the address, the notes or the merchant's own statuses.
1422
+ From the browser, use the storefront routes' `orderStatus`.
1423
+
1246
1424
  ### Meta browser primitives
1247
1425
 
1248
1426
  The payload builders are pure and browser-safe. Use the server-validated value
@@ -1268,6 +1446,26 @@ Use these only when the theme has a real abandoned-checkout flow. Keep the
1268
1446
  normal mutation result handling and never block order creation on optional
1269
1447
  analytics.
1270
1448
 
1449
+ In the browser, `createBrowserAbandonedCheckoutTracker` does the capturing:
1450
+ the checkout's id for the tab in `sessionStorage`
1451
+ (`ABANDONED_CHECKOUT_SESSION_KEY`), a capture after the shopper pauses, the
1452
+ Meta cookies, a `keepalive` POST to the theme's route, and a beacon when the
1453
+ shopper leaves. Blocked storage or a failed send never touches the order.
1454
+
1455
+ ~~~ts
1456
+ import { convertAbandonedCheckout, createBrowserAbandonedCheckoutTracker } from "@wocart/storefront";
1457
+
1458
+ const tracker = createBrowserAbandonedCheckoutTracker({ endpoint: "/api/abandoned" });
1459
+ nameInput.addEventListener("input", () => tracker.schedule(snapshot()));
1460
+ addEventListener("pagehide", () => tracker.captureOnExit(snapshot()));
1461
+
1462
+ // On the thank-you page: the checkout became this order.
1463
+ convertAbandonedCheckout({ endpoint: "/api/abandoned/convert", orderId, orderNumber });
1464
+ ~~~
1465
+
1466
+ `createAbandonedCheckoutTracker` is the same policy with every browser
1467
+ dependency passed in, for an unusual host.
1468
+
1271
1469
  ## Astro actions and form handling
1272
1470
 
1273
1471
  The SDK cannot import Astro virtual modules, so a theme owns the thin Astro
@@ -1375,6 +1573,60 @@ the next page with no result: the thank-you redirect never fires, and a
1375
1573
  refused order shows no message. resolveActionRedirectDecision, the earlier
1376
1574
  name, is deprecated and now always answers next.
1377
1575
 
1576
+ ### The request's store and language
1577
+
1578
+ `resolveStoreRequest` decides which store a request renders and in which
1579
+ language, with the rules every storefront needs: a page takes its language
1580
+ from its path (`/fr/products/robe`); the primary's own prefix redirects for
1581
+ good to its one address, and a language the store stopped publishing
1582
+ redirects for now to the primary page; an endpoint (`/api/…`) or a server
1583
+ island takes the language of the page that asked, from its same-origin
1584
+ `Referer`, and is never redirected or rewritten. It answers data — the
1585
+ request's client, config, language, routing and unprefixed path — and the
1586
+ theme builds its own context from it:
1587
+
1588
+ ~~~ts
1589
+ // src/middleware.ts
1590
+ import { resolvePreviewStore, resolveStoreRequest, runFormAction } from "@wocart/storefront/astro";
1591
+
1592
+ const options = { client: createStore, fallback: FALLBACK_CONFIG }; // the theme's one client factory, and its shell config
1593
+
1594
+ export const onRequest = defineMiddleware(async (context, next) => {
1595
+ if (context.url.pathname === THEME_PREVIEW_PATH) {
1596
+ const preview = await readThemePreview(context.request);
1597
+ if (!preview.ok) return new Response(null, { status: preview.status, headers: { "Cache-Control": "no-store" } });
1598
+ context.locals.store = buildContext(await resolvePreviewStore(preview.draft, options));
1599
+ return next();
1600
+ }
1601
+ const request = await resolveStoreRequest({ url: context.url, referer: context.request.headers.get("Referer") }, options);
1602
+ if (request.kind === "redirect") return context.redirect(request.location, request.status);
1603
+ context.locals.store = buildContext(request);
1604
+ // … runFormAction as above …
1605
+ return request.rewrite ? next(request.rewrite) : next();
1606
+ });
1607
+ ~~~
1608
+
1609
+ `client(locale?)` makes the request's client, in a language or the
1610
+ store's primary; `fallback` is the config rendered when the store cannot be
1611
+ read — a shell with the checkout off. `embedded(pathname)` overrides which
1612
+ paths serve part of a page. `resolvePreviewStore(draft, options)` is the
1613
+ editor's preview: the draft's language when the store serves it, the draft's
1614
+ settings and text over the saved ones, tracking off.
1615
+
1616
+ ### A page whose resource does not exist
1617
+
1618
+ A product, category, collection or page that is not there answers the
1619
+ theme's not-found page with 404 — and is never cached, or a product published
1620
+ a minute later keeps answering "not found":
1621
+
1622
+ ~~~astro
1623
+ ---
1624
+ import { notFound } from "@wocart/storefront/astro";
1625
+ const product = await Astro.locals.store.client.products.get(Astro.params.handle ?? "");
1626
+ if (!product) return notFound(Astro);
1627
+ ---
1628
+ ~~~
1629
+
1378
1630
  ## Browser-facing proxy routes
1379
1631
 
1380
1632
  Browser code calls same-origin theme routes, which call the platform with the
@@ -1382,8 +1634,8 @@ server-only API key. The key never reaches the browser.
1382
1634
 
1383
1635
  ### The storefront routes, ready to export
1384
1636
 
1385
- createStorefrontRoutes returns the eight routes every storefront needs. Bind
1386
- them once to the request's own client:
1637
+ createStorefrontRoutes returns the routes every storefront needs. Bind them
1638
+ once to the request's own client:
1387
1639
 
1388
1640
  ~~~ts
1389
1641
  // src/theme/api-routes.ts
@@ -1415,12 +1667,14 @@ export const POST = routes.otpSend;
1415
1667
  | `reviewEligibility` | POST | `{ orderNumber, phone, turnstileToken? }` | `{ data: ReviewEligibility }` |
1416
1668
  | `reviewUpload` | PUT | the file as the body; headers `X-Review-Grant`, `X-Media-Duration-Ms` | 201 `{ data: ReviewUpload }` (streamed on, never buffered) |
1417
1669
  | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
1670
+ | `orderStatus` | POST | `{ orderNumber, phone }` | `{ data: ShopperOrderStatus }` |
1418
1671
 
1419
1672
  What every route gets right:
1420
1673
 
1421
1674
  - every POST and PUT refuses a cross-origin request with 403;
1422
- - the OTP routes and abandoned tracking forward the shopper's address and
1423
- user agent, so the server limits and attributes per shopper;
1675
+ - the OTP routes, abandoned tracking and the order status forward the
1676
+ shopper's address and user agent, so the server limits and attributes per
1677
+ shopper;
1424
1678
  - input is bounded before it is sent, and a malformed body is a 400 that never
1425
1679
  reaches the server;
1426
1680
  - a refusal answers with the status the server's category implies — 400 for
@@ -1517,6 +1771,33 @@ The image utility accepts the original media URL and can target a media
1517
1771
  domain. Use it to produce appropriately sized URLs rather than downloading
1518
1772
  original assets into every card.
1519
1773
 
1774
+ For an image whose width changes with the screen, give the browser widths and
1775
+ let `sizes` choose — a phone then downloads a phone-sized file.
1776
+ `mediaImageSrcset(src, widths, { mediaDomain, ratio, quality })` gives one
1777
+ size per width with width descriptors, cropped to `ratio` (height ÷ width)
1778
+ when given; null off the media domain:
1779
+
1780
+ ~~~astro
1781
+ <img
1782
+ src={mediaImageUrl(src, { width: 720, format: "auto" }, mediaDomain) ?? src}
1783
+ srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { mediaDomain, ratio: 1.25 }) ?? undefined}
1784
+ sizes="(min-width: 1024px) 25vw, 50vw"
1785
+ alt="" loading="lazy" />
1786
+ ~~~
1787
+
1788
+ A photo a merchant picks for a section is stored as a media key with its alt
1789
+ text, size and focal point. `pictureOf(image, mediaDomain)` turns it into what
1790
+ an `<img>` needs — `src`, `alt` (empty when none was written), `width`,
1791
+ `height` and `position`, the focal point as CSS `object-position` — or null
1792
+ when none is picked:
1793
+
1794
+ ~~~astro
1795
+ ---
1796
+ const picture = pictureOf(props.image, mediaDomain);
1797
+ ---
1798
+ {picture && <img src={picture.src} alt={picture.alt} width={picture.width} height={picture.height} style={{ objectPosition: picture.position }} />}
1799
+ ~~~
1800
+
1520
1801
  ### Which photo a product is
1521
1802
 
1522
1803
  `coverImage` is nullable and a merchant who uploads a gallery may never pick
@@ -1608,6 +1889,27 @@ Three rules come with it, and a theme must not re-implement them:
1608
1889
  visible rather than silently swallowed. The cap and the token list are the
1609
1890
  same ones the dashboard enforces.
1610
1891
 
1892
+ ## The sitemap
1893
+
1894
+ `sitemapXml(paths, routing, origin)` writes a multilingual sitemap: one
1895
+ `<url>` per page per published language, each naming every language version
1896
+ and `x-default`. `catalogPaths(client, config)` lists what it holds — home,
1897
+ the catalog, each category, every product (read page by page) and each
1898
+ published text page; landing pages stay out, as ad destinations. Both are in
1899
+ `@wocart/storefront/server`:
1900
+
1901
+ ~~~ts
1902
+ // src/pages/sitemap.xml.ts
1903
+ import { catalogPaths, sitemapXml } from "@wocart/storefront/server";
1904
+
1905
+ export const GET: APIRoute = async ({ locals, url }) => {
1906
+ const { client, config, routing } = locals.store;
1907
+ return new Response(sitemapXml(await catalogPaths(client, config), routing, url.origin), {
1908
+ headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600" },
1909
+ });
1910
+ };
1911
+ ~~~
1912
+
1611
1913
  ## Previewing a draft for an editor
1612
1914
 
1613
1915
  A visual editor shows a merchant's unsaved changes on the store's own
@@ -19,4 +19,49 @@ export interface AbandonedCheckoutTracker {
19
19
  captureOnExit(snapshot: AbandonedCheckoutSnapshot): void;
20
20
  isCapturable(snapshot: AbandonedCheckoutSnapshot): boolean;
21
21
  }
22
+ /**
23
+ * Where the browser keeps the checkout's id for the tab (`sessionStorage`):
24
+ * every capture of one checkout updates one record, and the thank-you page
25
+ * marks that record converted.
26
+ */
27
+ export declare const ABANDONED_CHECKOUT_SESSION_KEY = "cod_session_id";
22
28
  export declare function createAbandonedCheckoutTracker(ports: AbandonedCheckoutPorts): AbandonedCheckoutTracker;
29
+ /** What the browser wiring uses of the page; the page's own globals by default. */
30
+ export interface AbandonedCheckoutHost {
31
+ readonly sessionStorage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
32
+ readonly crypto?: Pick<Crypto, "getRandomValues"> & {
33
+ randomUUID?: () => string;
34
+ };
35
+ readonly document?: {
36
+ readonly cookie: string;
37
+ };
38
+ readonly navigator?: {
39
+ sendBeacon?: (url: string, data: Blob) => boolean;
40
+ };
41
+ readonly fetch?: typeof fetch;
42
+ readonly setTimeout: (callback: () => void, delayMs: number) => unknown;
43
+ readonly clearTimeout: (handle: never) => void;
44
+ }
45
+ export interface BrowserAbandonedCheckoutOptions {
46
+ /** The theme's abandoned-checkout route (`createStorefrontRoutes().abandonedUpsert`), e.g. `/api/abandoned`. */
47
+ endpoint: string;
48
+ }
49
+ /**
50
+ * `createAbandonedCheckoutTracker` wired to the page: the checkout's id in
51
+ * `sessionStorage`, timers, the Meta cookies, a `keepalive` POST to the
52
+ * theme's route, and `sendBeacon` when the shopper leaves. A page that blocks
53
+ * storage still captures; nothing it does can fail the order.
54
+ */
55
+ export declare function createBrowserAbandonedCheckoutTracker(options: BrowserAbandonedCheckoutOptions, host?: AbandonedCheckoutHost): AbandonedCheckoutTracker;
56
+ export interface AbandonedCheckoutConversion {
57
+ /** The theme's conversion route (`createStorefrontRoutes().abandonedConvert`), e.g. `/api/abandoned/convert`. */
58
+ endpoint: string;
59
+ orderId: string;
60
+ orderNumber: string;
61
+ }
62
+ /**
63
+ * Marks the tab's checkout converted, from the thank-you page: the merchant
64
+ * no longer sees the order it became as abandoned. The id is used once and
65
+ * forgotten. Best-effort — it never throws and never holds the page.
66
+ */
67
+ export declare function convertAbandonedCheckout(conversion: AbandonedCheckoutConversion, host?: Pick<AbandonedCheckoutHost, "sessionStorage" | "fetch">): void;