@wocart/storefront 1.19.0 → 1.21.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 (56) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/USAGE.md +317 -4
  3. package/dist/abandoned-checkout.d.ts +45 -0
  4. package/dist/abandoned-checkout.js +81 -3
  5. package/dist/answer-readers.d.ts +11 -4
  6. package/dist/answer-readers.js +114 -0
  7. package/dist/astro.d.ts +4 -0
  8. package/dist/astro.js +2 -0
  9. package/dist/basket.d.ts +11 -1
  10. package/dist/basket.js +17 -0
  11. package/dist/catalog.d.ts +24 -0
  12. package/dist/catalog.js +33 -0
  13. package/dist/client/orders.d.ts +10 -1
  14. package/dist/client/orders.js +8 -1
  15. package/dist/client/products.d.ts +13 -3
  16. package/dist/client/products.js +35 -12
  17. package/dist/coupon.d.ts +16 -1
  18. package/dist/coupon.js +37 -0
  19. package/dist/endpoints/storefront-routes.d.ts +8 -2
  20. package/dist/endpoints/storefront-routes.js +9 -2
  21. package/dist/index.d.ts +15 -7
  22. package/dist/index.js +9 -4
  23. package/dist/operations.d.ts +4 -0
  24. package/dist/operations.js +4 -0
  25. package/dist/order-lookup.d.ts +51 -0
  26. package/dist/order-lookup.js +44 -0
  27. package/dist/order-number.d.ts +9 -0
  28. package/dist/order-number.js +11 -0
  29. package/dist/product-view.d.ts +8 -0
  30. package/dist/product-view.js +26 -4
  31. package/dist/quote.d.ts +27 -0
  32. package/dist/quote.js +43 -0
  33. package/dist/review-capture.d.ts +77 -0
  34. package/dist/review-capture.js +172 -0
  35. package/dist/review-media.d.ts +8 -75
  36. package/dist/review-media.js +7 -170
  37. package/dist/review-session.d.ts +97 -0
  38. package/dist/review-session.js +157 -0
  39. package/dist/reviews.d.ts +33 -0
  40. package/dist/reviews.js +41 -0
  41. package/dist/routes.d.ts +2 -0
  42. package/dist/routes.js +3 -0
  43. package/dist/schemas.d.ts +98 -0
  44. package/dist/schemas.js +31 -0
  45. package/dist/seo.d.ts +47 -0
  46. package/dist/seo.js +87 -0
  47. package/dist/server.d.ts +1 -0
  48. package/dist/server.js +1 -0
  49. package/dist/sitemap.d.ts +22 -0
  50. package/dist/sitemap.js +51 -0
  51. package/dist/store-request.d.ts +96 -0
  52. package/dist/store-request.js +102 -0
  53. package/dist/types.d.ts +50 -2
  54. package/dist/utils/image.d.ts +48 -0
  55. package/dist/utils/image.js +51 -0
  56. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,97 @@ 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.21.0 — 2026-10-24
9
+
10
+ ### Added
11
+
12
+ - **A shopper's track-order page, with no script.** `lookupOrder(input,
13
+ request, client)` and `orderLookupInput` in `@wocart/storefront/astro`, for
14
+ an Astro Action a plain form posts: it answers `found` (the order),
15
+ `invalid-number` (not an order number — the server is not asked),
16
+ `invalid-phone`, `not-found` (a wrong number or phone, never saying which),
17
+ `rate-limited` (with the wait) or `failed`, and forwards the shopper's
18
+ address so the server's limit counts per shopper. The `orderStatus` route
19
+ takes the same input.
20
+ - **The track-order page is a platform page.** `STOREFRONT_PAGE_PATHS.trackOrder`
21
+ is `/track-order` — `?order=<number>` fills its number in — so the platform
22
+ can link a shopper to it like the thank-you page, and every theme renders it
23
+ at the same place. `showsWidgetOn` treats it as a store page.
24
+ - `SHOPPER_ORDER_JOURNEY`: the stages from `placed` to `delivered`, in order,
25
+ for a tracking page's progress; `returned` and `cancelled` end an order off
26
+ it.
27
+
28
+ ### Fixed
29
+
30
+ - `notFound(Astro)` type-checks against Astro's own global and endpoint
31
+ context: `NotFoundContext.response.status` is optional, as Astro's
32
+ `ResponseInit` declares it. Nothing changes at runtime.
33
+
34
+ ## 1.20.0 — 2026-10-24
35
+
36
+ What every theme used to write itself is now the package's: the order form's
37
+ quote, abandoned-checkout tracking, the review form, the request's store and
38
+ language, the sitemap, search-engine metadata and images sized for the
39
+ screen. Search, sorting, listing totals, a shopper's order status and product
40
+ SEO fields arrive with the server that answers them; on an older server each
41
+ degrades safely.
42
+
43
+ ### Added
44
+
45
+ - **The order form, from the browser.** `createQuoter({ endpoint })` asks the
46
+ theme's cart-validate route and answers `fresh` (the quote), `stale` (a newer
47
+ question was asked meanwhile) or `failed`. `readCartQuote(value)` reads a
48
+ quote exactly as `CartValidationSchema` does, without Zod.
49
+ - **Coupons.** `couponRefusalTone(reason)` says whether a refusal is a `hint`
50
+ (the basket does not meet the code yet) or `refused` (the code cannot be
51
+ used); `COUPON_HINT_REASONS` and `COUPON_REFUSAL_REASONS` list them.
52
+ - **Abandoned checkouts.** `createBrowserAbandonedCheckoutTracker({ endpoint })`
53
+ wires the tracker to the page — session storage, timers, Meta cookies, a
54
+ keepalive POST and a beacon on exit. `convertAbandonedCheckout(...)` marks the
55
+ tab's checkout converted from the thank-you page;
56
+ `ABANDONED_CHECKOUT_SESSION_KEY` names where its id lives.
57
+ - **Reviews.** `createReviewSession({ routes })` in `@wocart/storefront/review-media`
58
+ proves the purchase, sends each photo (shrunk) or voice note with its
59
+ progress and retries, and submits; every refusal comes back as a code from
60
+ `REVIEW_REFUSAL_CODES`. `REVIEW_TEXT_LIMITS` gives the lengths the server
61
+ accepts; `isOrderNumber(value)` and `ORDER_NUMBER_PATTERN` the shape of an
62
+ order number.
63
+ - **The basket.** `basket.addSelections(line, selections)` adds an offer chosen
64
+ piece by piece — one line per variant, as one change, all or nothing.
65
+ - **Request and preview.** `resolveStoreRequest` and `resolvePreviewStore` in
66
+ `@wocart/storefront/astro` decide the request's store and language (path
67
+ prefixes, the asking page's language for endpoints and server islands, the
68
+ redirects) and the draft's store. `notFound(Astro)` answers a missing
69
+ resource with the not-found page, never cached.
70
+ - **Sitemap.** `sitemapXml(paths, routing, origin)` and `catalogPaths(client,
71
+ config)` in `@wocart/storefront/server`.
72
+ - **Images.** `mediaImageSrcset(src, widths, options)` gives width descriptors
73
+ for `sizes`; `pictureOf(image, mediaDomain)` turns a picked image into its
74
+ URL, alt, size and focal point.
75
+ - **Search engines.** `productMeta(product)`, `productStructuredData(product,
76
+ options)` and `jsonLd(value)`; `SEO_TEXT_LIMITS`. Products carry the
77
+ merchant's `metaTitle` and `metaDescription`, in the client's language.
78
+ - **Catalog.** `products.search(text, filters)`; `sort` on listings
79
+ (`PRODUCT_SORTS`); `ProductPage.total`; a collection's `productCount` and
80
+ `image`; `DEFAULT_COLLECTION_HANDLES`; `productTags(product)` reads tags as a
81
+ list from a listing or a product page.
82
+ - **Order status.** `orders.status({ orderNumber, phone })` and the
83
+ `orderStatus` storefront route: where a shopper's order is
84
+ (`SHOPPER_ORDER_STAGES`), its lines, total and tracking number — never its
85
+ address.
86
+
87
+ ### Changed
88
+
89
+ - `getProductView(product)` reads the default variant's stock for
90
+ `isOutOfStock` and `isLowStock` on a product with variants, not the
91
+ product's total.
92
+
93
+ ### Deprecated
94
+
95
+ - `ProductSchema.costPrice` and `ProductListSchema.costPrice`: always null —
96
+ what the merchant paid is never served to a storefront. A later major drops
97
+ them.
98
+
8
99
  ## 1.19.0 — 2026-10-21
9
100
 
10
101
  What a theme's browser code takes from the package brings no Zod. Validation
package/USAGE.md CHANGED
@@ -111,6 +111,7 @@ break those links.
111
111
  | `landing` | `/lp/:slug` | a landing page | required when the store runs landing pages |
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
+ | `trackOrder` | `/track-order` | where a shopper follows an order by its number and phone; `?order=<number>` fills the number in | required |
114
115
 
115
116
  How a theme implements them, in Astro's own terms:
116
117
 
@@ -693,6 +694,36 @@ The picker reads only `variations`, `active`, `inventory`, `position` and
693
694
  `isDefault` of each variant (`PickerVariant`), so a page may hand it a
694
695
  lighter copy.
695
696
 
697
+ `getProductView(product)` opens on the default variant: its price, and its
698
+ stock for `isOutOfStock` and `isLowStock`, so a product whose first size is
699
+ nearly gone says so even when another size has plenty.
700
+
701
+ ### Search engines and shared links
702
+
703
+ A product carries the title and description its merchant wrote for search
704
+ engines (`metaTitle`, `metaDescription`, in the client's language; null when
705
+ none was written). `productMeta(product)` gives what a page puts in its
706
+ `<title>` and `<meta name="description">`: what the merchant wrote, else the
707
+ product's name and the start of its description as plain text — never markup
708
+ — within `SEO_TEXT_LIMITS` (60 and 160 characters). Add the store's name to
709
+ the title as the theme's design wants.
710
+
711
+ `productStructuredData(product, { url, storeName })` gives its schema.org
712
+ `Product` for rich results: photos, SKU, its price and stock as an `Offer`, or
713
+ an `AggregateOffer` when its variants cost different amounts, and its rating
714
+ once reviewed. Write it with `jsonLd(value)`, which escapes what could end the
715
+ script:
716
+
717
+ ~~~astro
718
+ ---
719
+ import { jsonLd, productMeta, productStructuredData } from "@wocart/storefront";
720
+ const meta = productMeta(product);
721
+ ---
722
+ <title>{meta.title} | {store.config.name}</title>
723
+ {meta.description && <meta name="description" content={meta.description} />}
724
+ <script type="application/ld+json" set:html={jsonLd(productStructuredData(product, { url: Astro.url.href, storeName: store.config.name }))} />
725
+ ~~~
726
+
696
727
  ### Product lists
697
728
 
698
729
  ~~~ts
@@ -725,10 +756,46 @@ const onSale = await store.products.list({ onSale: true, limit: 8 });
725
756
  - `featured: true` and `ProductList.storeFeatured` are deprecated: they mean
726
757
  the `featured` collection.
727
758
 
759
+ A collection's page also has its size and a photo: `collections.get(handle)`
760
+ answers `productCount` (what a shopper sees in it) and `image` (its first
761
+ listed product's first photo, a `ProductImage`), both absent from a server
762
+ that predates them.
763
+
764
+ **Pages, totals and order.** `products.page(filters)` answers one page,
765
+ whether another follows, and how many products the whole listing holds
766
+ (`total`, null from a server that predates counts) — enough for "page 2 of 5"
767
+ or "36 products":
768
+
769
+ ~~~ts
770
+ const page = await store.products.page({ collection: "best-sellers", sort: "price-ascending", limit: 24, offset: 24 });
771
+ // page.items, page.hasNext, page.total
772
+ ~~~
773
+
774
+ `sort` is one of `PRODUCT_SORTS` — `newest`, `price-ascending`,
775
+ `price-descending` — for a theme's sort control.
776
+
777
+ `DEFAULT_COLLECTION_HANDLES` names the collections every store starts with,
778
+ the likeliest to hold products first, for a page that needs one and was named
779
+ none. Without one a listing keeps
780
+ its own order: a collection's, a search's best match first, else newest. A
781
+ server that predates sorting ignores it.
782
+
783
+ **Search.** `products.search(text, filters)` answers a page of the products
784
+ whose name — in the client's language or the catalog's — or tags hold every
785
+ word, with the same filters, sort and paging; names that hold every word come
786
+ first. Blank text finds nothing without asking. Search has its own path, so a
787
+ server that predates it answers an empty page (reported through `onError`),
788
+ never the whole catalog:
789
+
790
+ ~~~ts
791
+ const results = await store.products.search(Astro.url.searchParams.get("q") ?? "", { limit: 24 });
792
+ ~~~
793
+
728
794
  Product lists return ProductList objects. ProductList and Product are
729
795
  different shapes: a list item is not a detail object and must not be cast to
730
796
  Product. ProductList carries list-oriented fields such as coverImage and
731
797
  reviewStats, while variantOptions and tags remain their raw serialized form.
798
+ Read tags with `productTags(product)`, which gives a list from either shape.
732
799
 
733
800
  The limit is optional. An explicit limit of 0 is preserved, so do not replace
734
801
  it with a truthiness fallback.
@@ -811,6 +878,41 @@ const { blob, durationMs } = await recorder.stop();
811
878
  `canRecordVoice()` says whether to show a record button at all; a shopper can
812
879
  also attach an audio file. Send a recording with its `durationMs`.
813
880
 
881
+ **The whole flow, from the browser.** `createReviewSession` runs the three
882
+ steps through the theme's own review routes (see the storefront routes
883
+ below): it keeps the proof and the upload grant between them, shrinks a
884
+ photo before sending it, reports an upload's progress, tries again after a
885
+ dropped connection or a server failure (twice, after 2 s then 4 s), and
886
+ answers every refusal as a code for the form to word. It never throws.
887
+
888
+ ~~~ts
889
+ import { createReviewSession } from "@wocart/storefront/review-media";
890
+ import { REVIEW_REFUSAL_CODES, REVIEW_TEXT_LIMITS, isOrderNumber } from "@wocart/storefront";
891
+
892
+ const session = createReviewSession({
893
+ routes: { eligibility: "/api/reviews/eligibility", upload: "/api/reviews/upload", submit: "/api/reviews/submit" },
894
+ });
895
+
896
+ const found = await session.find({ orderNumber, phone });
897
+ if (found.status === "refused") return say(found.code); // null: no answer came
898
+
899
+ const upload = session.upload(file, { kind: "image", onProgress: (share) => bar.style.inlineSize = `${share * 100}%` });
900
+ remove.onclick = () => upload.cancel();
901
+ const sent = await upload.result; // { status: "sent", uploadId } | refused | cancelled
902
+
903
+ const submitted = await session.submit({ productId, rating, body, title, uploads: [uploadId] });
904
+ // submitted.status === "submitted": submitted.result.visibility
905
+ ~~~
906
+
907
+ `REVIEW_REFUSAL_CODES` lists every code a step can be refused with — word
908
+ each one, and keep a general sentence for a code a newer server adds. A
909
+ malformed order number is refused before any request, with the server's own
910
+ `VALIDATION_FAILED`; `isOrderNumber(value)` and `ORDER_NUMBER_PATTERN` let a
911
+ form say so as the shopper types. `REVIEW_TEXT_LIMITS` holds the lengths the
912
+ server accepts (`bodyMin`, `bodyMax`, `titleMax`) for the form's hints and
913
+ `maxlength`. `session.reset()` forgets the order and cancels what is still
914
+ sending, when the shopper changes order.
915
+
814
916
  ## Categories, legal pages, and landing pages
815
917
 
816
918
  ### Categories
@@ -1074,6 +1176,20 @@ The basket persists under `DEFAULT_BASKET_STORAGE_KEY` and holds at most
1074
1176
  `MAX_BASKET_LINES` lines of at most `MAX_BASKET_LINE_QUANTITY` units each —
1075
1177
  the same caps the server re-checks.
1076
1178
 
1179
+ An offer chosen piece by piece — three pyjamas in two sizes — goes in with
1180
+ `addSelections`: the product's line, then the per-piece choice exactly as an
1181
+ order's `variantSelections` carries it. Each variant becomes one line holding
1182
+ its pieces, all carrying the offer, in one change (listeners hear once). It
1183
+ is all or nothing: when the basket has no room for every new line, nothing is
1184
+ added, so an offer is never half in the basket.
1185
+
1186
+ ~~~ts
1187
+ basket.addSelections(
1188
+ { productId, productName, unitPrice, offerId: "three-for-two", href, image },
1189
+ [{ variantId: "6m", variantLabel: "6 mois" }, { variantId: "12m", variantLabel: "12 mois" }, { variantId: "6m", variantLabel: "6 mois" }],
1190
+ );
1191
+ ~~~
1192
+
1077
1193
  ### Single-product order versus basket order
1078
1194
 
1079
1195
  An order is either:
@@ -1157,6 +1273,31 @@ the code's field.
1157
1273
  A shareable link carries a code in the query (`COUPON_URL_PARAM`, `?coupon=RAMADAN10`);
1158
1274
  `couponFromUrl(url)` reads it so the theme can fill the code box.
1159
1275
 
1276
+ How a refusal reads is the platform's too: `couponRefusalTone(reason)` is
1277
+ `"hint"` when the code is good but this basket does not meet it yet — a phone
1278
+ to type, a product under an offer, a minimum not reached, delivery already
1279
+ free (`COUPON_HINT_REASONS`) — and `"refused"` when the code itself cannot be
1280
+ used, including a reason this release does not know. `COUPON_REFUSAL_REASONS`
1281
+ lists every reason the platform names, for a theme to word each one.
1282
+
1283
+ **Asking from the browser.** An order form asks again whenever the shopper
1284
+ changes something, and only the latest question counts. `createQuoter` posts
1285
+ to the theme's cart-validate route and says which answer to show:
1286
+
1287
+ ~~~ts
1288
+ import { createQuoter } from "@wocart/storefront";
1289
+
1290
+ const quoter = createQuoter({ endpoint: "/api/cart/validate" });
1291
+ const answer = await quoter.quote({ items: basket.toOrderItems() }, { destination, couponCode, phone });
1292
+ if (answer.status === "fresh") render(answer.quote); // a CartValidation
1293
+ else if (answer.status === "failed") showError(); // the latest question got no usable answer
1294
+ // "stale": a newer question was asked meanwhile — keep what is shown
1295
+ ~~~
1296
+
1297
+ `readCartQuote(value)` reads a quote the way the quoter does, for a quote a
1298
+ page carries: exactly what `CartValidationSchema` accepts, without a
1299
+ validator in the shopper's page.
1300
+
1160
1301
  ### OTP
1161
1302
 
1162
1303
  Use OTP only when config.otpEnabled says the store requires or offers it.
@@ -1263,6 +1404,54 @@ tracking returns pixelId and an event of Purchase or Lead, or null. If it is
1263
1404
  null, fire no conversion event. Do not infer an event from a successful HTTP
1264
1405
  response alone.
1265
1406
 
1407
+ **Where an order is, for the shopper.** A shopper has no account: the order's
1408
+ number and the phone it was placed with prove it is theirs, as for a review.
1409
+
1410
+ ~~~ts
1411
+ const found = await store.orders.status({ orderNumber, phone }, forwardedHeaders);
1412
+ if (found.success) {
1413
+ // found.data.stage: placed, confirmed, preparing, shipped, out_for_delivery,
1414
+ // delivered, returned (refused at the door) or cancelled — SHOPPER_ORDER_STAGES;
1415
+ // a progress bar shows SHOPPER_ORDER_JOURNEY, placed to delivered, which
1416
+ // returned and cancelled leave
1417
+ // found.data.lines, found.data.total, found.data.placedAt
1418
+ // found.data.tracking: { number, carrier } once the carrier has the parcel
1419
+ }
1420
+ // ORDER_NOT_FOUND: a wrong number or a wrong phone, answered the same.
1421
+ // ORDER_LOOKUP_RATE_LIMITED: too many tries for the order or the shopper.
1422
+ ~~~
1423
+
1424
+ It never answers the address, the notes or the merchant's own statuses.
1425
+
1426
+ A theme's track-order page needs no script: an Astro Action posted by a plain
1427
+ form, with the outcome already worded in the few cases a page shows.
1428
+
1429
+ ~~~ts
1430
+ // src/actions/index.ts
1431
+ import { defineAction } from "astro:actions";
1432
+ import { lookupOrder, orderLookupInput } from "@wocart/storefront/astro";
1433
+
1434
+ export const server = {
1435
+ orderStatus: defineAction({
1436
+ accept: "form",
1437
+ input: orderLookupInput,
1438
+ handler: (input, context) => lookupOrder(input, context.request, context.locals.store.client),
1439
+ }),
1440
+ };
1441
+ ~~~
1442
+
1443
+ ~~~astro
1444
+ ---
1445
+ // src/pages/track.astro — the page renders from the same POST
1446
+ const lookup = Astro.getActionResult(actions.orderStatus)?.data;
1447
+ // lookup?.status: "found" (lookup.order), "invalid-number" (the server is not
1448
+ // asked), "invalid-phone", "not-found", "rate-limited" (retryAfterSeconds) or "failed"
1449
+ ---
1450
+ <form method="POST" action={actions.orderStatus}>…</form>
1451
+ ~~~
1452
+
1453
+ From the browser, use the storefront routes' `orderStatus`.
1454
+
1266
1455
  ### Meta browser primitives
1267
1456
 
1268
1457
  The payload builders are pure and browser-safe. Use the server-validated value
@@ -1288,6 +1477,26 @@ Use these only when the theme has a real abandoned-checkout flow. Keep the
1288
1477
  normal mutation result handling and never block order creation on optional
1289
1478
  analytics.
1290
1479
 
1480
+ In the browser, `createBrowserAbandonedCheckoutTracker` does the capturing:
1481
+ the checkout's id for the tab in `sessionStorage`
1482
+ (`ABANDONED_CHECKOUT_SESSION_KEY`), a capture after the shopper pauses, the
1483
+ Meta cookies, a `keepalive` POST to the theme's route, and a beacon when the
1484
+ shopper leaves. Blocked storage or a failed send never touches the order.
1485
+
1486
+ ~~~ts
1487
+ import { convertAbandonedCheckout, createBrowserAbandonedCheckoutTracker } from "@wocart/storefront";
1488
+
1489
+ const tracker = createBrowserAbandonedCheckoutTracker({ endpoint: "/api/abandoned" });
1490
+ nameInput.addEventListener("input", () => tracker.schedule(snapshot()));
1491
+ addEventListener("pagehide", () => tracker.captureOnExit(snapshot()));
1492
+
1493
+ // On the thank-you page: the checkout became this order.
1494
+ convertAbandonedCheckout({ endpoint: "/api/abandoned/convert", orderId, orderNumber });
1495
+ ~~~
1496
+
1497
+ `createAbandonedCheckoutTracker` is the same policy with every browser
1498
+ dependency passed in, for an unusual host.
1499
+
1291
1500
  ## Astro actions and form handling
1292
1501
 
1293
1502
  The SDK cannot import Astro virtual modules, so a theme owns the thin Astro
@@ -1395,6 +1604,60 @@ the next page with no result: the thank-you redirect never fires, and a
1395
1604
  refused order shows no message. resolveActionRedirectDecision, the earlier
1396
1605
  name, is deprecated and now always answers next.
1397
1606
 
1607
+ ### The request's store and language
1608
+
1609
+ `resolveStoreRequest` decides which store a request renders and in which
1610
+ language, with the rules every storefront needs: a page takes its language
1611
+ from its path (`/fr/products/robe`); the primary's own prefix redirects for
1612
+ good to its one address, and a language the store stopped publishing
1613
+ redirects for now to the primary page; an endpoint (`/api/…`) or a server
1614
+ island takes the language of the page that asked, from its same-origin
1615
+ `Referer`, and is never redirected or rewritten. It answers data — the
1616
+ request's client, config, language, routing and unprefixed path — and the
1617
+ theme builds its own context from it:
1618
+
1619
+ ~~~ts
1620
+ // src/middleware.ts
1621
+ import { resolvePreviewStore, resolveStoreRequest, runFormAction } from "@wocart/storefront/astro";
1622
+
1623
+ const options = { client: createStore, fallback: FALLBACK_CONFIG }; // the theme's one client factory, and its shell config
1624
+
1625
+ export const onRequest = defineMiddleware(async (context, next) => {
1626
+ if (context.url.pathname === THEME_PREVIEW_PATH) {
1627
+ const preview = await readThemePreview(context.request);
1628
+ if (!preview.ok) return new Response(null, { status: preview.status, headers: { "Cache-Control": "no-store" } });
1629
+ context.locals.store = buildContext(await resolvePreviewStore(preview.draft, options));
1630
+ return next();
1631
+ }
1632
+ const request = await resolveStoreRequest({ url: context.url, referer: context.request.headers.get("Referer") }, options);
1633
+ if (request.kind === "redirect") return context.redirect(request.location, request.status);
1634
+ context.locals.store = buildContext(request);
1635
+ // … runFormAction as above …
1636
+ return request.rewrite ? next(request.rewrite) : next();
1637
+ });
1638
+ ~~~
1639
+
1640
+ `client(locale?)` makes the request's client, in a language or the
1641
+ store's primary; `fallback` is the config rendered when the store cannot be
1642
+ read — a shell with the checkout off. `embedded(pathname)` overrides which
1643
+ paths serve part of a page. `resolvePreviewStore(draft, options)` is the
1644
+ editor's preview: the draft's language when the store serves it, the draft's
1645
+ settings and text over the saved ones, tracking off.
1646
+
1647
+ ### A page whose resource does not exist
1648
+
1649
+ A product, category, collection or page that is not there answers the
1650
+ theme's not-found page with 404 — and is never cached, or a product published
1651
+ a minute later keeps answering "not found":
1652
+
1653
+ ~~~astro
1654
+ ---
1655
+ import { notFound } from "@wocart/storefront/astro";
1656
+ const product = await Astro.locals.store.client.products.get(Astro.params.handle ?? "");
1657
+ if (!product) return notFound(Astro);
1658
+ ---
1659
+ ~~~
1660
+
1398
1661
  ## Browser-facing proxy routes
1399
1662
 
1400
1663
  Browser code calls same-origin theme routes, which call the platform with the
@@ -1402,8 +1665,8 @@ server-only API key. The key never reaches the browser.
1402
1665
 
1403
1666
  ### The storefront routes, ready to export
1404
1667
 
1405
- createStorefrontRoutes returns the eight routes every storefront needs. Bind
1406
- them once to the request's own client:
1668
+ createStorefrontRoutes returns the routes every storefront needs. Bind them
1669
+ once to the request's own client:
1407
1670
 
1408
1671
  ~~~ts
1409
1672
  // src/theme/api-routes.ts
@@ -1435,12 +1698,14 @@ export const POST = routes.otpSend;
1435
1698
  | `reviewEligibility` | POST | `{ orderNumber, phone, turnstileToken? }` | `{ data: ReviewEligibility }` |
1436
1699
  | `reviewUpload` | PUT | the file as the body; headers `X-Review-Grant`, `X-Media-Duration-Ms` | 201 `{ data: ReviewUpload }` (streamed on, never buffered) |
1437
1700
  | `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
1701
+ | `orderStatus` | POST | `{ orderNumber, phone }` | `{ data: ShopperOrderStatus }` |
1438
1702
 
1439
1703
  What every route gets right:
1440
1704
 
1441
1705
  - every POST and PUT refuses a cross-origin request with 403;
1442
- - the OTP routes and abandoned tracking forward the shopper's address and
1443
- user agent, so the server limits and attributes per shopper;
1706
+ - the OTP routes, abandoned tracking and the order status forward the
1707
+ shopper's address and user agent, so the server limits and attributes per
1708
+ shopper;
1444
1709
  - input is bounded before it is sent, and a malformed body is a 400 that never
1445
1710
  reaches the server;
1446
1711
  - a refusal answers with the status the server's category implies — 400 for
@@ -1537,6 +1802,33 @@ The image utility accepts the original media URL and can target a media
1537
1802
  domain. Use it to produce appropriately sized URLs rather than downloading
1538
1803
  original assets into every card.
1539
1804
 
1805
+ For an image whose width changes with the screen, give the browser widths and
1806
+ let `sizes` choose — a phone then downloads a phone-sized file.
1807
+ `mediaImageSrcset(src, widths, { mediaDomain, ratio, quality })` gives one
1808
+ size per width with width descriptors, cropped to `ratio` (height ÷ width)
1809
+ when given; null off the media domain:
1810
+
1811
+ ~~~astro
1812
+ <img
1813
+ src={mediaImageUrl(src, { width: 720, format: "auto" }, mediaDomain) ?? src}
1814
+ srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { mediaDomain, ratio: 1.25 }) ?? undefined}
1815
+ sizes="(min-width: 1024px) 25vw, 50vw"
1816
+ alt="" loading="lazy" />
1817
+ ~~~
1818
+
1819
+ A photo a merchant picks for a section is stored as a media key with its alt
1820
+ text, size and focal point. `pictureOf(image, mediaDomain)` turns it into what
1821
+ an `<img>` needs — `src`, `alt` (empty when none was written), `width`,
1822
+ `height` and `position`, the focal point as CSS `object-position` — or null
1823
+ when none is picked:
1824
+
1825
+ ~~~astro
1826
+ ---
1827
+ const picture = pictureOf(props.image, mediaDomain);
1828
+ ---
1829
+ {picture && <img src={picture.src} alt={picture.alt} width={picture.width} height={picture.height} style={{ objectPosition: picture.position }} />}
1830
+ ~~~
1831
+
1540
1832
  ### Which photo a product is
1541
1833
 
1542
1834
  `coverImage` is nullable and a merchant who uploads a gallery may never pick
@@ -1628,6 +1920,27 @@ Three rules come with it, and a theme must not re-implement them:
1628
1920
  visible rather than silently swallowed. The cap and the token list are the
1629
1921
  same ones the dashboard enforces.
1630
1922
 
1923
+ ## The sitemap
1924
+
1925
+ `sitemapXml(paths, routing, origin)` writes a multilingual sitemap: one
1926
+ `<url>` per page per published language, each naming every language version
1927
+ and `x-default`. `catalogPaths(client, config)` lists what it holds — home,
1928
+ the catalog, each category, every product (read page by page) and each
1929
+ published text page; landing pages stay out, as ad destinations. Both are in
1930
+ `@wocart/storefront/server`:
1931
+
1932
+ ~~~ts
1933
+ // src/pages/sitemap.xml.ts
1934
+ import { catalogPaths, sitemapXml } from "@wocart/storefront/server";
1935
+
1936
+ export const GET: APIRoute = async ({ locals, url }) => {
1937
+ const { client, config, routing } = locals.store;
1938
+ return new Response(sitemapXml(await catalogPaths(client, config), routing, url.origin), {
1939
+ headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600" },
1940
+ });
1941
+ };
1942
+ ~~~
1943
+
1631
1944
  ## Previewing a draft for an editor
1632
1945
 
1633
1946
  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;
@@ -1,5 +1,10 @@
1
1
  import { toLocalAlgerianMobile } from "./phone.js";
2
- const SESSION_KEY = "cod_session_id";
2
+ /**
3
+ * Where the browser keeps the checkout's id for the tab (`sessionStorage`):
4
+ * every capture of one checkout updates one record, and the thank-you page
5
+ * marks that record converted.
6
+ */
7
+ export const ABANDONED_CHECKOUT_SESSION_KEY = "cod_session_id";
3
8
  const DEBOUNCE_MS = 3000;
4
9
  export function createAbandonedCheckoutTracker(ports) {
5
10
  let sessionId = readSessionId(ports);
@@ -76,14 +81,87 @@ function readCookie(ports, name) {
76
81
  }
77
82
  function readSessionId(ports) {
78
83
  try {
79
- const existing = ports.storage.getItem(SESSION_KEY);
84
+ const existing = ports.storage.getItem(ABANDONED_CHECKOUT_SESSION_KEY);
80
85
  if (existing)
81
86
  return existing;
82
87
  const created = ports.createId();
83
- ports.storage.setItem(SESSION_KEY, created);
88
+ ports.storage.setItem(ABANDONED_CHECKOUT_SESSION_KEY, created);
84
89
  return created;
85
90
  }
86
91
  catch {
87
92
  return ports.createId();
88
93
  }
89
94
  }
95
+ /**
96
+ * `createAbandonedCheckoutTracker` wired to the page: the checkout's id in
97
+ * `sessionStorage`, timers, the Meta cookies, a `keepalive` POST to the
98
+ * theme's route, and `sendBeacon` when the shopper leaves. A page that blocks
99
+ * storage still captures; nothing it does can fail the order.
100
+ */
101
+ export function createBrowserAbandonedCheckoutTracker(options, host = globalThis) {
102
+ const post = (payload) => (host.fetch ?? globalThis.fetch)(options.endpoint, {
103
+ method: "POST",
104
+ headers: { "Content-Type": "application/json" },
105
+ body: JSON.stringify(payload),
106
+ keepalive: true,
107
+ });
108
+ return createAbandonedCheckoutTracker({
109
+ storage: {
110
+ getItem: (key) => host.sessionStorage?.getItem(key) ?? null,
111
+ setItem: (key, value) => host.sessionStorage?.setItem(key, value),
112
+ },
113
+ createId: () => uuid(host),
114
+ schedule: (callback, delayMs) => host.setTimeout(callback, delayMs),
115
+ cancel: (handle) => host.clearTimeout(handle),
116
+ readCookie: (name) => cookieValue(host.document?.cookie ?? "", name),
117
+ send: async (payload) => {
118
+ await post(payload);
119
+ },
120
+ beacon: (payload) => {
121
+ const sent = host.navigator?.sendBeacon?.(options.endpoint, new Blob([JSON.stringify(payload)], { type: "application/json" })) ?? false;
122
+ if (!sent)
123
+ void post(payload).catch(() => undefined);
124
+ },
125
+ });
126
+ }
127
+ /**
128
+ * Marks the tab's checkout converted, from the thank-you page: the merchant
129
+ * no longer sees the order it became as abandoned. The id is used once and
130
+ * forgotten. Best-effort — it never throws and never holds the page.
131
+ */
132
+ export function convertAbandonedCheckout(conversion, host = globalThis) {
133
+ try {
134
+ const sessionId = host.sessionStorage?.getItem(ABANDONED_CHECKOUT_SESSION_KEY);
135
+ if (!sessionId || !conversion.orderId)
136
+ return;
137
+ host.sessionStorage?.removeItem(ABANDONED_CHECKOUT_SESSION_KEY);
138
+ void (host.fetch ?? globalThis.fetch)(conversion.endpoint, {
139
+ method: "POST",
140
+ headers: { "Content-Type": "application/json" },
141
+ body: JSON.stringify({ sessionId, orderId: conversion.orderId, orderNumber: conversion.orderNumber }),
142
+ keepalive: true,
143
+ }).catch(() => undefined);
144
+ }
145
+ catch {
146
+ return;
147
+ }
148
+ }
149
+ function cookieValue(cookies, name) {
150
+ for (const part of cookies.split(";")) {
151
+ const separator = part.indexOf("=");
152
+ if (separator > 0 && part.slice(0, separator).trim() === name)
153
+ return part.slice(separator + 1).trim() || undefined;
154
+ }
155
+ return undefined;
156
+ }
157
+ /** A version 4 UUID: the platform's `randomUUID`, else one drawn from `getRandomValues`. */
158
+ function uuid(host) {
159
+ const crypto = host.crypto ?? globalThis.crypto;
160
+ if (typeof crypto.randomUUID === "function")
161
+ return crypto.randomUUID();
162
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
163
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
164
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
165
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
166
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
167
+ }