@webround/commerce 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +192 -2
  2. package/dist/account/account.d.ts +54 -0
  3. package/dist/account/account.js +40 -0
  4. package/dist/account/index.d.ts +3 -0
  5. package/dist/account/index.js +2 -0
  6. package/dist/addresses/api.d.ts +16 -0
  7. package/dist/addresses/api.js +13 -0
  8. package/dist/addresses/book.d.ts +40 -0
  9. package/dist/addresses/book.js +144 -0
  10. package/dist/addresses/index.d.ts +8 -0
  11. package/dist/addresses/index.js +5 -0
  12. package/dist/addresses/rules.d.ts +4 -0
  13. package/dist/addresses/rules.js +9 -0
  14. package/dist/addresses/types.d.ts +48 -0
  15. package/dist/addresses/types.js +6 -0
  16. package/dist/auth/api.d.ts +43 -0
  17. package/dist/auth/api.js +22 -0
  18. package/dist/auth/auth.d.ts +32 -0
  19. package/dist/auth/auth.js +23 -0
  20. package/dist/auth/cookies.d.ts +46 -0
  21. package/dist/auth/cookies.js +156 -0
  22. package/dist/auth/index.d.ts +14 -0
  23. package/dist/auth/index.js +8 -0
  24. package/dist/auth/renewal.d.ts +4 -0
  25. package/dist/auth/renewal.js +37 -0
  26. package/dist/auth/store.d.ts +70 -0
  27. package/dist/auth/store.js +284 -0
  28. package/dist/auth/types.d.ts +105 -0
  29. package/dist/auth/types.js +5 -0
  30. package/dist/auth/wake.d.ts +18 -0
  31. package/dist/auth/wake.js +23 -0
  32. package/dist/cache.d.ts +49 -0
  33. package/dist/cache.js +144 -0
  34. package/dist/canonical.d.ts +7 -0
  35. package/dist/canonical.js +32 -0
  36. package/dist/cart/api.d.ts +25 -0
  37. package/dist/cart/api.js +16 -0
  38. package/dist/cart/index.d.ts +10 -0
  39. package/dist/cart/index.js +6 -0
  40. package/dist/cart/lines.d.ts +15 -0
  41. package/dist/cart/lines.js +23 -0
  42. package/dist/cart/storage.d.ts +30 -0
  43. package/dist/cart/storage.js +82 -0
  44. package/dist/cart/store.d.ts +58 -0
  45. package/dist/cart/store.js +218 -0
  46. package/dist/cart/types.d.ts +194 -0
  47. package/dist/cart/types.js +11 -0
  48. package/dist/catalog/cached.d.ts +47 -0
  49. package/dist/catalog/cached.js +52 -0
  50. package/dist/catalog/catalog.d.ts +10 -0
  51. package/dist/catalog/catalog.js +7 -0
  52. package/dist/catalog/named.d.ts +3 -0
  53. package/dist/catalog/named.js +7 -0
  54. package/dist/catalog/products.d.ts +28 -0
  55. package/dist/catalog/products.js +12 -0
  56. package/dist/catalog/store-data.d.ts +13 -0
  57. package/dist/catalog/store-data.js +6 -0
  58. package/dist/catalog/types.d.ts +430 -0
  59. package/dist/catalog/types.js +4 -0
  60. package/dist/checkout/api.d.ts +14 -0
  61. package/dist/checkout/api.js +7 -0
  62. package/dist/checkout/errors.d.ts +23 -0
  63. package/dist/checkout/errors.js +18 -0
  64. package/dist/checkout/index.d.ts +10 -0
  65. package/dist/checkout/index.js +6 -0
  66. package/dist/checkout/return-urls.d.ts +7 -0
  67. package/dist/checkout/return-urls.js +11 -0
  68. package/dist/checkout/store.d.ts +98 -0
  69. package/dist/checkout/store.js +179 -0
  70. package/dist/checkout/types.d.ts +225 -0
  71. package/dist/checkout/types.js +28 -0
  72. package/dist/commerce.d.ts +55 -0
  73. package/dist/commerce.js +55 -0
  74. package/dist/customer-resources.d.ts +18 -0
  75. package/dist/customer-resources.js +40 -0
  76. package/dist/errors.d.ts +65 -0
  77. package/dist/errors.js +96 -0
  78. package/dist/http.d.ts +49 -0
  79. package/dist/http.js +94 -0
  80. package/dist/index.d.ts +32 -0
  81. package/dist/index.js +22 -0
  82. package/dist/orders/api.d.ts +16 -0
  83. package/dist/orders/api.js +13 -0
  84. package/dist/orders/index.d.ts +6 -0
  85. package/dist/orders/index.js +3 -0
  86. package/dist/orders/orders.d.ts +19 -0
  87. package/dist/orders/orders.js +18 -0
  88. package/dist/orders/types.d.ts +88 -0
  89. package/dist/orders/types.js +4 -0
  90. package/dist/resource.d.ts +19 -0
  91. package/dist/resource.js +15 -0
  92. package/dist/returns/api.d.ts +27 -0
  93. package/dist/returns/api.js +19 -0
  94. package/dist/returns/index.d.ts +6 -0
  95. package/dist/returns/index.js +3 -0
  96. package/dist/returns/returns.d.ts +30 -0
  97. package/dist/returns/returns.js +31 -0
  98. package/dist/returns/types.d.ts +99 -0
  99. package/dist/returns/types.js +2 -0
  100. package/dist/reviews/api.d.ts +19 -0
  101. package/dist/reviews/api.js +15 -0
  102. package/dist/reviews/index.d.ts +9 -0
  103. package/dist/reviews/index.js +5 -0
  104. package/dist/reviews/reviews.d.ts +23 -0
  105. package/dist/reviews/reviews.js +37 -0
  106. package/dist/reviews/rules.d.ts +8 -0
  107. package/dist/reviews/rules.js +7 -0
  108. package/dist/reviews/types.d.ts +54 -0
  109. package/dist/reviews/types.js +10 -0
  110. package/dist/shop/address-store.d.ts +14 -0
  111. package/dist/shop/address-store.js +28 -0
  112. package/dist/shop/address.d.ts +12 -0
  113. package/dist/shop/address.js +95 -0
  114. package/dist/shop/index.d.ts +16 -0
  115. package/dist/shop/index.js +9 -0
  116. package/dist/shop/question.d.ts +15 -0
  117. package/dist/shop/question.js +12 -0
  118. package/dist/shop/range.d.ts +7 -0
  119. package/dist/shop/range.js +38 -0
  120. package/dist/shop/sort.d.ts +19 -0
  121. package/dist/shop/sort.js +18 -0
  122. package/dist/shop/state.d.ts +47 -0
  123. package/dist/shop/state.js +51 -0
  124. package/dist/shop/store.d.ts +26 -0
  125. package/dist/shop/store.js +47 -0
  126. package/dist/shop/to-search.d.ts +14 -0
  127. package/dist/shop/to-search.js +53 -0
  128. package/dist/store-kit.d.ts +39 -0
  129. package/dist/store-kit.js +81 -0
  130. package/dist/wishlist/api.d.ts +12 -0
  131. package/dist/wishlist/api.js +10 -0
  132. package/dist/wishlist/index.d.ts +7 -0
  133. package/dist/wishlist/index.js +4 -0
  134. package/dist/wishlist/types.d.ts +30 -0
  135. package/dist/wishlist/types.js +4 -0
  136. package/dist/wishlist/wishlist.d.ts +45 -0
  137. package/dist/wishlist/wishlist.js +84 -0
  138. package/package.json +39 -5
package/README.md CHANGED
@@ -1,3 +1,193 @@
1
- # Temporary Holding Version
1
+ # @webround/commerce
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The logic of a Webround Commerce store (catalog, customers, cart and checkout), with no UI and no framework. A site is code (tsx) that uses it; what a
4
+ framework needs comes as an **adapter**, a package of its own (`@webround/commerce-react`; `@webround/commerce-next` and others later), never in the core.
5
+
6
+ - It knows nothing of `@webround/core` or `@webround/editor`, and they know nothing of it. The editor edits the site's code as it always does.
7
+ - It is a plain npm package (for now a tarball in `webround-dev-container/vendor/`, see the Makefile: `make build-commerce`, `pack`, `publish-commerce`).
8
+ - Node ≥ 22 (tests run TypeScript directly), TypeScript strict, ESM only.
9
+
10
+ ## The layers (each one knows only the one below)
11
+
12
+ ```
13
+ HttpClient src/http.ts one per backend + store: address, `store-id` header, timeout, `getHeaders` (the JWT), CommerceError
14
+ └ calls src/catalog/*.ts products.search/getBySlug/getById/getReviews/getRelated, storeData.get: ask and give back what the backend answers
15
+ └ canonical src/canonical.ts a question written in ONE way (keys and lists sorted): the same question is the same text
16
+ └ cache src/cache.ts in memory, per page: key → idle | pending | ready | error; the same question in flight is ONE request
17
+ └ cached catalog src/catalog/cached.ts `Resource`s (a question + how it stands) over the cache; `invalidate`
18
+ └ adapter src/react/… binds a `Resource` to a framework (`useResource`)
19
+ ```
20
+
21
+ Beside the cache, pure and with no I/O (`src/shop/`): **the rules of the shop**. `ShopState` is what a customer chose (collection, text, tag values, one range per
22
+ filter, sorting, page); every change is a function returning a new state (`setRange`, `toggleTagValue`, `setSort`…) and any change to what is looked for goes back to
23
+ page 1 (`setPage` is the only one that does not). `toSearch(state, storeData, context)` turns it into the catalog's `ProductSearch` and never throws:
24
+ a text is trimmed, cut at 255 and not sent when empty; a range is `<id>[<min>;<max>]` (a bound that is not valid is no bound; min above max is turned round, min equal
25
+ to max is a range; no ends, not sent); only the store's own range filters and tag values are sent, ids exactly as the store wrote them; the sorting always goes with its
26
+ direction (the catalog's silent default is descending) and only if the store offers it (`sortOptionsOf`: `match` always: with no text the catalog orders by creation, newest first; `tag:`/`attribute:` only when
27
+ `sortable`); the limit is 1-100 and `page × limit` cannot pass 10 000 (the catalog answers 400, and a 500 for a huge page). Each rule is something seen against a real
28
+ store: `smoke/catalog.smoke.ts`, "how a search combines".
29
+
30
+ `ShopStore` (`createShopStore`, `shopActions`) is where the state lives: an interface of three methods (`getState`, `subscribe`, `set`), in memory by default; the same state object until
31
+ something really changes (a change that leaves it as it was tells nobody); one that keeps it in the address bar is another implementation. `shopQuestion(catalog, storeData, state,
32
+ context)` is the state made into a `Resource` of the cached catalog (null until the store's data is there). `/react` binds them: `useShop({ catalog, context, initial?, store?,
33
+ keepPrevious? })` gives `state`, `actions`, `storeData`, `sortOptions`, `question` and `results` (`useResource`'s shape: `keepPrevious` on by default, so a listing does not empty at
34
+ every click). The text of the search box is the VIEW's (what is being typed); `actions.setSearch` is what is searched (on Enter, or after a pause the component decides): every state
35
+ change goes back to page 1 and asks the catalog, so a keystroke must not be one.
36
+
37
+ **The address** (`src/shop/address.ts`, `address-store.ts`; `useShopAddress` in `/react`): the state written in the query string and read back, so a search is a link, "back" goes back through
38
+ the choices and a reload keeps them. Keys: `q` text · `ignore` 1/0 · `tags` tag value ids with commas · `r.<filter id>=min..max` (either side may be empty) · `sort=<field>:<asc|desc>` · `page` ·
39
+ `limit`. The default is not written (an empty state is an empty address); only OUR keys are touched (`utm_source` and the rest stay as they were); an address made by hand can say anything and what is
40
+ not valid is ignored (the first of a repeated key counts); ids are the store's own; the collection is NOT in the query (it is the page: `/shop/<slug>`, and the page calls `setCollection`).
41
+ `queryToShopState` needs no store data (what a value means is `toSearch`'s). `createAddressShopStore(port)` is a `ShopStore` over a port of three functions (`read`, `write`, `subscribe`); the
42
+ address is the truth, a change that changes nothing writes nothing (no history entry). `useShopAddress({ search, navigate, collectionId?, limit? })` makes one from what a React router gives
43
+ (`location.search`, a `navigate`), without naming any router; a change is shown at once and the router's later answer says the same; the router saying something else (back) is the state, in
44
+ that same render. Use it as `useShop({ store: useShopAddress(...) })`.
45
+
46
+ **Customer auth, calls** (`src/auth/`, step A): `createAuthApi(http)` has the calls of commerce-core that are about who a customer is (`signup`, `login`, `totpLoginVerify`, `refresh`, `logout`,
47
+ `requestReset`, `resendVerification`, `requestDelete`, `totpSetup`, `totpConfirm`), each giving back what the store answers, unchanged; no state. Facts seen against a real commerce-core
48
+ (`smoke/auth.smoke.ts`, which WRITES: it makes a customer `smoke.<time>@example.test`, only against a development store): a signup also logs in; a login is a session or, with two-factor, a
49
+ `TwoFactorChallenge` (`requiresTwoFactor`); the JWT lasts an hour and is `Authorization: Bearer` (from the client's `getHeaders`); the refresh token is the cookie `wrRefreshToken_<storeId>` (HttpOnly,
50
+ Secure, SameSite=None, 30 days) and belongs to a `deviceId` the client makes; the CSRF token comes in the session of a signup/login (not in the DTO), goes in `x-wr-csrf` for a refresh and is
51
+ made with the refresh token (a new login, a new one; a refresh gives none); an invalid or ended JWT is a 401 with NO `code`, so a client treats any 401 the same way (refresh once); a refresh that
52
+ fails is 403 (no cookie, no CSRF) or 401 (wrong ones: no code; ended: `EXPIRED_TOKEN`); a validation is a `message` that is a LIST of `{ path, message }`, read as `CommerceError.issues`
53
+ (the field of each); `requestReset` answers 204 for an address it does not know. The emails' links (verify, choose a password, confirm a deletion) are pages of commerce-core, not of the site.
54
+ Two-factor is covered when the store's key service is there (setup, confirm → a session with `extraData.recoveryCodes`, login → a challenge, `totpLoginVerify`). Not covered by the smoke: the emails (nothing reads a mailbox) and a JWT that ends (an hour; the store answers it as one that is not valid, which is covered). Seen once: the store answers a signup/login/2FA step from inside its transaction, so a refresh that comes at once can be refused "Invalid Token". **Customer auth, the session** (step B; `createAuth`, `createAuthStore`, `cookies.ts`): the JWT is in memory only. What survives a page load is the store's refresh-token cookie (HttpOnly, which
55
+ JavaScript never sees; `credentials: 'include'`) and two cookies the CLIENT writes (so a server render can read them): `wrCsrfToken_<storeId>` (the CSRF token the login gave, 30 days, as long as the
56
+ refresh token; a refresh sends it IN A HEADER, `x-wr-csrf`) and `wrDeviceId_<storeId>` (a year). Cookies go through a port of two functions (`browserCookiePort`, `memoryCookiePort`). `AuthStore`
57
+ (`getState`, `subscribe` like the shop's, `restore`, `signup`, `login`, `verifyTwoFactor`, `logout`, `refresh`, `accessToken`, `authHeaders`): `unknown` → `signedOut` | `signedIn`; a login may be a `challenge`
58
+ (two-factor). It renews the JWT a minute before it ends (a timer) and before it is used; a renewal that could not reach the store is asked again after 2, 4, 8, 16 and 30 seconds, for as long as the JWT is good (a page that loaded with no network: ten asks); a `WakePort` (`browserWakePort`: tab visible again, `online`, `pageshow`) makes it look at the JWT against the clock when the page is back, since a timer does not run while a machine sleeps; `dispose()` stops the timer and the port; **The cart's calls** (`src/cart/`, `createCartApi(http)`; step 1 of the cart): `get`, `addItem`, `updateItem`, `removeItem`, `merge` (these need the JWT: pass the client of `createAuth`), `total` and `verifyPromotions` (with or without one: a guest's lines are in the page and `total` prices them). What the real store does, found by `smoke:cart` and written in the types: `addItem` ADDS to a variant that is already there and answers what happened, not the cart (ask with `get`); a cart has 99 different variants at most and ONE currency and cadence (`CART_LIMIT_REACHED`, `CART_CURRENCY_MISMATCH`, in `CART_ERROR_CODES`); the two totals count only the lines that are AVAILABLE (`isAvailable`); `total` leaves out a variant the store does not have (it does not refuse it) and gives a guest's lines no `id`; `verifyPromotions` with no codes, or with one the store does not know, is 404 `PROMOTION_INVALID`, and wants `cartTotal` as a text of digits; an option of a line is `{ optionName, tagValue, tagValueAlias }` in what `total` answers (the value in the language asked) and `{ optionName, optionValue }` in a customer's cart (the value as it was saved: another thing, not another name). Next: the cart's store (a guest's lines in `localStorage`, the customer's cart, `merge` at login, following `useAuth`), then `useCart`. **The cart's store** (`src/cart/store.ts`, `commerce.cart`; `useCart(commerce)` in `/react`): not logged in, the cart is a guest's: lines kept in the browser (`CartStorage` over a `KeyValuePort`, `localStorage` by default, memory where there is none; key `wrCart_<storeId>`) and priced by the store's `total`. Logged in, it is the customer's and every action is a call of the store's. When a customer logs in or signs up with lines in the browser they are merged with the store's `merge`, and forgotten ONLY once the store has them (a merge that could not be asked for loses nothing and is asked again at the next read; one the store refuses for the lines, 400/404/409/422, is not asked again until the next login; a 403 is asked again: the store answers it right after a signup). State: `lines`/`count` (what the cart IS: known at once) apart from `priced`/`isStale` (what it COSTS: the store's answer, which may be one change behind). Actions (`add`, `setQuantity`, `remove`, `refresh`) go one at a time; a customer's shows at once and is settled with one read when the last is done; a refused one THROWS what the store said and the cart goes back to what the store holds. The cart's `cartTrackingId` (the store's analytics id) is made the first time it is needed, sent with every read and pricing, kept from the guest's cart to the customer's after logging in, and forgotten when the cart is empty. Logging out gives the browser's cart back (empty if its lines were merged): the customer's does not come along. To step a quantity read it from `state.lines`, not from `priced`. A server render says `unknown` and zero items, as the first render in the browser does. **The account** (`src/account/`, `commerce.account`; `useAccount(commerce)` in `/react`): the requests a customer makes about their own account, each one call of the store's. `requestPasswordReset(email)`, `resendVerification()`, `requestAccountDeletion()` only ASK: the store sends an email and the page it leads to (where a password is chosen, an address is verified, a deletion is confirmed) is the store's own. `startTwoFactorSetup()` gives the secret and the address an authenticator app takes; `confirmTwoFactor(code)` turns it on, takes the new session (the customer is `twoFactorEnabled`) and gives the RECOVERY CODES, once (turning it off does not exist in the store). The newsletter is a strategy the store applies and `newsletterStrategy(authState)` tells the screen: a logged-in customer with a verified address is done AT ONCE with their own address (`direct: true`; the session is then read again so `customer.newsletter` says it); a customer whose address is not verified, or a guest (who has to say which address), gets an email with a link (`direct: false`). Asked by someone who is NOT logged in with a REGISTERED address (the store does not look at whether it is verified, for a guest): subscribing sends the owner an email and changes nothing (`direct: false`), unless the address is already subscribed, which it answers `direct: true` (so a guest can learn that an address is subscribed); cancelling sends an email only if it is subscribed, and answers `direct: false` either way. The store limits subscribing and cancelling to 5 a day each per IP. A signup's `newsletter: true` is applied only when the address is verified. Seen on the real store: a logged-in customer already subscribed gets `direct: false` and no email; a guest cancelling an address that was never confirmed gets `direct: false` (an email); a customer cancelling when not subscribed gets `direct: true`. **The addresses** (`src/addresses/`, `commerce.addresses`; `useAddresses(commerce)` in `/react`): a CUSTOMER's addresses are the store's (`/store-customers-addresses`): every action is a call of its and what is shown is what the store holds afterwards (making one the default takes it from the one that was). A GUEST's addresses are kept NOWHERE: in memory, for as long as the page lives (a checkout needs one to ship to and one to bill), with an id of their own (`local-…`); they do not go to a customer when somebody logs in, nor a customer's to a guest. `remember(input)` is what a checkout calls with an address just entered: it keeps it if the customer does not have it already (the same address whatever case and spacing; being a default does not make it another) and gives back the one that is kept. A store keeps 5 addresses for a customer by default (it depends on the plan); the one that goes over is answered with a **429 and no code** ("Too many store_customer_addresss. Limit is 5."), like a rate limit: `dataLimitOf(error)` tells it apart (from the message) and says the limit. **What the customer bought** (`src/orders/`, `commerce.orders`; `useOrders`, `useOrder`, `useSubscriptions`, `useSubscription` in `/react`): the customer's orders, one order and their subscriptions, a page at a time (`page`, `limit` 1 to 100 and 25 by default, `sortBy`, `sortDirection`, `lang`), as RESOURCES of the same cache the catalog is in (`useResource` shows them; a page keeps showing the one before while the next comes). They are the customer's own: the key says whose, there is no resource (`idle` in a hook) when nobody is logged in, and when the customer goes (a logout, another login) what was kept for them is forgotten (`createCustomerResources`, which is what the returns, the reviews and the rest of what a customer reads will be made with). `commerce.orders.refresh()` forgets what was kept (a customer who has just bought wants to see it). An order's items carry `orderItemId`, which a return request names. Held against a real store: the pages (empty, with their pagination), the refusals (404 for what is not the customer's, 400 for a page that is not one) and the cache; the shape of an order that is NOT empty is the types' (the store's code and documentation), to be held against a store that has orders. **Returns** (`src/returns/`, `commerce.returns`; `useReturnRequests`, `useReturnRequest`, `useGuestReturnSession` in `/react`): a CUSTOMER's requests are resources of the cache (their own, as the orders are: `list`, `get`), and `create(input)` / `cancel(id)` forget what was kept so the next read is what the store holds. The `orderItemId`s are the ones of an order's items (`commerce.orders`). Only a `pending` request can be cancelled (the store answers 400 to any other). A GUEST (someone who bought with no account) goes through a LINK: `guest.requestLink({ customerEmail, orderId | reference })` asks the store to email it to the address of the order (it answers the same whether the order exists or not; an order that belongs to an account is refused, 400: its customer logs in). **The link leads to the SITE's page `/returns?token=…`** (the store builds it from the draft's domain): the site needs that page, which reads `token` from the address and shows `useGuestReturnSession(commerce, token)` (the order and its items to choose from); `guest.create({ token, items, reason, shippingAddress, billingAddress })` and `guest.cancel(id, token)` prove who they are by the token (401 for one that is not good or has ended). Held against a real store: what it says to a customer with no orders (an empty page, 404 for an order or a request that is not theirs), to a guest asking for a link, and to a token that is not good; NOT held: a request that is made (it needs an order) and the link itself (it is emailed): their shapes are the types' (the store's code). **The wishlist** (`src/wishlist/`, `commerce.wishlist`; `useWishlist(commerce)` in `/react`): a CUSTOMER's, kept by the store (`/store-customer-wishlists`: the list and one call that is a SWITCH, adding a variant that is not in it and removing one that is). A guest has none: the switch refuses with `LOGIN_REQUIRED` (for the screen to ask them to log in) and the store is not asked. The state is what a heart needs: `variantIds` (which variants are in it, `has(variantId)` in the hook, `isInWishlist`), `count`, `enabled`. A switch shows at once and is settled with what the store holds (read again afterwards); one the store refuses throws what it said and the state goes back to what it was; switches go one at a time. A store can turn the wishlist off (403 "Wishlist is disabled for this store"): `enabled` is `false` from then and the screen can hide its hearts (nothing says it before the first ask: the store's `store_data` does not carry the flag). The store keeps 50 variants for each customer by default; the one that goes over is a 429 with no code (`dataLimitOf`). **Seen on the real store, and it is the store's to fix:** AT the limit even a removal is refused (the same 429): the rule counts the items plus one on every POST of the switch, and removing is the same POST, so a customer who has 50 can not take one out. **Reviews, written** (`src/reviews/`, `commerce.reviews`; `useMyReviews`, `useReviewAbility` in `/react`): a customer's own reviews (`mine`, a resource of the cache: theirs, none when nobody is logged in), and `create(variantId, { rating 1-5, title 2-100, comment 10-2000 })`, `update(id, patch)`, `remove(id)`: the store answers them in `data` and the facade gives that back unwrapped. The store lets only a customer whose address is VERIFIED write (`EMAIL_NOT_VERIFIED`, 400; a change asks for it too, a removal does not), one review for each variant (`REVIEW_ALREADY_EXISTS`, 409) and can turn reviews off (403). `reviewAbility(authState)` / `useReviewAbility` is the hint for the screen (`login`, `verify`, `allowed`: the store decides, the session may be one verification behind). A guest is asked to log in (`LOGIN_REQUIRED`) without asking the store. Writing, changing or removing forgets what was kept of the customer's reviews AND what the catalog kept of that product's reviews (`products.getReviews`; all of them after a removal, which does not say the product), so the next read shows it. Held against a real store: the customer's page (empty), the refusals and the store's rule; NOT held: a review that is written, changed and removed (it needs a verified address, which takes the link in the email): its shape is the types' (the store's code). **What the package does NOT check (decided 14/10).** The store is the source of truth, so the package does not validate what the store validates, and never refuses a request the store might accept: an address, a return, a review, a quantity, a page or a text that is too big or too small is SENT, and the store's 400 comes back as a `CommerceError` (`issues` lists the fields, `code` is what an interface translates). The types (`AddressInput`, `ReviewInput`, `CreateReturnInput`, `CartLineInput`…) are where a screen is told at compile time that something is not right: a type error is a warning, not a request that is not made. This keeps a version of the package working against a store whose rules moved. What stays is not validation: a guest has no wishlist or reviews (`LOGIN_REQUIRED`, the store is not asked: that will not change), `reviewAbility` and `newsletterStrategy` as hints for the screen (the store decides), the guest's cart keeps one line for each variant (adding to a variant adds), and `sameAddress` for `remember`. **`createCommerce({ storeId, commerceApiUrl, catalogApiUrl, … })`** makes what a page needs of ONE store together: `auth` (the session, over the commerce API), `catalog` (cached, over the catalog API: another address) and their `cache`; plain configuration, no environment. **`@webround/commerce-react/site`** is the store of THIS site, made from what it was built with (`wrSiteEnv` of `@webround/core`: `VITE_WR_DRAFT_ID`, which is the `storeId`, `VITE_WR_COMMERCE_API_URL`, `VITE_WR_CATALOG_API_URL`): `import { commerce } from '@webround/commerce-react/site'`, then `useAuth(commerce.auth)`. It is made the first time `commerce.auth`/`.catalog` is read, not when it is imported: a site built without the variables fails where it uses the store and says which are missing (`commerceFromEnv(env, options)` is the same with an environment you give). **`useAuth(auth)`** in `/react` (`auth` is what `createAuth` made, a module of the site makes it once and components import it; no Provider): `{ state, status, customer, challenge, actions }`, `actions` = `signup`, `login`, `verifyTwoFactor`, `cancelChallenge`, `logout` (one object, the same at every render). A call that fails THROWS what the store said: it belongs to the form that made it. The first component that mounts takes the customer back (`restore`, once); a server render says `unknown` and asks nothing; one refresh at a time; a refresh the store REFUSES (401/403) ends the session, one that cannot be reached
59
+ does not (it keeps what is good, with `error`); a renewal that comes back after a logout does not bring the session back; logout never throws. `createAuth` makes the store with a client that carries no
60
+ JWT (it asks for the JWT) and gives the site another that carries it. Seen against the real store: it answers a signup/login from inside its transaction, so a call made at once can be refused
61
+ (a refresh: 401 "Invalid Token"; a call with the JWT: 403 "Customer not found"; a logout: 401 "Token mismatch"; the second step of a two-factor login: 404 "Challenge not found"): it
62
+ answers `finalizeAuthSession` and `handle2FAChallenge` from inside `db.transaction`, before the commit; and it limits signups and logins to 60 an hour per IP. **Customer auth, renewing on a 401** (step C; `withTokenRenewal`, which `createAuth`'s `http` is): the store answers a JWT that has ended the way it answers one that was never one, a 401 with NO
63
+ `code`, so a request that carried the JWT and got that is asked again ONCE with a new one. Not for a 401 that has a code (`INVALID_CREDENTIALS`), not for a 403 (right after a signup the store may answer
64
+ 403 "Customer not found" for a moment: that is not "log out"), not for a 500, not for the refresh token's own path, not for a caller that set `Authorization` itself or gave up. Many requests refused
65
+ together are one renewal (`AuthStore.renew`: one at a time; a request refused with a JWT that is no longer the current one asks again with the current); a new JWT is told by a counter and not by its
66
+ text (two JWTs made in the same second are the same text). If the renewal fails the caller gets the 401 it got, and the session says why (signed out, or the store not reached, with `error`);
67
+ a second 401 is the answer and does not end the session. Next (D): `useAuth` in `/react`.
68
+
69
+ **The checkout** (`src/checkout/`, `commerce.checkout`; `useCheckout(commerce)` in `/react`): two calls of the checkout API, which is ANOTHER backend (`checkoutApiUrl`, `VITE_WR_CHECKOUT_API_URL`) that takes the same JWT
70
+ (`auth.httpFor(url)` is a client like `auth.http` for another address). `createCheckoutApi(http)`: `quote(input)` is `POST /v2/checkout/quote` (what the cart costs with the store's rules and hooks; it changes nothing) and `pay(input)` is
71
+ `POST /v2/checkout/stripe` (NOT `/pay`: it reserves the stock, makes the order and answers `{ url, orderId, reference }`: the payment page to send the customer to, and the order that was made and its number). The types (`src/checkout/types.ts`) are the checkout service's own and as strict as
72
+ it is: a delivery method is `pickup | shipping | none` (chosen for each VARIANT of the cart), the invoice asks for both its codes when it is asked for (`InvoiceInput`), the return URLs come both or neither (`ReturnUrls`), a promotion code that
73
+ did nothing says why (`PromotionRejectionReason`, in `quote.promotions.rejected`: not an error), and the shipping and billing addresses have ONE shape everywhere (`CheckoutAddress`: an address of the book without its id and defaults).
74
+ Held against the processor itself, not by hand: the bodies the package builds pass the processor's own `quoteV2Schema` and `payV2Schema` (strict), and `Quote` is the same type as the processor's `quoteResponseSchema` (checked both ways;
75
+ `currencyCode` is a text here). **The store** (`createCheckoutStore`, `commerce.checkout`): `state` has what the customer CHOSE (`deliveryMethods` by variant id, `shippingAddress`, `billingAddress`, `shippingMethodId` from the preview's options,
76
+ `promotionCodes`, `additionalData`, `email`, the invoice) and what the store SAID (`quote`). The lines are the cart's, read from it and never kept: the preview asks for one line for each variant (the cart already keeps it so: adding to a variant adds, and
77
+ nothing here caps a quantity: the store's is 1 to 10 000) with the cart's tracking id. **The preview is never cached** (stock and prices change): `quote()` always asks; asking again while one is on its way cancels the first (it throws `aborted`), and an
78
+ answer to an older question is thrown away. `isStale` says the answer on show is not for what would be asked now (a change of the cart, of who is logged in, or of anything the preview reads; the email and the invoice are not part of it).
79
+ A refused preview throws what the store said, leaves it in `state.error` and keeps the last good one on show as stale. `ensureQuote()` is what a page does by itself: it asks unless the cart is empty or this very question was already asked (by anyone:
80
+ a refusal is not asked again by itself, any number of components can call it); `useCheckout` calls it when the cart or the choices change (`autoQuote: false` turns that off). Nothing is checked here: a cart with no lines, a missing delivery or a
81
+ code that does not exist is sent, and the store's refusal comes back. What the checkout refuses on purpose has a `code` (`CHECKOUT_ERROR_CODES`: `CHECKOUT_BLOCKED` with `{ hookId, code, target }`, `CHECKOUT_HOOK_FAILED`, `SHIPPING_METHOD_UNAVAILABLE`) and
82
+ `checkoutRefusalOf(error)` reads it with its `meta` typed. **Lines with not enough stock** (19/10): the preview does not refuse them, it SETS THEM APART: `quote.unavailable` has them (`QuoteUnavailableLine`: priced like the others but without discounts, with `availableStock`; the whole line even if some units exist), and `quote.items` and `quote.totals` count only what can be bought. The preview is still asked WITH every line (it is what says which ones are unavailable); the payment is not: `pay()` leaves out the lines the last preview said are unavailable (and their delivery method), and when none is left it throws `NOTHING_TO_PAY` without asking the store. A page pays with a fresh preview; the store's own 400 (`Insufficient stock…`, the last unit sold between the preview and the payment) is what is left, and the page asks for the preview again. **The preview describes the lines too** (19/10): each line of `items` and of `unavailable` carries `variantName`, `productName`, `variantSlug`, `skuCode`, `coverUrl`, `options`, `allowedDeliveryMethods` (what `deliveryMethods` can choose from), `availableStock`, `allowBackorder` and `maxOrderQuantity` (`QuoteLinePresentation`): in the v2 flow it is the ONE call that prices a cart and describes it, and it replaces `/cart/total` (the store reads these from OpenSearch like `/cart/total` does). A site that works this way makes its store with `cartPricing: false` (`CartStoreOptions.pricing`; `@webround/commerce-react/site` does): a guest's lines are kept and counted, `priced` stays null and `/cart/total` is never asked; a customer's cart is still read with `/cart/get`, which is where its lines are. **Delivery**: the preview does not need the customer's delivery choice for every line: one without a choice gets the product's default (shipping, else pickup, else none) in the line's `deliveryMethod`, and `allowedDeliveryMethods` says what else it could be; `pay()` sends the customer's choice or, for a line they did not choose for, the one the last preview gave it. **Tracking**: the store's preview sends the cart's analytics event (`cartTrackingId`, as `/cart/total` did), so a site that never calls `/cart/total` is still tracked. **Paying**: `pay(returnUrls?)` builds the order's body from the same choices, `email` (a guest's: who the order is for) and, for an invoice, both codes (a missing one goes as an empty text for the store to
83
+ refuse); it does not redirect (the site sends the customer to `url`) and is asked once per order. Where the customer comes back to is the argument, else `createCommerce({ returnUrls })`, else `browserReturnUrls` (this page's origin: `/checkout/success?clean_cart=true`
84
+ and `/`, what the site has always sent; the store accepts only its own origin, an allowed one or localhost). **After the payment** (the success page): `commerce.cart.clear()` (a guest's lines and tracking id forgotten in the browser; a customer's cart,
85
+ which the store emptied by itself, is read again) or `commerce.checkout.complete()` (that, and the choices and the preview forgotten). `commerce.checkout` needs `checkoutApiUrl`: a store made without it works, and it throws, saying so, where the checkout is read.
86
+ `smoke/checkout.smoke.ts` holds the preview against a real checkout API (it NEVER pays): the shape, the arithmetic (net + VAT = gross, per line and in total), the refusals, a shipping preview, the store over the real API and, with commerce-core, a cart made through
87
+ the package whose lines the preview reads (the delivery each product allows is read from the cart's priced lines: `CartItem.deliveryMethods`, which is what a screen offers to choose from). Seen on the real API: a validation refusal (a quantity, an empty cart, a duplicate) is a 400
88
+ with a message and no `code`; the ones the checkout gives a code to are `SHIPPING_METHOD_UNAVAILABLE`, `CHECKOUT_BLOCKED` and `CHECKOUT_HOOK_FAILED`; a code the store does not have is 404; something to ship with no address is `shippingPending` (nothing chosen, the total without shipping).
89
+ Hooks (16/10, tried once, not kept in the repo: it names a path of this machine): the SDK over the processor running IN a test (test database and OpenSearch), with the documentation's free-shipping hook as a real HTTP server that signs its answers. Held: without the hook a shipping
90
+ of 5 net costs 6.10 gross; with an always-free hook the chosen shipping is 0, `discountTotal` is 5 (the shipping discount) and the discount is `{ source: { type: 'hook', hookId }, class: 'shipping' }`; under the hook's threshold nothing changes; a hook that must succeed (`abort`) and answers with a wrong signature is
91
+ `CHECKOUT_HOOK_FAILED` with `meta.hookId` (the same hook with `passthrough` is skipped and the preview is the normal one); a validation hook's `block` is `CHECKOUT_BLOCKED` with `hookId`, `code`, `target`; and the checkout store re-asks by itself when the shipping is chosen and when the cart changes. Not held against the
92
+ running development API: a hook (it needs an `https` address and the key service), and `pay` (it opens a real payment session: by hand, with the provider's test mode).
93
+
94
+ ## Rules that do not bend
95
+
96
+ 1. **The backend's answers are never altered.** Methods return exactly what the API says, `{ data }` envelope included (no unwrapping, no renaming, no "cleaning"). If a
97
+ field looks wrong, say so; do not fix it here. `wr-commerce-catalog` (the catalog API) is never modified from this repo's work.
98
+ 2. **Types come from what the catalog really sends, not from its OpenAPI** (it is wrong in places). `smoke/` checks the types against a real store; when a real answer
99
+ does not match, the TYPE changes (`Format`, nullable `label`/`icon`, `quantityTable | null` were found that way).
100
+ 3. **No defaults for what the caller chooses.** `include*` options are sent only when given. This is a data layer, not a set of opinions.
101
+ 4. **No framework, no environment, no storage in the core.** Anything that needs one comes in as an argument (`fetch`, `getHeaders`, later a storage adapter).
102
+ 5. **Errors are `CommerceError`** (`kind`: http, network, timeout, aborted, parse; the server's `code` for the interface to translate; never headers, body or query).
103
+ 6. **The language** a query takes is `default` (the store's default language, whatever its own code is) or one of the store's other languages.
104
+ 7. The slug belongs to a **variant**, not to a product.
105
+
106
+ ## The cache, in one paragraph
107
+
108
+ A key (`cacheKey`: backend | store | call | subject | canonical params) is `idle`, `pending`, `ready` or `error`. `load(key, fetcher, {signal})`: `ready` is
109
+ returned; `pending` is joined; `idle`/`error` calls the fetcher once. Errors are state to show, never a kept result. A caller that gives up (`signal`) does not
110
+ stop the request for the others; it stops when nobody waits. `invalidate(prefix)` forgets (a request in flight goes on for whoever waits, what it brings is not
111
+ kept). `snapshot(key)` is the SAME object until the key changes, which is what lets a framework tell that something did.
112
+
113
+ ## What an adapter binds to: `Resource<T>` (`src/catalog/cached.ts`)
114
+
115
+ ```ts
116
+ interface Resource<T> {
117
+ key: string;
118
+ load(options?: { signal?: AbortSignal }): Promise<T>; // from the cache, or joins the request in flight, or asks
119
+ snapshot(): CacheSnapshot<T>; // never asks; same object until it changes
120
+ subscribe(listener: () => void): () => void;
121
+ }
122
+ ```
123
+
124
+ `catalog.resource.products.search(input)` and the other `resource.*` calls make them (a NEW object each call; the SAME `key` is the same question). An adapter
125
+ depends on `Resource` and `Cache`, never on HTTP or on a catalog: that is what keeps a GraphQL catalog possible
126
+ (`createCachedCatalog(identity, cache, catalog)` takes any object that implements `Catalog`).
127
+
128
+ ## Writing a new adapter (`/next`, `/vue`, …)
129
+
130
+ 1. **Declare it**: an `exports` entry `./<name>` in `package.json` (`import` → `dist/<name>/index.js`, `types`, `development` → `src/<name>/index.ts`), the framework as an
131
+ **optional peer dependency** (`peerDependencies` + `peerDependenciesMeta.optional`), and as a devDependency to test with. Code in `src/<name>/`. `tsc` builds it.
132
+ 2. **Only bind, never decide.** The reference is `src/react/use-resource.ts` (≈100 lines): `subscribe` + `snapshot` for the state, `load({ signal })` to ask, abort when
133
+ the consumer goes, `retry` only from `error`. If you are writing a rule about products, it belongs below, not here.
134
+ 3. **What every adapter must get right** (each is a test in `test/react-use-resource.test.ts`, copy them):
135
+ - the same question from many places is ONE request; a new `Resource` object with the same `key` is not a new question;
136
+ - already `ready` in the cache: shown at once, nothing asked; never a frame of "nothing" for a question about to be asked (`pending`, not `idle`);
137
+ - when nobody is left waiting the request is given up; when what is shown is invalidated it is asked again;
138
+ - an error is state (with the `CommerceError`), `retry` asks again, an error is not retried by itself;
139
+ - the server render reads the same snapshot (a prerender fills the cache with `await resource.load()` first): hydration must not ask again.
140
+ 4. **Test it** the way the React one is: `test/support/dom.ts` (jsdom, import it BEFORE the framework), a fake `Resource` over `createCache()` whose fetcher waits to
141
+ be told (so a test sees `pending`), and one test over the real `createCachedCatalog` with a fake `fetch`.
142
+ 5. **Document it** in the header comment of its file and in this README (what it exports, what it needs).
143
+
144
+ Sketches (nothing built): Vue → `shallowRef` fed by `subscribe`; Svelte → a store (`subscribe` is already the store contract); Next → a server helper that awaits
145
+ `resource.load()` and hands the cache's contents to the client, plus the React adapter.
146
+
147
+ ## Adding a call to the catalog
148
+
149
+ 1. Type of the input and of the answer in `src/catalog/types.ts`, **from a real answer** (run the smoke first and look).
150
+ 2. The call in the right API file (`products.ts`, `store-data.ts`): build the request, give back the answer untouched. A body goes through `canonicalize`.
151
+ 3. Its `Resource` in `cached.ts` (`resource(call, subject, params, fetch)`), its name in `CatalogCall` and `CatalogForget`.
152
+ 4. Tests with a fake `fetch` (`test/catalog-*.test.ts`: what is sent, what comes back, the errors), and a cache test (`catalog-cached.test.ts`).
153
+ 5. The shape of the type in `smoke/shapes.ts` and the call in `smoke/catalog.smoke.ts`.
154
+ 6. Export what is public from `src/index.ts`.
155
+
156
+ ## Commands
157
+
158
+ From `wr-core-sdk/`:
159
+
160
+ ```bash
161
+ make build # all packages (the editor and the playground use dist/)
162
+ npm test --workspace=@webround/commerce # node --test (tests import dist/: build first)
163
+ npm run typecheck --workspace=@webround/commerce # src + test + smoke
164
+ # against a real store (needs the catalog running; reads only):
165
+ npm run smoke:catalog --workspace=@webround/commerce -- http://localhost:3002 <storeId>
166
+ # against a real commerce-core (WRITES: a test customer; a development store only):
167
+ npm run smoke:auth --workspace=@webround/commerce -- http://localhost:3000 <storeId>
168
+ # reviews, with a real variant from the catalog (WRITES: a test customer; its address is not verified, so it cannot write):
169
+ npm run smoke:reviews --workspace=@webround/commerce -- http://localhost:3000 <storeId> http://localhost:3002
170
+ # the wishlist, with real variants from the catalog (WRITES: a test customer and 50 wished variants):
171
+ npm run smoke:wishlist --workspace=@webround/commerce -- http://localhost:3000 <storeId> http://localhost:3002
172
+ # returns (WRITES: a test customer; it has bought nothing):
173
+ npm run smoke:returns --workspace=@webround/commerce -- http://localhost:3000 <storeId>
174
+ # orders and subscriptions (WRITES: a test customer; it has bought nothing):
175
+ npm run smoke:orders --workspace=@webround/commerce -- http://localhost:3000 <storeId>
176
+ # the addresses (WRITES: a test customer and addresses for it):
177
+ npm run smoke:addresses --workspace=@webround/commerce -- http://localhost:3000 <storeId>
178
+ # the cart, with real products from the catalog of the same store (WRITES: a test customer and a cart):
179
+ npm run smoke:cart --workspace=@webround/commerce -- http://localhost:3000 <storeId> http://localhost:3002
180
+ # the checkout's preview, with real products from the catalog (READS only; it never pays; the checkout API must be a development one with the v2 routes):
181
+ npm run smoke:checkout --workspace=@webround/commerce -- http://localhost:3003 <storeId> http://localhost:3002 [http://localhost:3000]
182
+ ```
183
+
184
+ Tests are never bent to pass: if one finds a real defect the code changes; if an old one no longer fits a deliberate change, it is said and changed on purpose.
185
+
186
+ ## Where the project stands
187
+
188
+ Done: HTTP client; `canonicalize`; catalog (search, detail by variant slug and by id, reviews, related, store data); cache; cached catalog with `Resource`s;
189
+ `/react` (`useResource`, `useShop`); the rules of the shop and their store (`src/shop/`); verified against a real store (34 products, 184 variants, quantity tiers, reviews, video assets as HLS `.m3u8`).
190
+ The checkout's preview and the start of the payment (`src/checkout/`, `useCheckout`) are done; the preview is held against a real checkout API (`smoke:checkout`, 16/10: the shape, the arithmetic, the refusals, a shipping preview, the store, and a guest's cart through commerce-core whose total the preview agrees with to the cent).
191
+ Next: active-filter chips, the kit (separate artifact `@webround/commerce-kit`: the pages and
192
+ components as source files installed into a project; a card is a shared component of the editor, see `wr-core-sdk/CLAUDE.md`, "Componenti condivisi"), the Worker's env
193
+ vars (`VITE_WR_COMMERCE_CATALOG_URL`…), SEO/prerender of products.
@@ -0,0 +1,54 @@
1
+ import type { AuthApi } from '../auth/api.js';
2
+ import type { AuthStorage } from '../auth/cookies.js';
3
+ import type { AuthState, AuthStore } from '../auth/store.js';
4
+ import type { TotpSetup } from '../auth/types.js';
5
+ /**
6
+ * What subscribing to the newsletter (or cancelling) will do, from who is asking. A logged-in customer whose address is verified: it is done AT ONCE, with their own address. A logged-in customer whose address is not
7
+ * verified, or a guest: the store sends an email with a link to confirm. A guest has to say which address; a customer does not (the store uses theirs).
8
+ */
9
+ export interface NewsletterStrategy {
10
+ mode: 'direct' | 'email';
11
+ /** The address the store will use: the customer's own; null for a guest, who has to give one. */
12
+ email: string | null;
13
+ needsEmail: boolean;
14
+ }
15
+ export declare function newsletterStrategy(auth: Pick<AuthState, 'status' | 'customer'>): NewsletterStrategy;
16
+ export interface NewsletterResult {
17
+ /** Done at once (`true`), or an email was sent to confirm (`false`). */
18
+ direct: boolean;
19
+ }
20
+ export interface AccountCallOptions {
21
+ /** The language the emails are written in (the site's, by default). */
22
+ language?: string;
23
+ }
24
+ export interface Account {
25
+ /** Asks for the email with the link to choose a new password. It answers the same for an address it does not know. */
26
+ requestPasswordReset(email: string, options?: AccountCallOptions): Promise<void>;
27
+ /** Asks for the email that verifies the customer's address, again (logged in). */
28
+ resendVerification(options?: AccountCallOptions): Promise<void>;
29
+ /** Asks for the email with the link that confirms deleting the account (logged in). */
30
+ requestAccountDeletion(options?: AccountCallOptions): Promise<void>;
31
+ /** The secret and the address an authenticator app takes (logged in). A second request within 15 minutes is `TWO_FA_SETUP_ALREADY_REQUESTED`. */
32
+ startTwoFactorSetup(): Promise<TotpSetup>;
33
+ /**
34
+ * Turns two-factor authentication on with the code the app shows. The recovery codes come back ONLY here: the store does not give them again. The customer is `twoFactorEnabled` from now on.
35
+ */
36
+ confirmTwoFactor(totpCode: string): Promise<{
37
+ recoveryCodes: string[];
38
+ }>;
39
+ newsletter: {
40
+ strategy(): NewsletterStrategy;
41
+ /** A guest says the address; a logged-in customer's own is used. When it was done at once, the customer's `newsletter` is read again. */
42
+ subscribe(email?: string, options?: AccountCallOptions): Promise<NewsletterResult>;
43
+ unsubscribe(email?: string, options?: AccountCallOptions): Promise<NewsletterResult>;
44
+ };
45
+ }
46
+ export interface AccountOptions {
47
+ api: AuthApi;
48
+ auth: Pick<AuthStore, 'getState' | 'accept' | 'refresh'>;
49
+ storage: Pick<AuthStorage, 'deviceId'>;
50
+ /** The language the emails are written in when a call does not say: read at each call. */
51
+ language?: () => string | undefined;
52
+ }
53
+ export declare function createAccount(options: AccountOptions): Account;
54
+ //# sourceMappingURL=account.d.ts.map
@@ -0,0 +1,40 @@
1
+ export function newsletterStrategy(auth) {
2
+ const customer = auth.status === 'signedIn' ? auth.customer : null;
3
+ if (customer)
4
+ return { mode: customer.emailVerified ? 'direct' : 'email', email: customer.email, needsEmail: false };
5
+ return { mode: 'email', email: null, needsEmail: true };
6
+ }
7
+ export function createAccount(options) {
8
+ const { api, auth, storage } = options;
9
+ const withLanguage = (call) => {
10
+ const language = call?.language ?? options.language?.();
11
+ return language === undefined ? {} : { language };
12
+ };
13
+ async function newsletterCall(call, email, callOptions) {
14
+ const strategy = newsletterStrategy(auth.getState());
15
+ // A guest with no address is sent as it is: the store says it needs one (400)
16
+ const address = strategy.email ?? email?.trim() ?? '';
17
+ const answer = await call({ email: address }, withLanguage(callOptions));
18
+ // A customer's own newsletter state changed at the store: the session says it, so the screen does
19
+ if (answer.direct && strategy.email !== null)
20
+ await auth.refresh();
21
+ return { direct: answer.direct };
22
+ }
23
+ return {
24
+ requestPasswordReset: (email, callOptions) => api.requestReset({ email }, withLanguage(callOptions)),
25
+ resendVerification: (callOptions) => api.resendVerification(withLanguage(callOptions)),
26
+ requestAccountDeletion: (callOptions) => api.requestDelete(withLanguage(callOptions)),
27
+ startTwoFactorSetup: () => api.totpSetup(),
28
+ async confirmTwoFactor(totpCode) {
29
+ const session = await api.totpConfirm({ totpCode, deviceId: storage.deviceId() });
30
+ auth.accept(session);
31
+ return { recoveryCodes: session.extraData?.recoveryCodes ?? [] };
32
+ },
33
+ newsletter: {
34
+ strategy: () => newsletterStrategy(auth.getState()),
35
+ subscribe: (email, callOptions) => newsletterCall(api.newsletterSubscribe, email, callOptions),
36
+ unsubscribe: (email, callOptions) => newsletterCall(api.newsletterUnsubscribe, email, callOptions),
37
+ },
38
+ };
39
+ }
40
+ //# sourceMappingURL=account.js.map
@@ -0,0 +1,3 @@
1
+ export { createAccount, newsletterStrategy } from './account.js';
2
+ export type { Account, AccountCallOptions, AccountOptions, NewsletterResult, NewsletterStrategy } from './account.js';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,2 @@
1
+ export { createAccount, newsletterStrategy } from './account.js';
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,16 @@
1
+ import type { HttpClient } from '../http.js';
2
+ import type { CallOptions } from '../catalog/types.js';
3
+ import type { AccessTokenOptions } from '../auth/api.js';
4
+ import type { Address, AddressInput, AddressPatch } from './types.js';
5
+ export interface AddressApi {
6
+ /** `GET /store-customers-addresses`: the customer's addresses. */
7
+ list(options?: CallOptions & AccessTokenOptions): Promise<Address[]>;
8
+ /** `POST /store-customers-addresses`: keeps an address; the one it makes default stops being it for the one that was. */
9
+ create(input: AddressInput, options?: CallOptions & AccessTokenOptions): Promise<Address>;
10
+ /** `PATCH /store-customers-addresses/:id`: changes what is given. `ADDRESS_NOT_FOUND` for one that is not the customer's. */
11
+ update(id: string, patch: AddressPatch, options?: CallOptions & AccessTokenOptions): Promise<Address>;
12
+ /** `DELETE /store-customers-addresses/:id`: nothing comes back. */
13
+ remove(id: string, options?: CallOptions & AccessTokenOptions): Promise<void>;
14
+ }
15
+ export declare function createAddressApi(http: HttpClient): AddressApi;
16
+ //# sourceMappingURL=api.d.ts.map
@@ -0,0 +1,13 @@
1
+ // The calls of commerce-core that are about a customer's addresses (`/store-customers-addresses`), each asking one thing and giving back what the store answers, unchanged. All need the JWT of the customer.
2
+ // A store keeps a limited number of addresses for each customer (5 by default: it depends on the store's plan): the one that goes over is refused, and the refusal is the store's to say.
3
+ const bearer = (options) => (options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {});
4
+ const part = (id) => encodeURIComponent(id);
5
+ export function createAddressApi(http) {
6
+ return {
7
+ list: (options) => http.request({ path: '/store-customers-addresses', headers: bearer(options), signal: options?.signal }),
8
+ create: (input, options) => http.request({ method: 'POST', path: '/store-customers-addresses', body: input, headers: bearer(options), signal: options?.signal }),
9
+ update: (id, patch, options) => http.request({ method: 'PATCH', path: `/store-customers-addresses/${part(id)}`, body: patch, headers: bearer(options), signal: options?.signal }),
10
+ remove: (id, options) => http.request({ method: 'DELETE', path: `/store-customers-addresses/${part(id)}`, headers: bearer(options), signal: options?.signal }),
11
+ };
12
+ }
13
+ //# sourceMappingURL=api.js.map
@@ -0,0 +1,40 @@
1
+ import { CommerceError } from '../errors.js';
2
+ import type { AuthStore } from '../auth/store.js';
3
+ import { type Owner } from '../store-kit.js';
4
+ import type { AddressApi } from './api.js';
5
+ import type { AddressInput, AddressPatch, BookAddress } from './types.js';
6
+ export interface AddressBookState {
7
+ /** Whose addresses these are. `unknown` until it is known whether a customer is logged in. */
8
+ owner: Owner;
9
+ addresses: BookAddress[];
10
+ /** A customer's addresses were read (a guest's are always there). */
11
+ loaded: boolean;
12
+ /** An action or a read is under way. */
13
+ busy: boolean;
14
+ /** What stopped the addresses from being read, without being an action's own failure (those throw): it goes when it works again. */
15
+ error: CommerceError | null;
16
+ }
17
+ export interface AddressBook {
18
+ getState(): AddressBookState;
19
+ subscribe(listener: () => void): () => void;
20
+ /** Starts following who is logged in (asks the auth store to find out) and reads the customer's addresses. Once. */
21
+ start(): Promise<void>;
22
+ /** Keeps an address, and gives it back as kept. A customer's goes to the store (refused if over what their store allows: the store says); a guest's stays in memory. */
23
+ save(input: AddressInput): Promise<BookAddress>;
24
+ /** Keeps the address if the customer does not have it already (the same address, whatever case or spacing); gives back the one that is kept. For a checkout: the customer has not to enter it again. */
25
+ remember(input: AddressInput): Promise<BookAddress>;
26
+ update(id: string, patch: AddressPatch): Promise<BookAddress>;
27
+ remove(id: string): Promise<void>;
28
+ /** Makes an address the default for shipping or for billing (the one that was stops being it). */
29
+ setDefault(id: string, kind: 'shipping' | 'billing'): Promise<void>;
30
+ /** Reads the customer's addresses again. */
31
+ refresh(): Promise<void>;
32
+ dispose(): void;
33
+ }
34
+ export interface AddressBookOptions {
35
+ api: AddressApi;
36
+ /** Whose addresses: only what is needed to follow it. */
37
+ auth: Pick<AuthStore, 'getState' | 'subscribe' | 'restore'>;
38
+ }
39
+ export declare function createAddressBook(options: AddressBookOptions): AddressBook;
40
+ //# sourceMappingURL=book.d.ts.map
@@ -0,0 +1,144 @@
1
+ import { CommerceError, toCommerceError } from '../errors.js';
2
+ import { createSerialQueue, createStateHolder, followAuth } from '../store-kit.js';
3
+ import { sameAddress } from './rules.js';
4
+ const bookOf = (a) => ({
5
+ id: a.id, fullName: a.fullName, company: a.company, phone: a.phone, addressLine1: a.addressLine1, addressLine2: a.addressLine2, city: a.city, postalCode: a.postalCode, province: a.province,
6
+ countryCode: a.countryCode, additionalInfo: a.additionalInfo, vatCode: a.vatCode, isDefaultShipping: a.isDefaultShipping, isDefaultBilling: a.isDefaultBilling,
7
+ });
8
+ /** An address as it is given to be kept: what it says, with what it does not have left out. */
9
+ const inputOf = (a) => ({
10
+ fullName: a.fullName, addressLine1: a.addressLine1, city: a.city, postalCode: a.postalCode, countryCode: a.countryCode,
11
+ ...(a.company !== null ? { company: a.company } : {}), ...(a.phone !== null ? { phone: a.phone } : {}), ...(a.addressLine2 !== null ? { addressLine2: a.addressLine2 } : {}),
12
+ ...(a.province !== null ? { province: a.province } : {}), ...(a.additionalInfo !== null ? { additionalInfo: a.additionalInfo } : {}), ...(a.vatCode !== null ? { vatCode: a.vatCode } : {}),
13
+ isDefaultShipping: a.isDefaultShipping, isDefaultBilling: a.isDefaultBilling,
14
+ });
15
+ /** What a guest's address is, from what was given: no more, no less than the store would keep. */
16
+ const localOf = (id, input) => ({
17
+ id, fullName: input.fullName, company: input.company ?? null, phone: input.phone ?? null, addressLine1: input.addressLine1, addressLine2: input.addressLine2 ?? null, city: input.city,
18
+ postalCode: input.postalCode, province: input.province ?? null, countryCode: input.countryCode, additionalInfo: input.additionalInfo ?? null, vatCode: input.vatCode ?? null,
19
+ isDefaultShipping: input.isDefaultShipping === true, isDefaultBilling: input.isDefaultBilling === true,
20
+ });
21
+ export function createAddressBook(options) {
22
+ const { api, auth } = options;
23
+ const holder = createStateHolder({ owner: 'unknown', addresses: [], loaded: false, busy: false, error: null });
24
+ const set = holder.set;
25
+ const queue = createSerialQueue((busy) => set({ busy }));
26
+ // Counts the times the addresses changed hands: an answer that comes after that is about addresses that are not these any more
27
+ let epoch = 0;
28
+ let localCount = 0;
29
+ async function loadCustomer() {
30
+ const mine = epoch;
31
+ try {
32
+ const list = await api.list();
33
+ if (epoch === mine)
34
+ set({ addresses: list.map(bookOf), loaded: true, error: null });
35
+ }
36
+ catch (error) {
37
+ if (epoch === mine)
38
+ set({ error: toCommerceError(error) });
39
+ }
40
+ }
41
+ function toGuest() {
42
+ epoch++;
43
+ set({ owner: 'guest', addresses: [], loaded: true, error: null });
44
+ }
45
+ function toCustomer() {
46
+ epoch++;
47
+ set({ owner: 'customer', addresses: [], loaded: false, error: null });
48
+ void queue.run(loadCustomer);
49
+ }
50
+ const following = followAuth(auth, { current: () => holder.get().owner, toGuest, toCustomer });
51
+ const ready = async () => { if (holder.get().owner === 'unknown')
52
+ await following.start(); };
53
+ const isCustomer = () => holder.get().owner === 'customer';
54
+ /** A customer's action: the call goes in its turn; afterwards the addresses are read again, so what is shown is what the store holds, a refusal included. */
55
+ function customerAction(call) {
56
+ const mine = epoch;
57
+ return queue.run(async () => {
58
+ try {
59
+ return await call();
60
+ }
61
+ finally {
62
+ if (epoch === mine)
63
+ await loadCustomer();
64
+ }
65
+ });
66
+ }
67
+ /** What a guest's addresses become when one is made the default for `kind`: it takes it from the one that was. */
68
+ const withDefault = (list, id, kind) => {
69
+ const key = kind === 'shipping' ? 'isDefaultShipping' : 'isDefaultBilling';
70
+ return list.map((a) => ({ ...a, [key]: a.id === id }));
71
+ };
72
+ async function save(input) {
73
+ await ready();
74
+ if (isCustomer()) {
75
+ const created = await customerAction(() => api.create(input));
76
+ return bookOf(created);
77
+ }
78
+ const made = localOf(`local-${++localCount}`, input);
79
+ let list = [...holder.get().addresses, made];
80
+ if (made.isDefaultShipping)
81
+ list = withDefault(list, made.id, 'shipping');
82
+ if (made.isDefaultBilling)
83
+ list = withDefault(list, made.id, 'billing');
84
+ set({ addresses: list });
85
+ return list[list.length - 1];
86
+ }
87
+ return {
88
+ getState: holder.get,
89
+ subscribe: holder.subscribe,
90
+ start: () => following.start(),
91
+ save,
92
+ async remember(input) {
93
+ await ready();
94
+ // A customer's addresses have to be there to tell whether this one is kept already
95
+ if (isCustomer() && !holder.get().loaded)
96
+ await queue.run(loadCustomer);
97
+ const known = holder.get().addresses.find((a) => sameAddress(a, input));
98
+ return known ?? save(input);
99
+ },
100
+ async update(id, patch) {
101
+ await ready();
102
+ if (isCustomer())
103
+ return bookOf(await customerAction(() => api.update(id, patch)));
104
+ const at = holder.get().addresses.findIndex((a) => a.id === id);
105
+ if (at < 0)
106
+ throw new CommerceError({ kind: 'http', status: 404, code: 'ADDRESS_NOT_FOUND', message: 'Address not found' });
107
+ const given = Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined));
108
+ const merged = localOf(id, { ...inputOf(holder.get().addresses[at]), ...given });
109
+ let list = holder.get().addresses.map((a, i) => (i === at ? merged : a));
110
+ if (patch.isDefaultShipping === true)
111
+ list = withDefault(list, id, 'shipping');
112
+ if (patch.isDefaultBilling === true)
113
+ list = withDefault(list, id, 'billing');
114
+ set({ addresses: list });
115
+ return list[at];
116
+ },
117
+ async remove(id) {
118
+ await ready();
119
+ if (isCustomer())
120
+ return customerAction(() => api.remove(id));
121
+ if (!holder.get().addresses.some((a) => a.id === id))
122
+ throw new CommerceError({ kind: 'http', status: 404, code: 'ADDRESS_NOT_FOUND', message: 'Address not found' });
123
+ set({ addresses: holder.get().addresses.filter((a) => a.id !== id) });
124
+ },
125
+ async setDefault(id, kind) {
126
+ await ready();
127
+ const patch = kind === 'shipping' ? { isDefaultShipping: true } : { isDefaultBilling: true };
128
+ if (isCustomer()) {
129
+ await customerAction(() => api.update(id, patch));
130
+ return;
131
+ }
132
+ if (!holder.get().addresses.some((a) => a.id === id))
133
+ throw new CommerceError({ kind: 'http', status: 404, code: 'ADDRESS_NOT_FOUND', message: 'Address not found' });
134
+ set({ addresses: withDefault(holder.get().addresses, id, kind) });
135
+ },
136
+ async refresh() {
137
+ await ready();
138
+ if (isCustomer())
139
+ await queue.run(loadCustomer);
140
+ },
141
+ dispose: following.stop,
142
+ };
143
+ }
144
+ //# sourceMappingURL=book.js.map
@@ -0,0 +1,8 @@
1
+ export { createAddressApi } from './api.js';
2
+ export type { AddressApi } from './api.js';
3
+ export { createAddressBook } from './book.js';
4
+ export type { AddressBook, AddressBookOptions, AddressBookState } from './book.js';
5
+ export { sameAddress } from './rules.js';
6
+ export { ADDRESS_ERROR_CODES } from './types.js';
7
+ export type { Address, AddressInput, AddressPatch, BookAddress } from './types.js';
8
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export { createAddressApi } from './api.js';
2
+ export { createAddressBook } from './book.js';
3
+ export { sameAddress } from './rules.js';
4
+ export { ADDRESS_ERROR_CODES } from './types.js';
5
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,4 @@
1
+ import type { AddressInput, BookAddress } from './types.js';
2
+ /** The same address: what it says, with case, spaces and what is not given (`undefined`, `null`, empty) not counting. Whether it is a default does not make it another one. */
3
+ export declare function sameAddress(a: AddressInput | BookAddress, b: AddressInput | BookAddress): boolean;
4
+ //# sourceMappingURL=rules.d.ts.map
@@ -0,0 +1,9 @@
1
+ // When two addresses are the same one (what `remember` asks before keeping one the customer may have already). What an address has to meet is the STORE's to say: a form that does not meet it gets the store's 400
2
+ // (`issues` lists the fields), and `AddressInput` says it at compile time. Nothing here stops a request.
3
+ const norm = (value) => (value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
4
+ /** The same address: what it says, with case, spaces and what is not given (`undefined`, `null`, empty) not counting. Whether it is a default does not make it another one. */
5
+ export function sameAddress(a, b) {
6
+ const fields = ['fullName', 'company', 'phone', 'addressLine1', 'addressLine2', 'city', 'province', 'postalCode', 'countryCode', 'additionalInfo', 'vatCode'];
7
+ return fields.every((f) => norm(a[f]) === norm(b[f]));
8
+ }
9
+ //# sourceMappingURL=rules.js.map