@wocart/storefront 1.22.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ 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
+ ## 2.0.0 — 2026-10-30
9
+
10
+ A store's images can live on any media host. The server answers every image
11
+ as a `MediaImage` and the store's config says how its images are delivered;
12
+ the image helpers take that delivery instead of a media domain. Requires a
13
+ store server that answers this shape.
14
+
15
+ ### Changed (breaking)
16
+
17
+ - Every image is a `MediaImage`, `{ key, url, width, height }`: a product
18
+ image (`ProductImageSchema`) and a landing image carry `key` and `url`
19
+ (`src`, `srcSm`, `srcMd`, `srcLg` are gone); a category has `image` (was
20
+ `imageUrl`); `StoreConfig` has `logo` (was `logoUrl`) and `ogImage` as
21
+ images.
22
+ - `StoreConfig.media` (`MediaDelivery`, `{ kind, origin }`): where every key is
23
+ served from, and how it is resized — `imagekit`, `cloudflare`, or `direct`.
24
+ - `mediaUrl(key, delivery)`, `mediaImageUrl(url, params, delivery)`,
25
+ `mediaImageSrcset(url, widths, { delivery })` and `pictureOf(image,
26
+ delivery)` take the store's delivery (was a media domain). `mediaImageUrl`
27
+ builds ImageKit's `?tr=` or Cloudflare's `/cdn-cgi/image/`, and answers
28
+ `null` for a `direct` delivery.
29
+ - `productImageSource` reads `url`.
30
+
31
+ ### Removed
32
+
33
+ - `optimizeImage`, `generateSrcSet`, `IMAGE_SIZES`, `getCardImage`,
34
+ `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`,
35
+ `getLogoImage`, `createImageHelpers` and their types: each took a media
36
+ domain, which no longer exists. Use `mediaImageUrl` and `mediaImageSrcset`
37
+ with the size a page shows. They are removed without a deprecating minor
38
+ because no media domain is left for them to take.
39
+
40
+ ### Added
41
+
42
+ - `isMediaKey`, `MediaImage`, `MediaDelivery`, `MediaDeliveryKind`,
43
+ `MediaImageSchema` and `MediaDeliverySchema`.
44
+
45
+ ### Changed
46
+
47
+ - The README is short and points to the documentation at docs.wocart.store.
48
+
49
+ ## 1.23.0 — 2026-10-25
50
+
51
+ One checkout, one order: a double click, a refreshed submission or a retry
52
+ after a dropped connection no longer places the order twice. And an order
53
+ records the language it was placed in.
54
+
55
+ ### Added
56
+
57
+ - `bindCheckoutKey(form)`: puts a `checkoutKey` on every submission a checkout
58
+ form makes, through its `formdata` event. The key stays the same while the
59
+ shopper changes nothing and is drawn anew once they change any field; it is
60
+ drawn at the first submission, never rendered into a cached page. Returns a
61
+ function that unbinds it.
62
+ - `placeOrderInput` and `CreateOrderInput` take `checkoutKey`, a UUID. The same
63
+ key again answers with the order it already placed, exactly as the first
64
+ time; for another phone it fails with `CHECKOUT_KEY_IN_USE`. Without a key,
65
+ every order is placed as before.
66
+ - `orders.create` sends the client's `locale`: the order records the
67
+ language the shopper checked out in (the primary when the store does not
68
+ publish it), and the customer's messages are written in it.
69
+
8
70
  ## 1.22.0 — 2026-10-24
9
71
 
10
72
  What moving Theme-00 onto the SDK showed every theme needs: an add reported
package/README.md CHANGED
@@ -1,20 +1,13 @@
1
1
  # @wocart/storefront
2
2
 
3
3
  The storefront SDK for [Wocart](https://github.com/bighadj22/codflow), the
4
- cash-on-delivery commerce platform for Algeria. It gives a storefront theme
5
- typed, validated access to a store's catalog, delivery prices, basket,
6
- checkout, OTP and order tracking — and keeps prices, rules and secrets on the
7
- server.
4
+ cash-on-delivery commerce platform for Algeria. It gives a storefront typed,
5
+ validated access to a store's catalog, delivery prices, basket, checkout, OTP,
6
+ order tracking and images — and keeps prices, rules and secrets on the server.
8
7
 
9
- - **Typed and validated.** Every server response is parsed against a schema
10
- before your code sees it.
11
- - **Predictable failures.** Reads never throw: they resolve to `null`, `[]` or
12
- `{}` and report through `onError`. Writes return a result whose failure always
13
- carries a structured `DomainError`.
14
- - **Browser-safe by construction.** The root entry exports no client and no
15
- secret; the store API key lives behind `@wocart/storefront/server`.
16
- - **Framework-neutral core, Astro adapters.** Proxy routes, form actions and
17
- the POST/Redirect/GET middleware decision for Astro, with no Astro import.
8
+ **Documentation: [docs.wocart.store/sdk/storefront](https://docs.wocart.store/sdk/storefront)**
9
+ — also in [French](https://docs.wocart.store/fr/sdk/storefront) and
10
+ [Arabic](https://docs.wocart.store/ar/sdk/storefront).
18
11
 
19
12
  ## Install
20
13
 
@@ -22,123 +15,35 @@ server.
22
15
  npm install @wocart/storefront zod
23
16
  ```
24
17
 
25
- `zod` (4.x) is a peer dependency: the SDK's schemas are part of its API — you
26
- can validate with them, and you pass your own schemas to `createProxyEndpoint` —
27
- so your app and the SDK share one copy. npm 7 and later install it
28
- automatically. ESM only. Runs in browsers, Cloudflare Workers, Node and Astro server
29
- runtimes; tooling needs Node 22.12 or newer.
18
+ `zod` 4 is a peer dependency. ESM only; Node 22.12 or newer for tooling.
30
19
 
31
20
  ## Quick start
32
21
 
33
22
  ```ts
34
23
  import { createStoreClient } from "@wocart/storefront/server";
24
+ import { mediaImageUrl } from "@wocart/storefront";
35
25
 
36
- // Server-side only: a page frontmatter, endpoint, action or middleware.
37
- const store = createStoreClient({
38
- serverUrl: env.COD_SERVER_URL, // your store's API origin
39
- apiKey: env.STORE_API_KEY, // server-only secret
40
- onError: (error) => console.warn(error.operation, error.code, error.requestId),
41
- });
26
+ const store = createStoreClient({ serverUrl: env.COD_SERVER_URL, apiKey: env.STORE_API_KEY });
42
27
 
43
- // Reads: typed, validated, safe to render straight away.
44
- const [config, products] = await Promise.all([
45
- store.config.get(), // StoreConfig | null
46
- store.products.list({ featured: true, limit: 8 }), // ProductList[]
47
- ]);
48
- const product = await store.products.get("blue-t-shirt"); // Product | null
49
-
50
- // Writes: a discriminated result, never a thrown HTTP error.
51
- const result = await store.orders.create({
52
- customerName: "أمين",
53
- phone: "0555123456",
54
- wilayaId: 16,
55
- communeId: "c-16-001",
56
- items: [{ productId: "p-1", productName: "Blue T-Shirt", quantity: 1 }],
57
- });
58
-
59
- if (result.success) {
60
- console.log(result.data.orderNumber, result.data.total);
61
- } else {
62
- console.error(result.domainError.category, result.domainError.message);
63
- }
64
- ```
65
-
66
- ## Entry points
67
-
68
- | Import | Runs in | What it holds |
69
- | --- | --- | --- |
70
- | `@wocart/storefront` | browser and server | read-model types and schemas, reference data (58 wilayas, 1,551 communes), money and image helpers, the basket and OTP state machines, language routing, Meta pixel payloads |
71
- | `@wocart/storefront/server` | server only | `createStoreClient`, `StoreClient`, `ClientResult`, `StorefrontError` — the API key lives here — the preview checks (`readPreviewRequest`, `verifyPreviewToken`, `previewResponseHeaders`, `previewConfig`) and revalidation (`readRevalidateRequest`) |
72
- | `@wocart/storefront/astro` | server only | `createStorefrontRoutes`, `createProxyEndpoint`, `placeOrderInput`, `executePlaceOrder`, `collectForwardedHeaders`, `runFormAction` |
73
-
74
- Browser code never receives the key. It calls your own same-origin routes
75
- (for example `src/pages/api/cart.ts`), which call the SDK on the server:
76
-
77
- ```ts
78
- // In the browser: no client, no key.
79
- import { createBasketStore, formatMoney } from "@wocart/storefront";
28
+ const config = await store.config.get();
29
+ const product = await store.products.get("blue-t-shirt");
30
+ const photo = product?.images[0];
31
+ const src = photo && config ? mediaImageUrl(photo.url, { width: 480, fit: "cover", format: "auto" }, config.media) ?? photo.url : null;
80
32
  ```
81
33
 
82
- ## What the client covers
83
-
84
- | Namespace | Use it for |
85
- | --- | --- |
86
- | `config` | store identity, branding, languages, checkout policy, feature flags |
87
- | `products` | catalog lists, product detail, reviews |
88
- | `categories` | catalog navigation |
89
- | `pages` | legal and CMS pages, landing pages |
90
- | `pageData` | the store's saved page document for a surface (render it with [`@wocart/theme`](https://www.npmjs.com/package/@wocart/theme)) |
91
- | `shipping` | home and stop-desk delivery prices per wilaya |
92
- | `geo` | wilayas and communes |
93
- | `cart` | server re-pricing and validation of a browser basket |
94
- | `orders` | order creation, OTP, abandoned checkout, conversion tracking |
95
-
96
- ## How errors work
34
+ Reads never throw: they resolve to `null`, `[]` or `{}` and report through
35
+ `onError`. Writes return `{ success, data }` or a structured `domainError`.
97
36
 
98
- - **Reads** resolve to the empty value (`null`, `[]`, `{}`) so a page still
99
- renders, and report the cause to `onError` as a `DomainError` with
100
- `category`, `code`, `retryable`, `sideEffect`, `operation` and `requestId`.
101
- - **Writes** return `ClientResult<T>`: `{ success: true, data }` or
102
- `{ success: false, error, domainError }`. Branch on
103
- `result.domainError.category` — `validation`, `authentication`, `business`,
104
- `system`, `transport` — instead of status codes.
105
- - **Setup errors are loud.** An empty `serverUrl` or `apiKey` throws
106
- `MISSING_CONFIGURATION` when the client is created, instead of quietly
107
- turning every read into an empty store.
108
-
109
- ## Configuration
37
+ ## Entry points
110
38
 
111
- | Option | Required | Notes |
39
+ | Import | Runs in | Holds |
112
40
  | --- | --- | --- |
113
- | `serverUrl` | yes | your store's API origin |
114
- | `apiKey` | yes | the store API key, sent as `X-Store-API-Key`; server-only |
115
- | `locale` | no | read merchant text in a published store language (`"fr"`); one client per language |
116
- | `theme` | no | your theme's key (`defineTheme({ key })`), to receive the merchant's edits to your theme's text |
117
- | `onError` | no | observability hook for degraded reads |
118
- | `readTimeoutMs` | no | how long one read may take, retries included, before it degrades (default 10 000; `0` for no limit) |
119
-
120
- A storefront deployed by Wocart receives its origin and key as the
121
- `COD_SERVER_URL` and `STORE_API_KEY` environment variables. The SDK reads no
122
- environment variables itself; pass the values in. In Astro, declare the key as
123
- a server secret (`envField.string({ context: "server", access: "secret" })`)
124
- and never expose it as a public variable.
125
-
126
- ## Documentation
127
-
128
- - [USAGE.md](./USAGE.md) — the full guide: every namespace and the UI decisions
129
- it implies, checkout and OTP, Astro actions and proxy routes, languages,
130
- images, and a definition of done for a new theme.
131
- - [CHANGELOG.md](./CHANGELOG.md) — what changed in each release.
132
-
133
- ## Compatibility
41
+ | `@wocart/storefront` | browser and server | types and schemas, reference data, money and image helpers, basket and OTP state |
42
+ | `@wocart/storefront/server` | server only | `createStoreClient` — the store API key lives here — preview and revalidation checks |
43
+ | `@wocart/storefront/astro` | server only | proxy routes, form actions, `placeOrderInput` |
134
44
 
135
- - Follows [semantic versioning](https://semver.org/). Breaking changes ship
136
- only in a new major version and are listed in the changelog.
137
- - Response schemas ignore fields they do not know, so a newer server never
138
- breaks an older SDK; a response missing a required field degrades the read
139
- and reports it.
140
- - Astro 7 is verified. The Astro adapters are structural and import nothing
141
- from Astro.
45
+ [USAGE.md](./USAGE.md) ships with the package for offline reading;
46
+ [CHANGELOG.md](./CHANGELOG.md) lists every release.
142
47
 
143
48
  ## License
144
49
 
package/USAGE.md CHANGED
@@ -214,10 +214,11 @@ server directly.
214
214
 
215
215
  | Export | Use it for |
216
216
  | --- | --- |
217
- | `formatMoney`, `optimizeImage`, `generateSrcSet` | Money text and image URLs |
218
- | `mediaImageUrl` | The Cloudflare resizing URL of a media-domain image, served by the media domain itself (any storefront host); `null` for any other image — the builder behind every image helper and `@wocart/theme/image-service` |
219
- | `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 |
220
- | `createImageHelpers(mediaDomain)`, `getCardImage`, `getGalleryImage`, `getThumbnailImage`, `getHeroImage`, `getCategoryImage`, `getLogoImage` | The bound image toolkit (`IMAGE_SIZES` holds the presets) |
217
+ | `formatMoney` | Money text |
218
+ | `mediaImageUrl(url, params, config.media)` | A store image at the size and format a page shows it, from the store's delivery (ImageKit `?tr=` or Cloudflare `/cdn-cgi/image/`); `null` for an image the delivery does not serve, or a `direct` delivery — the builder behind `@wocart/theme/image-service` |
219
+ | `mediaImageSrcset(url, widths, { delivery })` | The same image at several widths, for `srcset` |
220
+ | `mediaUrl(key, config.media)`, `isMediaKey` | The URL of a Media Key — what a theme's `image` field holds (`{ key: "products/…" }`); `null` without a delivery or for a value that is not a key |
221
+ | `pictureOf(image, config.media)` | A picked image ready for an `<img>`: URL, alt, size, focal point |
221
222
  | `WILAYAS`, `ALGERIA_COMMUNES`, `communesForWilaya` | Reference data: 58 wilayas, 1,551 communes, one wilaya's rows |
222
223
  | `isWilayaId` | Whether a value is a wilaya id — the range `WilayaIdSchema` checks on the server, without a schema in the browser |
223
224
  | `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 |
@@ -236,6 +237,7 @@ server directly.
236
237
  | `basketLineKey`, `toOrderItems`, `MAX_BASKET_LINES`, `MAX_BASKET_LINE_QUANTITY` | Basket identity, the wire projection, and the caps the server re-checks |
237
238
  | `createOtpMachine` | The OTP step's state machine |
238
239
  | `createOtpTransport`, `mapOtpResponse`, `otpTransportFailure` | Route-agnostic OTP result mapping; the theme supplies its same-origin route functions |
240
+ | `bindCheckoutKey` | One checkout attempt, one order: the key a checkout form's submissions carry |
239
241
  | `createAbandonedCheckoutTracker` | Port-injected session, capture eligibility, debounce, update, cookie, fetch, and exit-beacon policy |
240
242
  | `STOREFRONT_OPERATIONS` | The exact operation labels used by namespace calls |
241
243
  | `STOREFRONT_RETRY_POLICIES` | Which operations retry, by exact operation label |
@@ -260,7 +262,7 @@ not add a local product-view or offer-price module around them.
260
262
  | --- | --- | --- |
261
263
  | `serverUrl` | yes | Your store's API origin. No default: a wrong origin must be a visible mistake, not a silent one. |
262
264
  | `apiKey` | yes | The store's API key. Sent as `X-Store-API-Key` on every request. **Server-only.** |
263
- | `locale` | no | The language to read merchant text in — see "Store languages". |
265
+ | `locale` | no | The language to read merchant text in, and the one an order is recorded in — see "Store languages". |
264
266
  | `theme` | no | Your theme's key, to receive the merchant's edits to your theme's text — see "Store languages". |
265
267
  | `onError` | no | Observability hook for degraded reads — see "When a read degrades: onError". |
266
268
 
@@ -289,7 +291,6 @@ storefront deployed by Wocart receives these:
289
291
  | --- | --- | --- |
290
292
  | `COD_SERVER_URL` | server variable | the store's API origin, e.g. `https://api.your-store.com` |
291
293
  | `STORE_API_KEY` | server secret | the store API key |
292
- | `MEDIA_DOMAIN` | server variable (optional) | media domain for `createImageHelpers(mediaDomain)`; unset → image URLs pass through unchanged |
293
294
 
294
295
  Never put `STORE_API_KEY` in a browser-exposed variable (no `PUBLIC_*`, no
295
296
  `import.meta.env` in client code, no HTML attribute). In Astro, declare it in
@@ -517,11 +518,13 @@ const currencySymbol = config?.currencySymbol ?? "دج";
517
518
  StoreConfig contains the current store identity and presentation fields,
518
519
  including:
519
520
 
520
- - name, domain, logoUrl and themeId (the colors, fontFamily and fontUrl
521
+ - name, domain, logo (a MediaImage) and themeId (the colors, fontFamily and fontUrl
521
522
  fields are deprecated: colors and fonts are theme settings, read from
522
523
  themeSettings);
523
524
  - lang (the primary language), locales, contentOverrides, themeSettings,
524
- currency, currencySymbol, metaTitle, metaDescription, and ogImage;
525
+ currency, currencySymbol, metaTitle, metaDescription, and ogImage (a
526
+ MediaImage);
527
+ - media, the store's MediaDelivery, which every image helper takes;
525
528
  - reviewsEnabled (announcementBar is deprecated and always null: an
526
529
  announcement is a theme's own setting);
527
530
  - cartEnabled, freeShippingThreshold, and cartShippingMode;
@@ -1258,6 +1261,32 @@ CreatedOrder contains the order ID, customer-facing order number, product
1258
1261
  price, delivery fee, and total, all in whole DZD — and `couponDiscount`, what
1259
1262
  the order's coupon took off.
1260
1263
 
1264
+ ### One checkout, one order
1265
+
1266
+ A shopper who double-clicks, refreshes the thank-you page's POST, or retries
1267
+ after a dropped connection sends the same checkout again. Give the order its
1268
+ `checkoutKey` and the server places it once: the same key again answers with
1269
+ the order it already placed, exactly as the first time — no second order, no
1270
+ second stock deduction, no second notification. For another phone the key
1271
+ fails with `CHECKOUT_KEY_IN_USE`; without a key, every call places an order.
1272
+
1273
+ In the browser, bind the key to the checkout form once:
1274
+
1275
+ ~~~ts
1276
+ import { bindCheckoutKey } from "@wocart/storefront";
1277
+
1278
+ bindCheckoutKey(document.querySelector("form.order-form")!);
1279
+ ~~~
1280
+
1281
+ It adds `checkoutKey` to every submission the form makes, through the form's
1282
+ `formdata` event, so a native submit and a `new FormData(form)` both carry it.
1283
+ The key stays the same while the shopper changes nothing and is drawn anew
1284
+ once they change any field, so a different order is never answered with the
1285
+ one already placed. It is drawn at the first submission, never rendered into
1286
+ the page: a storefront page is cached and served to every shopper.
1287
+ `placeOrderInput` passes the key through; the function `bindCheckoutKey`
1288
+ returns unbinds it.
1289
+
1261
1290
  ### Coupons
1262
1291
 
1263
1292
  A coupon is a code the shopper types. It applies after offers, only to the
@@ -1800,53 +1829,46 @@ price presentation does not drift between cards, product pages, and checkout.
1800
1829
 
1801
1830
  ### Images
1802
1831
 
1832
+ Every image the store's server answers is a `MediaImage` — `{ key, url,
1833
+ width, height }` — and the store's config carries `media`, its
1834
+ `MediaDelivery`: the origin every key is served from and how that origin
1835
+ resizes (`imagekit`, `cloudflare`, or `direct` for files served as stored).
1836
+ Pass `config.media` to the image helpers; one theme then works on every host a
1837
+ store can use.
1838
+
1803
1839
  ~~~ts
1804
- import {
1805
- generateSrcSet,
1806
- getCardImage,
1807
- optimizeImage,
1808
- } from "@wocart/storefront";
1840
+ import { mediaImageUrl } from "@wocart/storefront";
1809
1841
 
1810
- const mediaDomain = env.MEDIA_DOMAIN;
1811
- const originalImage = product.images[0]?.src ?? "";
1812
- const cardImage = getCardImage(originalImage, mediaDomain);
1813
- const src = optimizeImage(originalImage, {
1814
- width: 480,
1815
- height: 480,
1816
- quality: 82,
1817
- format: "auto",
1818
- fit: "cover",
1819
- }, mediaDomain);
1820
- const srcSet = generateSrcSet(originalImage, 480, 480, mediaDomain);
1842
+ const photo = product.images[0];
1843
+ const card = photo ? mediaImageUrl(photo.url, { width: 480, height: 480, fit: "cover", format: "auto", quality: 82 }, config.media) ?? photo.url : null;
1821
1844
  ~~~
1822
1845
 
1823
- The image utility accepts the original media URL and can target a media
1824
- domain. Use it to produce appropriately sized URLs rather than downloading
1825
- original assets into every card.
1846
+ `mediaImageUrl` answers `null` for an image its delivery does not serve and
1847
+ for a `direct` delivery, which does not resize: show the original then.
1826
1848
 
1827
1849
  For an image whose width changes with the screen, give the browser widths and
1828
1850
  let `sizes` choose — a phone then downloads a phone-sized file.
1829
- `mediaImageSrcset(src, widths, { mediaDomain, ratio, quality })` gives one
1830
- size per width with width descriptors, cropped to `ratio` (height ÷ width)
1831
- when given; null off the media domain:
1851
+ `mediaImageSrcset(src, widths, { delivery, ratio, quality })` gives one size
1852
+ per width with width descriptors, cropped to `ratio` (height ÷ width) when
1853
+ given and never enlarged past the original otherwise:
1832
1854
 
1833
1855
  ~~~astro
1834
1856
  <img
1835
- src={mediaImageUrl(src, { width: 720, format: "auto" }, mediaDomain) ?? src}
1836
- srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { mediaDomain, ratio: 1.25 }) ?? undefined}
1857
+ src={mediaImageUrl(src, { width: 720, fit: "scale-down", format: "auto" }, config.media) ?? src}
1858
+ srcset={mediaImageSrcset(src, [360, 540, 720, 1080], { delivery: config.media, ratio: 1.25 }) ?? undefined}
1837
1859
  sizes="(min-width: 1024px) 25vw, 50vw"
1838
1860
  alt="" loading="lazy" />
1839
1861
  ~~~
1840
1862
 
1841
- A photo a merchant picks for a section is stored as a media key with its alt
1842
- text, size and focal point. `pictureOf(image, mediaDomain)` turns it into what
1863
+ A photo a merchant picks for a section is stored as a Media Key with its alt
1864
+ text, size and focal point. `pictureOf(image, config.media)` turns it into what
1843
1865
  an `<img>` needs — `src`, `alt` (empty when none was written), `width`,
1844
1866
  `height` and `position`, the focal point as CSS `object-position` — or null
1845
1867
  when none is picked:
1846
1868
 
1847
1869
  ~~~astro
1848
1870
  ---
1849
- const picture = pictureOf(props.image, mediaDomain);
1871
+ const picture = pictureOf(props.image, config.media);
1850
1872
  ---
1851
1873
  {picture && <img src={picture.src} alt={picture.alt} width={picture.width} height={picture.height} style={{ objectPosition: picture.position }} />}
1852
1874
  ~~~
@@ -1859,10 +1881,10 @@ thumbnail. `productImageSource` answers which image represents the product, so
1859
1881
  a cart drawer, a checkout summary and a product card cannot disagree:
1860
1882
 
1861
1883
  ~~~ts
1862
- import { getThumbnailImage, productImageSource } from "@wocart/storefront";
1884
+ import { mediaImageUrl, productImageSource } from "@wocart/storefront";
1863
1885
 
1864
1886
  const src = productImageSource(product); // Product | ProductList
1865
- const thumbnail = src ? getThumbnailImage(src) : null;
1887
+ const thumbnail = src ? mediaImageUrl(src, { width: 160, height: 160, fit: "cover", format: "auto" }, config.media) ?? src : null;
1866
1888
  ~~~
1867
1889
 
1868
1890
  It prefers the merchant's cover and falls back to the first usable gallery
@@ -1870,40 +1892,6 @@ image; `null` means the product genuinely has no photograph, so that is the
1870
1892
  moment to render a placeholder. The list model carries no gallery at all, so a
1871
1893
  card gets the cover or `null`.
1872
1894
 
1873
- Available semantic helpers include:
1874
-
1875
- - getCardImage;
1876
- - getGalleryImage;
1877
- - getThumbnailImage;
1878
- - getHeroImage;
1879
- - getCategoryImage;
1880
- - getLogoImage.
1881
-
1882
- The helper chooses the appropriate image role. The theme still owns layout,
1883
- aspect ratio, alt text, lazy loading, placeholders, and art direction.
1884
-
1885
- #### Bind the media domain once
1886
-
1887
- Do not thread the media domain through every call site — a missed argument
1888
- silently ships an unoptimized image. Bind it once and use the URL-only helper
1889
- set:
1890
-
1891
- ~~~ts
1892
- import { createImageHelpers } from "@wocart/storefront";
1893
-
1894
- const images = createImageHelpers(env.MEDIA_DOMAIN);
1895
-
1896
- const thumbnail = images.getThumbnailImage(product.images[0].src);
1897
- const preview = images.getCardImage(product.images[0].src);
1898
- const srcSet = images.generateSrcSet(product.images[0].src, 480, 480);
1899
- ~~~
1900
-
1901
- The returned toolkit exposes the same helpers plus `optimizeImage`,
1902
- `generateSrcSet`, and `IMAGE_SIZES`. With no domain bound, every helper returns
1903
- the original URL unchanged, so a store without a media domain needs no branch —
1904
- and `optimizeImage` / `generateSrcSet` still take a per-call domain that
1905
- overrides the bound one.
1906
-
1907
1895
  ## WhatsApp prefill messages
1908
1896
 
1909
1897
  The storefront's WhatsApp launcher hands the shopper to WhatsApp with a message
@@ -1,4 +1,5 @@
1
1
  import type { AbandonedCheckoutInput } from "./domain.js";
2
+ import { type UuidSource } from "./utils/uuid.js";
2
3
  export type AbandonedCheckoutSnapshot = Omit<AbandonedCheckoutInput, "sessionId" | "fbc" | "fbp">;
3
4
  export interface AbandonedCheckoutStorage {
4
5
  getItem(key: string): string | null;
@@ -29,9 +30,7 @@ export declare function createAbandonedCheckoutTracker(ports: AbandonedCheckoutP
29
30
  /** What the browser wiring uses of the page; the page's own globals by default. */
30
31
  export interface AbandonedCheckoutHost {
31
32
  readonly sessionStorage?: Pick<Storage, "getItem" | "setItem" | "removeItem">;
32
- readonly crypto?: Pick<Crypto, "getRandomValues"> & {
33
- randomUUID?: () => string;
34
- };
33
+ readonly crypto?: UuidSource;
35
34
  readonly document?: {
36
35
  readonly cookie: string;
37
36
  };
@@ -1,4 +1,5 @@
1
1
  import { toLocalAlgerianMobile } from "./phone.js";
2
+ import { uuidV4 } from "./utils/uuid.js";
2
3
  /**
3
4
  * Where the browser keeps the checkout's id for the tab (`sessionStorage`):
4
5
  * every capture of one checkout updates one record, and the thank-you page
@@ -110,7 +111,7 @@ export function createBrowserAbandonedCheckoutTracker(options, host = globalThis
110
111
  getItem: (key) => host.sessionStorage?.getItem(key) ?? null,
111
112
  setItem: (key, value) => host.sessionStorage?.setItem(key, value),
112
113
  },
113
- createId: () => uuid(host),
114
+ createId: () => uuidV4(host.crypto ?? globalThis.crypto),
114
115
  schedule: (callback, delayMs) => host.setTimeout(callback, delayMs),
115
116
  cancel: (handle) => host.clearTimeout(handle),
116
117
  readCookie: (name) => cookieValue(host.document?.cookie ?? "", name),
@@ -154,14 +155,3 @@ function cookieValue(cookies, name) {
154
155
  }
155
156
  return undefined;
156
157
  }
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/actions.d.ts CHANGED
@@ -86,6 +86,7 @@ export declare const placeOrderInput: z.ZodObject<{
86
86
  }, z.core.$strip>>>, unknown>;
87
87
  landingPageSlug: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
88
88
  couponCode: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
89
+ checkoutKey: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
89
90
  }, z.core.$strip>;
90
91
  export type PlaceOrderInput = z.infer<typeof placeOrderInput>;
91
92
  /**
package/dist/actions.js CHANGED
@@ -153,6 +153,9 @@ export const placeOrderInput = z.object({
153
153
  // The code the shopper typed; an empty box sends none. Whether it applies
154
154
  // is the server's to say — a refusal comes back as `couponRefusal`.
155
155
  couponCode: z.preprocess((v) => (typeof v === "string" && v.trim() === "") || v == null ? undefined : v, z.string().max(64).optional()),
156
+ // The checkout attempt's key (`bindCheckoutKey`): the same key again is
157
+ // answered with the order it placed, never a second one.
158
+ checkoutKey: z.preprocess((v) => (v === "" || v == null ? undefined : v), z.string().uuid().optional()),
156
159
  });
157
160
  /**
158
161
  * Collect the shopper's attribution headers so the server records the
@@ -0,0 +1,25 @@
1
+ /**
2
+ * One checkout attempt, one order. The server places the order a checkout key
3
+ * arrives with once: the same key again answers with that order, never a
4
+ * second one, a second stock deduction or a second notification.
5
+ *
6
+ * `bindCheckoutKey` puts a key on every submission a checkout form makes. It
7
+ * stays the same while the shopper changes nothing — a double click, a
8
+ * refreshed or retried submission carries it — and is drawn anew once they
9
+ * change anything, so a different order is never answered with the one
10
+ * already placed.
11
+ *
12
+ * The key is drawn in the browser at the first submission, never rendered: a
13
+ * storefront page is cached and served to every shopper.
14
+ */
15
+ import { type UuidSource } from "./utils/uuid.js";
16
+ /** What the binding uses of the form: its events. */
17
+ export type CheckoutKeyForm = Pick<EventTarget, "addEventListener" | "removeEventListener">;
18
+ /**
19
+ * Bind a checkout key to the form; the function returned unbinds it. The key
20
+ * is added through the form's `formdata` event, so it reaches a native submit
21
+ * and a `new FormData(form)` alike, whatever order the theme's own submit
22
+ * handlers run in. A browser without that event sends no key and places its
23
+ * orders as before.
24
+ */
25
+ export declare function bindCheckoutKey(form: CheckoutKeyForm, crypto?: UuidSource): () => void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * One checkout attempt, one order. The server places the order a checkout key
3
+ * arrives with once: the same key again answers with that order, never a
4
+ * second one, a second stock deduction or a second notification.
5
+ *
6
+ * `bindCheckoutKey` puts a key on every submission a checkout form makes. It
7
+ * stays the same while the shopper changes nothing — a double click, a
8
+ * refreshed or retried submission carries it — and is drawn anew once they
9
+ * change anything, so a different order is never answered with the one
10
+ * already placed.
11
+ *
12
+ * The key is drawn in the browser at the first submission, never rendered: a
13
+ * storefront page is cached and served to every shopper.
14
+ */
15
+ import { uuidV4 } from "./utils/uuid.js";
16
+ const FIELD = "checkoutKey";
17
+ const CHANGES = ["input", "change", "reset"];
18
+ /**
19
+ * Bind a checkout key to the form; the function returned unbinds it. The key
20
+ * is added through the form's `formdata` event, so it reaches a native submit
21
+ * and a `new FormData(form)` alike, whatever order the theme's own submit
22
+ * handlers run in. A browser without that event sends no key and places its
23
+ * orders as before.
24
+ */
25
+ export function bindCheckoutKey(form, crypto = globalThis.crypto) {
26
+ let key = null;
27
+ const stamp = (event) => {
28
+ key ??= uuidV4(crypto);
29
+ event.formData.set(FIELD, key);
30
+ };
31
+ const forget = () => {
32
+ key = null;
33
+ };
34
+ form.addEventListener("formdata", stamp);
35
+ for (const type of CHANGES)
36
+ form.addEventListener(type, forget);
37
+ return () => {
38
+ form.removeEventListener("formdata", stamp);
39
+ for (const type of CHANGES)
40
+ form.removeEventListener(type, forget);
41
+ };
42
+ }
@@ -14,13 +14,14 @@
14
14
  import { CreatedOrderSchema, IdResponseSchema, OrderTrackingSchema, OtpSendResultSchema, ShopperOrderStatusSchema, OtpVerifyResultSchema, } from "../schemas.js";
15
15
  import { STOREFRONT_OPERATIONS } from "../operations.js";
16
16
  import { isNotFoundAnswer, reportDegradedRead } from "./core.js";
17
+ import { localizedPath } from "./paths.js";
17
18
  /**
18
19
  * Create orders namespace
19
20
  */
20
21
  export function createOrdersNamespace(core) {
21
22
  return {
22
23
  async create(body, forwardedHeaders) {
23
- return core.post("/store/orders", body, {
24
+ return core.post(localizedPath(core.config, "/store/orders"), body, {
24
25
  ...(forwardedHeaders ? { headers: forwardedHeaders } : {}),
25
26
  schema: CreatedOrderSchema,
26
27
  operation: STOREFRONT_OPERATIONS.ordersCreate,
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Request paths for reads that carry merchant text. Each one names the
3
- * client's language (`StoreScope.locale`), so the server serves it
4
- * with its translations — and caches keyed by path keep one entry per
5
- * language.
2
+ * Request paths that name the client's language (`StoreScope.locale`): reads
3
+ * that carry merchant text, so the server serves them with their
4
+ * translations (caches keyed by path keep one entry per language), and an
5
+ * order, which records the language it was placed in.
6
6
  */
7
7
  import type { StoreScope } from "../domain.js";
8
8
  export declare function localizedPath(scope: Pick<StoreScope, "locale">, path: string, params?: Readonly<Record<string, string | undefined>>): string;
package/dist/index.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  */
12
12
  export { formatMoney } from "./utils/money.js";
13
13
  export type { MoneyFormatOptions } from "./utils/money.js";
14
- export { optimizeImage, generateSrcSet, IMAGE_SIZES, getCardImage, getGalleryImage, getThumbnailImage, getHeroImage, getCategoryImage, getLogoImage, createImageHelpers, mediaImageUrl, mediaImageSrcset, mediaUrl, pictureOf, } from "./utils/image.js";
15
- export type { ImageHelpers, ImageResizeOptions, MediaImageParams, MediaImageSrcsetOptions, PickedImage, Picture } from "./utils/image.js";
14
+ export { isMediaKey, mediaImageSrcset, mediaImageUrl, mediaUrl, pictureOf } from "./utils/image.js";
15
+ export type { MediaDelivery, MediaDeliveryKind, MediaImage, MediaImageParams, MediaImageSrcsetOptions, PickedImage, Picture } from "./utils/image.js";
16
16
  export { productImageSource } from "./utils/product-image.js";
17
17
  export type { ProductImageSourceInput } from "./utils/product-image.js";
18
18
  export { MAX_RESOLVED_PREFILL_LENGTH, PREFILL_TOKENS, stripTrackingParams, truncateAtWordBoundary, resolvePrefillText, buildWhatsAppHref, } from "./whatsapp.js";
@@ -25,6 +25,8 @@ export type { StorefrontPageMatch, StorefrontPageType, StorefrontPathParams, Sto
25
25
  export { addedLines, createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
26
26
  export { MAX_BASKET_LINES, MAX_BASKET_LINE_QUANTITY, basketLineKey } from "./basket-line.js";
27
27
  export type { BasketStore, BasketStorageAdapter, BasketStoreOptions, BasketListener, BasketSelectionLine, } from "./basket.js";
28
+ export { bindCheckoutKey } from "./checkout-key.js";
29
+ export type { CheckoutKeyForm } from "./checkout-key.js";
28
30
  export { createOtpMachine, createOtpTransport, mapOtpResponse, otpTransportFailure, } from "./otp.js";
29
31
  export type { OtpMachine, OtpMachineOptions, OtpFailureContext, OtpResponseOptions, OtpState, OtpStatus, OtpTransport, OtpTransportResult, OtpStateListener, } from "./otp.js";
30
32
  export { WILAYAS } from "./data/wilayas.js";