@wocart/storefront 1.22.0 → 1.23.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,27 @@ 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.23.0 — 2026-10-25
9
+
10
+ One checkout, one order: a double click, a refreshed submission or a retry
11
+ after a dropped connection no longer places the order twice. And an order
12
+ records the language it was placed in.
13
+
14
+ ### Added
15
+
16
+ - `bindCheckoutKey(form)`: puts a `checkoutKey` on every submission a checkout
17
+ form makes, through its `formdata` event. The key stays the same while the
18
+ shopper changes nothing and is drawn anew once they change any field; it is
19
+ drawn at the first submission, never rendered into a cached page. Returns a
20
+ function that unbinds it.
21
+ - `placeOrderInput` and `CreateOrderInput` take `checkoutKey`, a UUID. The same
22
+ key again answers with the order it already placed, exactly as the first
23
+ time; for another phone it fails with `CHECKOUT_KEY_IN_USE`. Without a key,
24
+ every order is placed as before.
25
+ - `orders.create` sends the client's `locale`: the order records the
26
+ language the shopper checked out in (the primary when the store does not
27
+ publish it), and the customer's messages are written in it.
28
+
8
29
  ## 1.22.0 — 2026-10-24
9
30
 
10
31
  What moving Theme-00 onto the SDK showed every theme needs: an add reported
package/USAGE.md CHANGED
@@ -236,6 +236,7 @@ server directly.
236
236
  | `basketLineKey`, `toOrderItems`, `MAX_BASKET_LINES`, `MAX_BASKET_LINE_QUANTITY` | Basket identity, the wire projection, and the caps the server re-checks |
237
237
  | `createOtpMachine` | The OTP step's state machine |
238
238
  | `createOtpTransport`, `mapOtpResponse`, `otpTransportFailure` | Route-agnostic OTP result mapping; the theme supplies its same-origin route functions |
239
+ | `bindCheckoutKey` | One checkout attempt, one order: the key a checkout form's submissions carry |
239
240
  | `createAbandonedCheckoutTracker` | Port-injected session, capture eligibility, debounce, update, cookie, fetch, and exit-beacon policy |
240
241
  | `STOREFRONT_OPERATIONS` | The exact operation labels used by namespace calls |
241
242
  | `STOREFRONT_RETRY_POLICIES` | Which operations retry, by exact operation label |
@@ -260,7 +261,7 @@ not add a local product-view or offer-price module around them.
260
261
  | --- | --- | --- |
261
262
  | `serverUrl` | yes | Your store's API origin. No default: a wrong origin must be a visible mistake, not a silent one. |
262
263
  | `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". |
264
+ | `locale` | no | The language to read merchant text in, and the one an order is recorded in — see "Store languages". |
264
265
  | `theme` | no | Your theme's key, to receive the merchant's edits to your theme's text — see "Store languages". |
265
266
  | `onError` | no | Observability hook for degraded reads — see "When a read degrades: onError". |
266
267
 
@@ -1258,6 +1259,32 @@ CreatedOrder contains the order ID, customer-facing order number, product
1258
1259
  price, delivery fee, and total, all in whole DZD — and `couponDiscount`, what
1259
1260
  the order's coupon took off.
1260
1261
 
1262
+ ### One checkout, one order
1263
+
1264
+ A shopper who double-clicks, refreshes the thank-you page's POST, or retries
1265
+ after a dropped connection sends the same checkout again. Give the order its
1266
+ `checkoutKey` and the server places it once: the same key again answers with
1267
+ the order it already placed, exactly as the first time — no second order, no
1268
+ second stock deduction, no second notification. For another phone the key
1269
+ fails with `CHECKOUT_KEY_IN_USE`; without a key, every call places an order.
1270
+
1271
+ In the browser, bind the key to the checkout form once:
1272
+
1273
+ ~~~ts
1274
+ import { bindCheckoutKey } from "@wocart/storefront";
1275
+
1276
+ bindCheckoutKey(document.querySelector("form.order-form")!);
1277
+ ~~~
1278
+
1279
+ It adds `checkoutKey` to every submission the form makes, through the form's
1280
+ `formdata` event, so a native submit and a `new FormData(form)` both carry it.
1281
+ The key stays the same while the shopper changes nothing and is drawn anew
1282
+ once they change any field, so a different order is never answered with the
1283
+ one already placed. It is drawn at the first submission, never rendered into
1284
+ the page: a storefront page is cached and served to every shopper.
1285
+ `placeOrderInput` passes the key through; the function `bindCheckoutKey`
1286
+ returns unbinds it.
1287
+
1261
1288
  ### Coupons
1262
1289
 
1263
1290
  A coupon is a code the shopper types. It applies after offers, only to the
@@ -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
@@ -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";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { servedLanguage } from "./locales.js";
18
18
  export { alternateLinks, localeRouting, localizeHref, resolveLocalePath } from "./locale-routing.js";
19
19
  export { addedLines, createBasketStore, createMemoryStorageAdapter, createLocalStorageAdapter, DEFAULT_BASKET_STORAGE_KEY, toOrderItems, } from "./basket.js";
20
20
  export { MAX_BASKET_LINES, MAX_BASKET_LINE_QUANTITY, basketLineKey } from "./basket-line.js";
21
+ export { bindCheckoutKey } from "./checkout-key.js";
21
22
  export { createOtpMachine, createOtpTransport, mapOtpResponse, otpTransportFailure, } from "./otp.js";
22
23
  export { WILAYAS } from "./data/wilayas.js";
23
24
  export { ALGERIA_COMMUNES, communesForWilaya } from "./data/communes.js";
package/dist/types.d.ts CHANGED
@@ -475,6 +475,13 @@ export interface CreateOrderInput {
475
475
  landingPageSlug?: string | undefined;
476
476
  /** The code the shopper typed. An order whose code does not apply is refused whole (`couponRefusalOf`). */
477
477
  couponCode?: string | undefined;
478
+ /**
479
+ * The checkout attempt's key, a UUID (`bindCheckoutKey`). The same key again
480
+ * answers with the order it placed, exactly as the first time; for another
481
+ * phone it fails with `CHECKOUT_KEY_IN_USE`. Without it, every call places
482
+ * an order.
483
+ */
484
+ checkoutKey?: string | undefined;
478
485
  }
479
486
  /**
480
487
  * The order's tracking destination — which Meta pixel it belongs to and
@@ -0,0 +1,6 @@
1
+ /** What a UUID is drawn from: the platform's `crypto`, whose `randomUUID` exists only in a secure context. */
2
+ export type UuidSource = Pick<Crypto, "getRandomValues"> & {
3
+ randomUUID?: () => string;
4
+ };
5
+ /** A version 4 UUID: the platform's `randomUUID`, else one drawn from `getRandomValues`. */
6
+ export declare function uuidV4(crypto: UuidSource): string;
@@ -0,0 +1,10 @@
1
+ /** A version 4 UUID: the platform's `randomUUID`, else one drawn from `getRandomValues`. */
2
+ export function uuidV4(crypto) {
3
+ if (typeof crypto.randomUUID === "function")
4
+ return crypto.randomUUID();
5
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
6
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
7
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
8
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
9
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
10
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wocart/storefront",
3
- "version": "1.22.0",
3
+ "version": "1.23.0",
4
4
  "description": "Typed, validated SDK for Wocart cash-on-delivery storefronts: catalog reads, basket, checkout, OTP and order tracking, with Astro adapters.",
5
5
  "keywords": [
6
6
  "wocart",