@wocart/storefront 1.19.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.
- package/CHANGELOG.md +65 -0
- package/USAGE.md +286 -4
- package/dist/abandoned-checkout.d.ts +45 -0
- package/dist/abandoned-checkout.js +81 -3
- package/dist/answer-readers.d.ts +11 -4
- package/dist/answer-readers.js +114 -0
- package/dist/astro.d.ts +2 -0
- package/dist/astro.js +1 -0
- package/dist/basket.d.ts +11 -1
- package/dist/basket.js +17 -0
- package/dist/catalog.d.ts +18 -0
- package/dist/catalog.js +27 -0
- package/dist/client/orders.d.ts +10 -1
- package/dist/client/orders.js +8 -1
- package/dist/client/products.d.ts +13 -3
- package/dist/client/products.js +35 -12
- package/dist/coupon.d.ts +16 -1
- package/dist/coupon.js +37 -0
- package/dist/endpoints/storefront-routes.d.ts +8 -2
- package/dist/endpoints/storefront-routes.js +12 -2
- package/dist/index.d.ts +15 -7
- package/dist/index.js +9 -4
- package/dist/operations.d.ts +4 -0
- package/dist/operations.js +4 -0
- package/dist/order-number.d.ts +9 -0
- package/dist/order-number.js +11 -0
- package/dist/product-view.d.ts +8 -0
- package/dist/product-view.js +26 -4
- package/dist/quote.d.ts +27 -0
- package/dist/quote.js +43 -0
- package/dist/review-capture.d.ts +77 -0
- package/dist/review-capture.js +172 -0
- package/dist/review-media.d.ts +8 -75
- package/dist/review-media.js +7 -170
- package/dist/review-session.d.ts +97 -0
- package/dist/review-session.js +157 -0
- package/dist/reviews.d.ts +33 -0
- package/dist/reviews.js +41 -0
- package/dist/schemas.d.ts +98 -0
- package/dist/schemas.js +31 -0
- package/dist/seo.d.ts +47 -0
- package/dist/seo.js +87 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +1 -0
- package/dist/sitemap.d.ts +22 -0
- package/dist/sitemap.js +51 -0
- package/dist/store-request.d.ts +96 -0
- package/dist/store-request.js +102 -0
- package/dist/types.d.ts +50 -2
- package/dist/utils/image.d.ts +48 -0
- package/dist/utils/image.js +51 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,71 @@ 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
|
+
|
|
8
73
|
## 1.19.0 — 2026-10-21
|
|
9
74
|
|
|
10
75
|
What a theme's browser code takes from the package brings no Zod. Validation
|
package/USAGE.md
CHANGED
|
@@ -693,6 +693,36 @@ The picker reads only `variations`, `active`, `inventory`, `position` and
|
|
|
693
693
|
`isDefault` of each variant (`PickerVariant`), so a page may hand it a
|
|
694
694
|
lighter copy.
|
|
695
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
|
+
|
|
696
726
|
### Product lists
|
|
697
727
|
|
|
698
728
|
~~~ts
|
|
@@ -725,10 +755,46 @@ const onSale = await store.products.list({ onSale: true, limit: 8 });
|
|
|
725
755
|
- `featured: true` and `ProductList.storeFeatured` are deprecated: they mean
|
|
726
756
|
the `featured` collection.
|
|
727
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
|
+
|
|
728
793
|
Product lists return ProductList objects. ProductList and Product are
|
|
729
794
|
different shapes: a list item is not a detail object and must not be cast to
|
|
730
795
|
Product. ProductList carries list-oriented fields such as coverImage and
|
|
731
796
|
reviewStats, while variantOptions and tags remain their raw serialized form.
|
|
797
|
+
Read tags with `productTags(product)`, which gives a list from either shape.
|
|
732
798
|
|
|
733
799
|
The limit is optional. An explicit limit of 0 is preserved, so do not replace
|
|
734
800
|
it with a truthiness fallback.
|
|
@@ -811,6 +877,41 @@ const { blob, durationMs } = await recorder.stop();
|
|
|
811
877
|
`canRecordVoice()` says whether to show a record button at all; a shopper can
|
|
812
878
|
also attach an audio file. Send a recording with its `durationMs`.
|
|
813
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
|
+
|
|
814
915
|
## Categories, legal pages, and landing pages
|
|
815
916
|
|
|
816
917
|
### Categories
|
|
@@ -1074,6 +1175,20 @@ The basket persists under `DEFAULT_BASKET_STORAGE_KEY` and holds at most
|
|
|
1074
1175
|
`MAX_BASKET_LINES` lines of at most `MAX_BASKET_LINE_QUANTITY` units each —
|
|
1075
1176
|
the same caps the server re-checks.
|
|
1076
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
|
+
|
|
1077
1192
|
### Single-product order versus basket order
|
|
1078
1193
|
|
|
1079
1194
|
An order is either:
|
|
@@ -1157,6 +1272,31 @@ the code's field.
|
|
|
1157
1272
|
A shareable link carries a code in the query (`COUPON_URL_PARAM`, `?coupon=RAMADAN10`);
|
|
1158
1273
|
`couponFromUrl(url)` reads it so the theme can fill the code box.
|
|
1159
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
|
+
|
|
1160
1300
|
### OTP
|
|
1161
1301
|
|
|
1162
1302
|
Use OTP only when config.otpEnabled says the store requires or offers it.
|
|
@@ -1263,6 +1403,24 @@ tracking returns pixelId and an event of Purchase or Lead, or null. If it is
|
|
|
1263
1403
|
null, fire no conversion event. Do not infer an event from a successful HTTP
|
|
1264
1404
|
response alone.
|
|
1265
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
|
+
|
|
1266
1424
|
### Meta browser primitives
|
|
1267
1425
|
|
|
1268
1426
|
The payload builders are pure and browser-safe. Use the server-validated value
|
|
@@ -1288,6 +1446,26 @@ Use these only when the theme has a real abandoned-checkout flow. Keep the
|
|
|
1288
1446
|
normal mutation result handling and never block order creation on optional
|
|
1289
1447
|
analytics.
|
|
1290
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
|
+
|
|
1291
1469
|
## Astro actions and form handling
|
|
1292
1470
|
|
|
1293
1471
|
The SDK cannot import Astro virtual modules, so a theme owns the thin Astro
|
|
@@ -1395,6 +1573,60 @@ the next page with no result: the thank-you redirect never fires, and a
|
|
|
1395
1573
|
refused order shows no message. resolveActionRedirectDecision, the earlier
|
|
1396
1574
|
name, is deprecated and now always answers next.
|
|
1397
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
|
+
|
|
1398
1630
|
## Browser-facing proxy routes
|
|
1399
1631
|
|
|
1400
1632
|
Browser code calls same-origin theme routes, which call the platform with the
|
|
@@ -1402,8 +1634,8 @@ server-only API key. The key never reaches the browser.
|
|
|
1402
1634
|
|
|
1403
1635
|
### The storefront routes, ready to export
|
|
1404
1636
|
|
|
1405
|
-
createStorefrontRoutes returns the
|
|
1406
|
-
|
|
1637
|
+
createStorefrontRoutes returns the routes every storefront needs. Bind them
|
|
1638
|
+
once to the request's own client:
|
|
1407
1639
|
|
|
1408
1640
|
~~~ts
|
|
1409
1641
|
// src/theme/api-routes.ts
|
|
@@ -1435,12 +1667,14 @@ export const POST = routes.otpSend;
|
|
|
1435
1667
|
| `reviewEligibility` | POST | `{ orderNumber, phone, turnstileToken? }` | `{ data: ReviewEligibility }` |
|
|
1436
1668
|
| `reviewUpload` | PUT | the file as the body; headers `X-Review-Grant`, `X-Media-Duration-Ms` | 201 `{ data: ReviewUpload }` (streamed on, never buffered) |
|
|
1437
1669
|
| `reviewSubmit` | POST | `ReviewInput` | 201 `{ success: true, data: { id, visibility } }` |
|
|
1670
|
+
| `orderStatus` | POST | `{ orderNumber, phone }` | `{ data: ShopperOrderStatus }` |
|
|
1438
1671
|
|
|
1439
1672
|
What every route gets right:
|
|
1440
1673
|
|
|
1441
1674
|
- every POST and PUT refuses a cross-origin request with 403;
|
|
1442
|
-
- the OTP routes
|
|
1443
|
-
user agent, so the server limits and attributes per
|
|
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;
|
|
1444
1678
|
- input is bounded before it is sent, and a malformed body is a 400 that never
|
|
1445
1679
|
reaches the server;
|
|
1446
1680
|
- a refusal answers with the status the server's category implies — 400 for
|
|
@@ -1537,6 +1771,33 @@ The image utility accepts the original media URL and can target a media
|
|
|
1537
1771
|
domain. Use it to produce appropriately sized URLs rather than downloading
|
|
1538
1772
|
original assets into every card.
|
|
1539
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
|
+
|
|
1540
1801
|
### Which photo a product is
|
|
1541
1802
|
|
|
1542
1803
|
`coverImage` is nullable and a merchant who uploads a gallery may never pick
|
|
@@ -1628,6 +1889,27 @@ Three rules come with it, and a theme must not re-implement them:
|
|
|
1628
1889
|
visible rather than silently swallowed. The cap and the token list are the
|
|
1629
1890
|
same ones the dashboard enforces.
|
|
1630
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
|
+
|
|
1631
1913
|
## Previewing a draft for an editor
|
|
1632
1914
|
|
|
1633
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;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { toLocalAlgerianMobile } from "./phone.js";
|
|
2
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|
package/dist/answer-readers.d.ts
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Readers for what this package's server parts hand the browser: the delivery
|
|
3
|
-
* rates a page carries and the coupon outcome
|
|
3
|
+
* rates a page carries, a cart quote, and the coupon outcome in it.
|
|
4
4
|
*
|
|
5
|
-
* The server built
|
|
6
|
-
* the browser need not run those schemas. A page can still be cached apart
|
|
5
|
+
* The server built them with `ShippingRatesSchema`, `CartValidationSchema` and
|
|
6
|
+
* `CouponOutcomeSchema`, so the browser need not run those schemas. A page can still be cached apart
|
|
7
7
|
* from its scripts, so a value of another version's shape is possible; these
|
|
8
8
|
* accept exactly what the schemas accept and return what they would — extra
|
|
9
9
|
* keys left out — without bringing Zod into a shopper's page. A property test
|
|
10
10
|
* holds each reader to its schema.
|
|
11
11
|
*/
|
|
12
|
-
import type { CouponOutcome, ShippingRates } from "./types.js";
|
|
12
|
+
import type { CartValidation, CouponOutcome, ShippingRates } from "./types.js";
|
|
13
|
+
/**
|
|
14
|
+
* A cart quote (`CartValidation`) — the `data` of the theme's cart-validate
|
|
15
|
+
* route — or null when the value is not one. What the quote says is the
|
|
16
|
+
* server's: lines re-priced from the catalog, rewards, the subtotal, the free
|
|
17
|
+
* delivery progress, and, when asked, the delivery fee and the coupon's outcome.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readCartQuote(value: unknown): CartValidation | null;
|
|
13
20
|
/** The delivery rates of a value (`ShippingRates`), or null when it is not a rate table. */
|
|
14
21
|
export declare function readShippingRates(value: unknown): ShippingRates | null;
|
|
15
22
|
/** What a coupon did in a quote (`CouponOutcome`), or null when the value is not one. */
|