@occluze/tether 0.6.8 → 0.7.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 (259) hide show
  1. package/README.md +11 -11
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
  4. package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
  5. package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
  6. package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
  7. package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
  8. package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
  9. package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
  10. package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
  11. package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
  12. package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
  13. package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
  14. package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
  15. package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
  16. package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
  17. package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
  18. package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
  19. package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
  20. package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
  21. package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
  22. package/dist/esm/index.js +1 -1
  23. package/dist/esm/server.js +2 -2
  24. package/dist/presets/barba.esm.js +19 -23
  25. package/dist/tether.account-pages.js +320 -365
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +242 -491
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +66 -88
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +36 -33
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +28 -36
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +1270 -894
  36. package/dist/tether.markets.js +73 -90
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +0 -0
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +44 -68
  41. package/dist/tether.search.min.js +1 -1
  42. package/dist/types/server/src/shared/account-orders.d.ts +44 -0
  43. package/dist/types/server/src/shared/account-routes.d.ts +153 -0
  44. package/dist/types/server/src/shared/api-versions.d.ts +13 -0
  45. package/dist/types/server/src/shared/error-message.d.ts +2 -0
  46. package/dist/types/server/src/shared/json.d.ts +25 -0
  47. package/dist/types/server/src/shared/market-codes.d.ts +16 -0
  48. package/dist/types/src/account/addresses-view.d.ts +5 -0
  49. package/dist/types/src/account/answers.d.ts +40 -0
  50. package/dist/types/src/account/markup.d.ts +19 -0
  51. package/dist/types/src/account/operations.d.ts +41 -0
  52. package/dist/types/src/account/order-lines.d.ts +13 -0
  53. package/dist/types/src/account/order-list.d.ts +12 -0
  54. package/dist/types/src/account/order-page.d.ts +6 -0
  55. package/dist/types/src/account/page-answers.d.ts +182 -0
  56. package/dist/types/src/account/page-events.d.ts +2 -0
  57. package/dist/types/src/account/server-client.d.ts +60 -0
  58. package/dist/types/src/account/subscriptions-view.d.ts +2 -0
  59. package/dist/types/src/account-api.d.ts +18 -0
  60. package/dist/types/src/account-marks.d.ts +15 -0
  61. package/dist/types/src/account-pages.d.ts +8 -0
  62. package/dist/types/src/account.d.ts +64 -0
  63. package/dist/types/src/amounts.d.ts +12 -0
  64. package/dist/types/src/analytics-shopify.d.ts +41 -0
  65. package/dist/types/src/analytics.d.ts +2 -0
  66. package/dist/types/src/api-version.d.ts +28 -0
  67. package/dist/types/src/astro.d.ts +42 -0
  68. package/dist/types/src/cart/cart-shape.d.ts +43 -0
  69. package/dist/types/src/cart/checkout.d.ts +16 -0
  70. package/dist/types/src/cart/drawer.d.ts +18 -0
  71. package/dist/types/src/cart/mutations.d.ts +79 -0
  72. package/dist/types/src/cart/render.d.ts +17 -0
  73. package/dist/types/src/cart/store.d.ts +182 -0
  74. package/dist/types/src/cart.d.ts +106 -0
  75. package/dist/types/src/client.d.ts +100 -0
  76. package/dist/types/src/collection.d.ts +25 -0
  77. package/dist/types/src/config-sections.d.ts +40 -0
  78. package/dist/types/src/config.d.ts +185 -0
  79. package/dist/types/src/controls/cart-controls.d.ts +2 -0
  80. package/dist/types/src/controls/discount.d.ts +2 -0
  81. package/dist/types/src/controls/extras.d.ts +5 -0
  82. package/dist/types/src/controls/navigation.d.ts +12 -0
  83. package/dist/types/src/diagnose-markup.d.ts +19 -0
  84. package/dist/types/src/diagnose.d.ts +60 -0
  85. package/dist/types/src/dom.d.ts +75 -0
  86. package/dist/types/src/extras.d.ts +21 -0
  87. package/dist/types/src/fragments.d.ts +158 -0
  88. package/dist/types/src/globals.d.ts +15 -0
  89. package/dist/types/src/images.d.ts +20 -0
  90. package/dist/types/src/index.d.ts +167 -0
  91. package/dist/types/src/initial.d.ts +56 -0
  92. package/dist/types/src/lazy.d.ts +28 -0
  93. package/dist/types/src/loader.d.ts +84 -0
  94. package/dist/types/src/market-codes.d.ts +6 -0
  95. package/dist/types/src/markets-ui.d.ts +32 -0
  96. package/dist/types/src/markets.d.ts +72 -0
  97. package/dist/types/src/markup.d.ts +131 -0
  98. package/dist/types/src/mounting.d.ts +46 -0
  99. package/dist/types/src/page-market.d.ts +27 -0
  100. package/dist/types/src/presets/barba.d.ts +56 -0
  101. package/dist/types/src/product-events.d.ts +2 -0
  102. package/dist/types/src/product-render.d.ts +17 -0
  103. package/dist/types/src/product-shape.d.ts +88 -0
  104. package/dist/types/src/product-state.d.ts +110 -0
  105. package/dist/types/src/product.d.ts +66 -0
  106. package/dist/types/src/repeaters.d.ts +16 -0
  107. package/dist/types/src/search.d.ts +33 -0
  108. package/dist/types/src/selling-plans.d.ts +64 -0
  109. package/dist/types/src/server.d.ts +88 -0
  110. package/dist/types/src/shopify-values.d.ts +46 -0
  111. package/dist/types/src/status.d.ts +33 -0
  112. package/dist/types/src/storage.d.ts +12 -0
  113. package/dist/types/src/store-html.d.ts +39 -0
  114. package/dist/types/src/storefront/catalog.d.ts +38 -0
  115. package/dist/types/src/storefront/json-ld.d.ts +20 -0
  116. package/dist/types/src/variant-render.d.ts +14 -0
  117. package/package.json +24 -23
  118. package/src/account/addresses-view.ts +58 -0
  119. package/src/account/answers.ts +79 -0
  120. package/src/account/markup.ts +36 -0
  121. package/src/account/operations.ts +145 -0
  122. package/src/account/{order-lines.js → order-lines.ts} +20 -41
  123. package/src/account/order-list.ts +107 -0
  124. package/src/account/order-page.ts +137 -0
  125. package/src/account/page-answers.ts +317 -0
  126. package/src/account/{page-events.js → page-events.ts} +55 -90
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/{subscriptions-view.js → subscriptions-view.ts} +25 -68
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/{account-pages.js → account-pages.ts} +29 -37
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/{analytics-shopify.js → analytics-shopify.ts} +112 -107
  135. package/src/analytics.ts +315 -0
  136. package/src/{api-version.js → api-version.ts} +25 -31
  137. package/src/{astro.js → astro.ts} +110 -104
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/{drawer.js → drawer.ts} +18 -31
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/{render.js → render.ts} +68 -96
  143. package/src/cart/store.ts +279 -0
  144. package/src/{cart.js → cart.ts} +82 -128
  145. package/src/client.ts +391 -0
  146. package/src/{collection.js → collection.ts} +30 -38
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/{cart-controls.js → cart-controls.ts} +22 -54
  150. package/src/controls/{discount.js → discount.ts} +13 -34
  151. package/src/controls/{extras.js → extras.ts} +15 -24
  152. package/src/controls/{navigation.js → navigation.ts} +24 -30
  153. package/src/{diagnose-markup.js → diagnose-markup.ts} +37 -72
  154. package/src/{diagnose.js → diagnose.ts} +95 -96
  155. package/src/dom.ts +268 -0
  156. package/src/{extras.js → extras.ts} +43 -52
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/{images.js → images.ts} +30 -43
  160. package/src/index.ts +415 -0
  161. package/src/initial.ts +101 -0
  162. package/src/lazy-iife.ts +197 -0
  163. package/src/lazy.ts +42 -0
  164. package/src/{loader.js → loader.ts} +149 -123
  165. package/src/market-codes.ts +6 -0
  166. package/src/{markets-ui.js → markets-ui.ts} +91 -68
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/{mounting.js → mounting.ts} +88 -101
  170. package/src/{page-market.js → page-market.ts} +32 -38
  171. package/src/part-integrity.ts +7 -0
  172. package/src/parts/account-pages.ts +7 -0
  173. package/src/parts/account.ts +7 -0
  174. package/src/parts/analytics.ts +7 -0
  175. package/src/parts/diagnose.ts +7 -0
  176. package/src/parts/extras.ts +7 -0
  177. package/src/parts/markets.ts +7 -0
  178. package/src/parts/search.ts +7 -0
  179. package/src/presets/barba.ts +194 -0
  180. package/src/{product-events.js → product-events.ts} +33 -48
  181. package/src/{product-render.js → product-render.ts} +38 -77
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/{product.js → product.ts} +90 -126
  185. package/src/{repeaters.js → repeaters.ts} +40 -52
  186. package/src/{search.js → search.ts} +85 -103
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/{shopify-values.js → shopify-values.ts} +24 -22
  190. package/src/{status.js → status.ts} +34 -54
  191. package/src/storage.ts +34 -0
  192. package/src/store-html.ts +359 -0
  193. package/src/storefront/catalog.ts +196 -0
  194. package/src/storefront/{json-ld.js → json-ld.ts} +45 -68
  195. package/src/{variant-render.js → variant-render.ts} +24 -50
  196. package/dist/esm/chunks/account-C3NP6HFB.js +0 -1
  197. package/dist/esm/chunks/account-pages-WLK374FO.js +0 -1
  198. package/dist/esm/chunks/analytics-G3SAWMNL.js +0 -1
  199. package/dist/esm/chunks/chunk-27PJJZGC.js +0 -1
  200. package/dist/esm/chunks/chunk-2FSXS55U.js +0 -1
  201. package/dist/esm/chunks/chunk-FCSWQDVV.js +0 -1
  202. package/dist/esm/chunks/chunk-FOMSMIAM.js +0 -1
  203. package/dist/esm/chunks/chunk-GZJTCLLW.js +0 -1
  204. package/dist/esm/chunks/chunk-HEQILZVR.js +0 -1
  205. package/dist/esm/chunks/chunk-JOOEXYF3.js +0 -4
  206. package/dist/esm/chunks/chunk-MPTQYLSM.js +0 -1
  207. package/dist/esm/chunks/chunk-NEHZ37TU.js +0 -3
  208. package/dist/esm/chunks/chunk-V74KGEJF.js +0 -1
  209. package/dist/esm/chunks/chunk-VT4CDUJJ.js +0 -1
  210. package/dist/esm/chunks/chunk-YJLWX2HK.js +0 -1
  211. package/dist/esm/chunks/diagnose-6VVGZNTY.js +0 -14
  212. package/dist/esm/chunks/extras-GJVRDFM3.js +0 -1
  213. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +0 -1
  214. package/dist/esm/chunks/search-OU75RNQL.js +0 -1
  215. package/src/account/addresses-view.js +0 -92
  216. package/src/account/browser-session.js +0 -359
  217. package/src/account/markup.js +0 -48
  218. package/src/account/operations.js +0 -180
  219. package/src/account/order-list.js +0 -91
  220. package/src/account/order-page.js +0 -192
  221. package/src/account/server-client.js +0 -172
  222. package/src/account/shared.js +0 -111
  223. package/src/account/shopify-auth.js +0 -150
  224. package/src/account-api.js +0 -118
  225. package/src/account-links.js +0 -35
  226. package/src/account.js +0 -342
  227. package/src/amounts.js +0 -36
  228. package/src/analytics.js +0 -352
  229. package/src/cart/checkout.js +0 -97
  230. package/src/cart/mutations.js +0 -234
  231. package/src/cart/store.js +0 -326
  232. package/src/client.js +0 -267
  233. package/src/config.js +0 -349
  234. package/src/dom.js +0 -287
  235. package/src/fragments.js +0 -237
  236. package/src/index.js +0 -353
  237. package/src/initial.js +0 -87
  238. package/src/lazy-iife.js +0 -116
  239. package/src/lazy.js +0 -39
  240. package/src/market-codes.js +0 -35
  241. package/src/markets.js +0 -334
  242. package/src/markup.js +0 -64
  243. package/src/parts/account-pages.js +0 -6
  244. package/src/parts/account.js +0 -6
  245. package/src/parts/analytics.js +0 -6
  246. package/src/parts/diagnose.js +0 -6
  247. package/src/parts/extras.js +0 -6
  248. package/src/parts/markets.js +0 -6
  249. package/src/parts/search.js +0 -6
  250. package/src/presets/barba.js +0 -190
  251. package/src/product-state.js +0 -318
  252. package/src/selling-plans.js +0 -154
  253. package/src/server.js +0 -215
  254. package/src/store-html.js +0 -0
  255. package/src/storefront/catalog.js +0 -199
  256. package/types/astro.d.ts +0 -28
  257. package/types/barba.d.ts +0 -16
  258. package/types/index.d.ts +0 -577
  259. package/types/server.d.ts +0 -182
package/package.json CHANGED
@@ -1,25 +1,25 @@
1
1
  {
2
2
  "name": "@occluze/tether",
3
- "version": "0.6.8",
3
+ "version": "0.7.0",
4
4
  "description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
5
5
  "type": "module",
6
6
  "main": "dist/esm/index.js",
7
7
  "module": "dist/esm/index.js",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./types/index.d.ts",
10
+ "types": "./dist/types/src/index.d.ts",
11
11
  "default": "./dist/esm/index.js"
12
12
  },
13
13
  "./server": {
14
- "types": "./types/server.d.ts",
14
+ "types": "./dist/types/src/server.d.ts",
15
15
  "default": "./dist/esm/server.js"
16
16
  },
17
17
  "./astro": {
18
- "types": "./types/astro.d.ts",
18
+ "types": "./dist/types/src/astro.d.ts",
19
19
  "default": "./dist/esm/astro.js"
20
20
  },
21
21
  "./presets/barba": {
22
- "types": "./types/barba.d.ts",
22
+ "types": "./dist/types/src/presets/barba.d.ts",
23
23
  "default": "./dist/presets/barba.esm.js"
24
24
  },
25
25
  "./dist/*": "./dist/*"
@@ -28,48 +28,46 @@
28
28
  "dist",
29
29
  "src",
30
30
  "LICENSE",
31
- "README.md",
32
- "types/index.d.ts",
33
- "types/barba.d.ts",
34
- "types/server.d.ts",
35
- "types/astro.d.ts"
31
+ "README.md"
36
32
  ],
37
33
  "scripts": {
38
- "build": "node scripts/build.mjs",
39
- "dev": "node scripts/build.mjs --watch",
34
+ "build": "node --experimental-strip-types scripts/build.ts",
35
+ "dev": "node --experimental-strip-types scripts/build.ts --watch",
40
36
  "format": "prettier --write .",
41
37
  "format:check": "prettier --check .",
42
38
  "lint": "eslint .",
43
- "typecheck": "tsc -p tsconfig.json",
39
+ "typecheck": "tsc -b",
44
40
  "knip": "knip",
45
41
  "deps": "depcruise src server/src server/bin server/scripts scripts",
46
42
  "check": "npm run format:check && npm run lint && npm run typecheck && npm run knip && npm run deps && npm test",
47
43
  "prepare": "lefthook install",
48
- "test": "node --test test/*.test.js server/test/*.test.js && npm run test:types",
44
+ "test": "node --experimental-strip-types --test test/*.test.js server/test/*.test.js && npm run test:types",
49
45
  "test:types": "tsc -p types/tsconfig.json",
50
- "smoke": "node scripts/smoke.mjs",
51
- "pretest": "node scripts/build.mjs --out .test-dist",
52
- "presmoke": "node scripts/build.mjs",
53
- "release": "node scripts/release.mjs",
46
+ "smoke": "node --experimental-strip-types scripts/smoke.ts",
47
+ "pretest": "node --experimental-strip-types scripts/build.ts --out .test-dist",
48
+ "presmoke": "node --experimental-strip-types scripts/build.ts",
49
+ "release": "node --experimental-strip-types scripts/release.ts",
54
50
  "e2e:build": "npm run build && cd examples/astro && (test -f .env || cp .env.example .env) && npm install --no-audit --no-fund && npm install --no-save ../.. && npx astro build",
55
51
  "e2e": "npm run e2e:build && playwright test -c e2e/playwright.config.js"
56
52
  },
57
53
  "devDependencies": {
58
54
  "@eslint/js": "10.0.1",
59
- "@playwright/test": "^1.64.1",
55
+ "@playwright/test": "1.64.1",
60
56
  "@tsconfig/strictest": "2.0.8",
57
+ "@types/jsdom": "21.1.7",
61
58
  "@types/node": "24.19.2",
62
59
  "dependency-cruiser": "18.7.2",
63
- "esbuild": "^0.25.0",
60
+ "esbuild": "0.25.12",
64
61
  "eslint": "10.14.0",
65
62
  "eslint-config-prettier": "10.1.8",
66
63
  "globals": "17.14.0",
67
- "jsdom": "^26.0.0",
64
+ "jsdom": "26.1.0",
68
65
  "knip": "6.44.1",
69
66
  "lefthook": "2.2.2",
70
67
  "prettier": "3.9.10",
71
68
  "typescript": "npm:@typescript/typescript6@6.0.2",
72
- "typescript-eslint": "8.72.1"
69
+ "typescript-eslint": "8.72.1",
70
+ "yaml": "2.9.1"
73
71
  },
74
72
  "license": "MIT",
75
73
  "author": {
@@ -110,7 +108,7 @@
110
108
  "publishConfig": {
111
109
  "access": "public"
112
110
  },
113
- "types": "types/index.d.ts",
111
+ "types": "dist/types/src/index.d.ts",
114
112
  "peerDependencies": {
115
113
  "astro": ">=4"
116
114
  },
@@ -118,5 +116,8 @@
118
116
  "astro": {
119
117
  "optional": true
120
118
  }
119
+ },
120
+ "engines": {
121
+ "node": ">=22.18"
121
122
  }
122
123
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The customer's addresses (`tether-addresses`), with `tether-show="addresses-loading"`, `addresses-empty` and
3
+ * `addresses-error`. The last list rendered is kept, so an edit button can fill the address form from it.
4
+ */
5
+ import { reportError } from '../config.ts';
6
+ import { get, qsa, repeat, sel, setText } from '../dom.ts';
7
+ import { addressText, bindsIn, ownValue, showIn } from './markup.ts';
8
+ import { addresses } from './operations.ts';
9
+ import type { CustomerAddress } from './page-answers.ts';
10
+
11
+ let shown: readonly CustomerAddress[] = [];
12
+
13
+ /** @returns The address with this id in the list shown last. */
14
+ export const shownAddress = (id: string): CustomerAddress | undefined => shown.find((address) => address.id === id);
15
+
16
+ /** @returns The text of each `tether-address-bind` value. */
17
+ const addressTexts = (address: CustomerAddress): Record<string, string> => ({
18
+ formatted: addressText(address),
19
+ name: [address.firstName, address.lastName].filter(Boolean).join(' '),
20
+ company: address.company || '',
21
+ address1: address.address1 || '',
22
+ address2: address.address2 || '',
23
+ city: address.city || '',
24
+ zip: address.zip || '',
25
+ province: address.province || '',
26
+ country: address.country || '',
27
+ phone: address.phoneNumber || '',
28
+ });
29
+
30
+ function fillAddress(row: HTMLElement, address: CustomerAddress): void {
31
+ row.dataset['tetherAddress'] = address.id;
32
+ const texts = addressTexts(address);
33
+ bindsIn(row, 'address-bind').forEach((el) => {
34
+ const text = ownValue(texts, get(el, 'address-bind') ?? '');
35
+ if (text !== undefined) setText(el, text);
36
+ });
37
+ showIn(row, 'address-show', { default: address.isDefault, 'not-default': !address.isDefault });
38
+ }
39
+
40
+ /** Loads and renders the addresses. A failure is reported and shows `addresses-error`, never "no addresses yet". */
41
+ export async function renderAddresses(root: ParentNode): Promise<void> {
42
+ const boxes = qsa(root, sel('addresses'));
43
+ if (!boxes.length) return;
44
+ showIn(root, 'show', { 'addresses-loading': true, 'addresses-empty': false });
45
+ let list: CustomerAddress[];
46
+ try {
47
+ list = await addresses();
48
+ } catch (error) {
49
+ reportError(error, 'addresses');
50
+ showIn(root, 'show', { 'addresses-loading': false, 'addresses-error': true });
51
+ return;
52
+ }
53
+ shown = list;
54
+ showIn(root, 'show', { 'addresses-loading': false, 'addresses-empty': list.length === 0, 'addresses-error': false });
55
+ boxes.forEach((box) => {
56
+ repeat(box, list, fillAddress);
57
+ });
58
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The Worker's account answers as typed values. The Worker passes on what Shopify answers to its fixed documents, and
3
+ * each answer is read once, field by field. Every field a document selects is kept. One that is missing or of another
4
+ * type reads as `null` (a list as empty, a count as 0): a page with one blank field is better than no page. The ids
5
+ * the pages act on are required, and an answer without one is an error. This module reads what the account part
6
+ * shows (the customer, their orders through the reader the Worker shares, Shopify's `userErrors`); page-answers.ts
7
+ * reads the account pages' answers.
8
+ */
9
+ import { TetherError } from '../client.ts';
10
+ import type { UserError } from '../client.ts';
11
+ import { field, isRecord, listOf, textList, textOrNull } from '../../server/src/shared/json.ts';
12
+ import { readOrders } from '../../server/src/shared/account-orders.ts';
13
+ import type { AccountOrder } from '../../server/src/shared/account-orders.ts';
14
+
15
+ /** The logged-in customer. */
16
+ export interface AccountCustomer {
17
+ /** The customer's gid; left out when the Worker's `/me` doesn't send it. */
18
+ id?: string;
19
+ firstName: string | null;
20
+ lastName: string | null;
21
+ displayName: string | null;
22
+ emailAddress: { emailAddress: string } | null;
23
+ defaultAddress: { formatted: string[] } | null;
24
+ }
25
+
26
+ export const unexpected = (what: string): TetherError =>
27
+ new TetherError(`Customer accounts: the answer has no ${what}`);
28
+
29
+ /**
30
+ * @param what Named in the error.
31
+ * @throws {TetherError} Without an id.
32
+ */
33
+ export function requiredId(value: unknown, what: string): string {
34
+ const id = textOrNull(value);
35
+ if (!id) throw unexpected(what);
36
+ return id;
37
+ }
38
+
39
+ /** @param value The `customer` of the Worker's `/me`, which flattens the email and the default address. */
40
+ export function toAccountCustomer(value: unknown): AccountCustomer {
41
+ const customer = isRecord(value) ? value : {};
42
+ const id = textOrNull(customer['id']);
43
+ const email = textOrNull(customer['email']);
44
+ const address = customer['address'];
45
+ return {
46
+ ...(id === null ? {} : { id }),
47
+ firstName: textOrNull(customer['firstName']),
48
+ lastName: textOrNull(customer['lastName']),
49
+ displayName: textOrNull(customer['displayName']),
50
+ emailAddress: email === null ? null : { emailAddress: email },
51
+ defaultAddress: Array.isArray(address) ? { formatted: textList(address) } : null,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * @param value The `orders` of the Worker's `/orders`.
57
+ * @throws {TetherError} For an order without an id.
58
+ */
59
+ export function toAccountOrders(value: unknown): AccountOrder[] {
60
+ const reading = readOrders(value);
61
+ if (reading.kind === 'missing-id') throw unexpected('order id');
62
+ return reading.orders;
63
+ }
64
+
65
+ /** @returns `null` for an entry without a message, which has nothing to show. */
66
+ function toUserError(value: unknown): UserError | null {
67
+ const message = textOrNull(field(value, 'message'));
68
+ if (message === null) return null;
69
+ const fieldPath = field(value, 'field');
70
+ return {
71
+ message,
72
+ field: Array.isArray(fieldPath) ? textList(fieldPath) : null,
73
+ code: textOrNull(field(value, 'code')),
74
+ };
75
+ }
76
+
77
+ /** @param value Shopify's `userErrors`, as Shopify or the Worker sends them. */
78
+ export const toUserErrors = (value: unknown): UserError[] =>
79
+ listOf(value, toUserError).filter((error) => error !== null);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The account markup's shared vocabulary: `tether-*-show` conditions, binds on a template row (the row itself may
3
+ * carry one), and how Shopify's enum values and formatted addresses read on the page.
4
+ */
5
+ import { get, qsa, sel, show } from '../dom.ts';
6
+
7
+ /**
8
+ * Shows or hides each `name` element whose value is one of `conditions`' keys; other values are left alone.
9
+ *
10
+ * @param name `show`, `order-show`, `address-show`…
11
+ */
12
+ export function showIn(root: ParentNode, name: string, conditions: Readonly<Record<string, boolean>>): void {
13
+ qsa(root, sel(name)).forEach((el) => {
14
+ const key = get(el, name) ?? '';
15
+ if (Object.hasOwn(conditions, key)) show(el, conditions[key] === true);
16
+ });
17
+ }
18
+
19
+ /**
20
+ * @param row A copy of a repeater's template.
21
+ * @returns The `name` elements in `row`, and `row` itself when it carries `name`.
22
+ */
23
+ export const bindsIn = (row: HTMLElement, name: string): HTMLElement[] =>
24
+ qsa(row, sel(name)).concat(row.matches(sel(name)) ? [row] : []);
25
+
26
+ /** @returns A Shopify enum as text: `PARTIALLY_FULFILLED` → `partially fulfilled`. */
27
+ export const enumText = (value: string | null | undefined): string =>
28
+ value ? value.toLowerCase().replace(/_/g, ' ') : '';
29
+
30
+ /** @returns Shopify's formatted address on one line. */
31
+ export const addressText = (address: { formatted?: string[] | null } | null | undefined): string =>
32
+ (address?.formatted ?? []).join(', ');
33
+
34
+ /** A bind's value, looked up by the attribute's text: only the table's own keys, never `constructor` and the like. */
35
+ export const ownValue = <T>(values: Readonly<Record<string, T>>, key: string): T | undefined =>
36
+ Object.hasOwn(values, key) ? values[key] : undefined;
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The account pages' operations: the Worker's fixed routes (shared/account-routes.ts), which check the input and run
3
+ * the documents with the customer's token. Each result is read into its typed shape (page-answers.ts), and a failure
4
+ * is a `TetherError`: `NOT_LOGGED_IN`, `INVALID` for input the Worker or Shopify refuses, `NETWORK`, and
5
+ * `HTTP_<status>` for a request that failed.
6
+ */
7
+ import { TetherError } from '../client.ts';
8
+ import { emit } from '../dom.ts';
9
+ import { operationRequest } from '../../server/src/shared/account-routes.ts';
10
+ import type {
11
+ AddressFields,
12
+ OperationInputs,
13
+ OperationName,
14
+ ReturnLine,
15
+ SubscriptionAction,
16
+ } from '../../server/src/shared/account-routes.ts';
17
+
18
+ export type { AddressFields, ReturnLine };
19
+ import {
20
+ toAccountAddress,
21
+ toCustomerAddresses,
22
+ toDeletedId,
23
+ toOrderDetail,
24
+ toProfile,
25
+ toReturn,
26
+ toSubscriptionChange,
27
+ toSubscriptionContracts,
28
+ } from './page-answers.ts';
29
+ import type {
30
+ AccountAddress,
31
+ CustomerAddress,
32
+ OpenedReturn,
33
+ OrderDetail,
34
+ Profile,
35
+ SubscriptionChange,
36
+ SubscriptionContract,
37
+ } from './page-answers.ts';
38
+ import type { ServerAnswer, ServerRequest } from './server-client.ts';
39
+ import type { AccountCustomer } from './answers.ts';
40
+
41
+ /** The account part's session, shared so both parts act as one customer. */
42
+ interface PagesContext {
43
+ serverFetch: (path: string, request?: ServerRequest) => Promise<ServerAnswer>;
44
+ customer: () => Promise<AccountCustomer | null>;
45
+ bindCustomer: (root: ParentNode) => Promise<void>;
46
+ /** Forgets the cached customer after a change of their data. */
47
+ reset: () => void;
48
+ }
49
+
50
+ /** `tether:account-updated`'s `what`. */
51
+ type ChangedData = 'profile' | 'addresses' | 'subscriptions' | 'orders';
52
+
53
+ let context: PagesContext | null = null;
54
+
55
+ /** Called by the account part once, when it loads this part. */
56
+ export function setup(shared: PagesContext): void {
57
+ context = shared;
58
+ }
59
+
60
+ /** @throws {Error} Before `setup`: this part is only ever loaded by the account part, which calls it first. */
61
+ export function pagesContext(): PagesContext {
62
+ if (!context) throw new Error('account-pages: loaded without the account part');
63
+ return context;
64
+ }
65
+
66
+ /**
67
+ * @returns The operation's result fields, read by the caller.
68
+ * @throws {TetherError} With one of the codes in the file's comment.
69
+ */
70
+ async function run<Name extends OperationName>(
71
+ name: Name,
72
+ input: OperationInputs[Name],
73
+ ): Promise<Record<string, unknown>> {
74
+ const { serverFetch } = pagesContext();
75
+ const { method, path } = operationRequest(name, input);
76
+ const answer = await serverFetch(path, method === 'POST' ? { method, body: input } : {});
77
+ if (answer.kind === 'logged-out') throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN', status: 401 });
78
+ return answer.data;
79
+ }
80
+
81
+ function changed(what: ChangedData): void {
82
+ pagesContext().reset();
83
+ emit('account-updated', { what });
84
+ }
85
+
86
+ /**
87
+ * One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns).
88
+ *
89
+ * @param id A gid or the numeric order id.
90
+ * @returns `null` when the customer has no such order.
91
+ */
92
+ export async function order(id: string | number): Promise<OrderDetail | null> {
93
+ const result = await run('order', { id });
94
+ return toOrderDetail(result['order']);
95
+ }
96
+
97
+ export async function updateProfile({
98
+ firstName,
99
+ lastName,
100
+ }: { firstName?: string; lastName?: string } = {}): Promise<Profile> {
101
+ const result = await run('profile', { firstName, lastName });
102
+ changed('profile');
103
+ return toProfile(result['customer']);
104
+ }
105
+
106
+ /** @returns The customer's addresses, the default one marked `isDefault`. */
107
+ export async function addresses(): Promise<CustomerAddress[]> {
108
+ const result = await run('addresses', {});
109
+ return toCustomerAddresses(result['addresses']);
110
+ }
111
+
112
+ /** Creates an address, or updates the one with `id`; without fields, only makes it the default. */
113
+ export async function saveAddress(
114
+ address: AddressFields | null,
115
+ { id, isDefault }: { id?: string | undefined; isDefault?: boolean } = {},
116
+ ): Promise<AccountAddress> {
117
+ const result = await run('saveAddress', { id, address, isDefault });
118
+ changed('addresses');
119
+ return toAccountAddress(result['address']);
120
+ }
121
+
122
+ /** @returns The deleted address's id. */
123
+ export async function deleteAddress(id: string): Promise<string> {
124
+ const result = await run('deleteAddress', { id });
125
+ changed('addresses');
126
+ return toDeletedId(result['deleted']);
127
+ }
128
+
129
+ export async function subscriptions(): Promise<SubscriptionContract[]> {
130
+ const result = await run('subscriptions', {});
131
+ return toSubscriptionContracts(result['subscriptions']);
132
+ }
133
+
134
+ /** @returns The changed contract or billing cycle. */
135
+ export async function subscriptionAction(id: string | number, action: SubscriptionAction): Promise<SubscriptionChange> {
136
+ const result = await run('subscription', { id, action });
137
+ changed('subscriptions');
138
+ return toSubscriptionChange(result);
139
+ }
140
+
141
+ export async function requestReturn(orderId: string | number, lines: ReturnLine[]): Promise<OpenedReturn> {
142
+ const result = await run('requestReturn', { orderId, lines });
143
+ changed('orders');
144
+ return toReturn(result['return']);
145
+ }
@@ -3,32 +3,23 @@
3
3
  * lines also carry price, total, sku and what can be returned; the order list's document has none of those, so they
4
4
  * render empty there, and its rows get no return controls.
5
5
  */
6
- import { formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.js';
7
- import { bindsIn, ownValue, showIn } from './markup.js';
8
- /** @import { Money } from '../shopify-values.js' */
6
+ import { formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.ts';
7
+ import { bindsIn, ownValue, showIn } from './markup.ts';
8
+ import type { AccountOrderLine } from '../../server/src/shared/account-orders.ts';
9
+ import type { OrderDetailLine } from './page-answers.ts';
9
10
 
10
- /**
11
- * @typedef {object} OrderLine A line of `ORDERS_QUERY` or of the order page's document (account-ops.js).
12
- * @property {string} [id] Only on the order page.
13
- * @property {string} [name]
14
- * @property {string} title
15
- * @property {string | null} [variantTitle]
16
- * @property {number} quantity
17
- * @property {string | null} [sku]
18
- * @property {{ url: string, altText: string | null } | null} [image]
19
- * @property {Money | null} [price]
20
- * @property {Money | null} [totalPrice]
21
- * @property {{ nodes: { id: string, name: string }[] }} [suggestedReturnReasonDefinitions]
22
- */
23
- /** @typedef {ReadonlyMap<string, number>} Returnable Line id → how many of it can still be returned. */
11
+ /** A line of the order list, or of the order page, which has the fields after `image` too. */
12
+ type OrderLine = AccountOrderLine &
13
+ Partial<Pick<OrderDetailLine, 'id' | 'name' | 'sku' | 'price' | 'totalPrice' | 'suggestedReturnReasonDefinitions'>>;
14
+ /** Line id → how many of it can still be returned. */
15
+ type Returnable = ReadonlyMap<string, number>;
24
16
 
25
17
  /**
26
- * @param {OrderLine} line
27
- * @param {number | null} returnable `null` in the order list, which knows nothing of returns.
28
- * @returns {Record<string, string | undefined>} The text of each `tether-order-line` value.
18
+ * @param returnable `null` in the order list, which knows nothing of returns.
19
+ * @returns The text of each `tether-order-line` value.
29
20
  */
30
- const lineTexts = (line, returnable) => ({
31
- title: line.title || line.name,
21
+ const lineTexts = (line: OrderLine, returnable: number | null): Record<string, string | undefined> => ({
22
+ title: line.title || line.name || '',
32
23
  'variant-title': line.variantTitle || '',
33
24
  quantity: String(line.quantity),
34
25
  price: formatMoney(line.price),
@@ -37,20 +28,10 @@ const lineTexts = (line, returnable) => ({
37
28
  returnable: returnable === null ? undefined : String(returnable),
38
29
  });
39
30
 
40
- /**
41
- * @param {HTMLElement} el
42
- * @returns {el is HTMLInputElement}
43
- */
44
- const isInput = (el) => el.tagName === 'INPUT';
31
+ const isInput = (el: HTMLElement): el is HTMLInputElement => el.tagName === 'INPUT';
45
32
 
46
- /**
47
- * The return controls of one line: how many can be returned, and Shopify's suggested reasons.
48
- *
49
- * @param {HTMLElement} row
50
- * @param {OrderLine & { id: string }} line
51
- * @param {number} returnable
52
- */
53
- function fillReturnControls(row, line, returnable) {
33
+ /** The return controls of one line: how many can be returned, and Shopify's suggested reasons. */
34
+ function fillReturnControls(row: HTMLElement, line: OrderLine & { id: string }, returnable: number): void {
54
35
  showIn(row, 'order-line-show', { returnable: returnable > 0 });
55
36
  qsa(row, sel('return-quantity')).forEach((input) => {
56
37
  if (!isInput(input)) return;
@@ -64,7 +45,7 @@ function fillReturnControls(row, line, returnable) {
64
45
  if (select.dataset['tetherFilled'] === line.id) return;
65
46
  select.dataset['tetherFilled'] = line.id;
66
47
  (line.suggestedReturnReasonDefinitions?.nodes ?? []).forEach((reason) => {
67
- select.appendChild(new Option(reason.name, reason.id));
48
+ select.appendChild(new Option(reason.name ?? '', reason.id));
68
49
  });
69
50
  });
70
51
  }
@@ -72,11 +53,9 @@ function fillReturnControls(row, line, returnable) {
72
53
  /**
73
54
  * Renders `lines` into a `tether-order-lines` repeater.
74
55
  *
75
- * @param {HTMLElement} box
76
- * @param {readonly OrderLine[]} lines
77
- * @param {Returnable | null} returns `null` for the order list.
56
+ * @param returns `null` for the order list.
78
57
  */
79
- export function fillLines(box, lines, returns) {
58
+ export function fillLines(box: HTMLElement, lines: readonly OrderLine[], returns: Returnable | null): void {
80
59
  repeat(box, lines, (row, line) => {
81
60
  const id = line.id;
82
61
  const returnable = returns && id ? (returns.get(id) ?? 0) : null;
@@ -85,7 +64,7 @@ export function fillLines(box, lines, returns) {
85
64
  bindsIn(row, 'order-line').forEach((el) => {
86
65
  const key = get(el, 'order-line') ?? '';
87
66
  if (key === 'image') {
88
- if (line.image) setImage(el, line.image.url, line.image.altText || line.title);
67
+ if (line.image) setImage(el, line.image.url, line.image.altText || line.title || '');
89
68
  return;
90
69
  }
91
70
  const text = ownValue(texts, key);
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The customer's recent orders (`tether-orders`), with `tether-show="orders-loading"` while they load and
3
+ * `orders-empty` when there are none yet.
4
+ */
5
+ import { cfg, reportError } from '../config.ts';
6
+ import { isHttpsUrl } from '../shopify-values.ts';
7
+ import { formatDate, formatMoney, get, qsa, repeat, sel, setImage, setText } from '../dom.ts';
8
+ import { ownValue, showIn } from './markup.ts';
9
+ import { fillLines } from './order-lines.ts';
10
+ import type { AccountOrder } from '../../server/src/shared/account-orders.ts';
11
+
12
+ /** How many orders `orders()` answers, and a `tether-orders` list shows, without being told. */
13
+ export const DEFAULT_ORDER_COUNT = 10;
14
+ /** The most orders one request asks for: the Worker answers no more. */
15
+ export const MAX_ORDER_COUNT = 50;
16
+
17
+ /** @returns True for a number of orders one request can ask for: a whole number from 1 to `MAX_ORDER_COUNT`. */
18
+ export const isOrderCount = (value: unknown): value is number =>
19
+ typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_ORDER_COUNT;
20
+
21
+ /**
22
+ * @param limit The list's `tether-limit`.
23
+ * @returns How many orders the list shows: the limit, kept within what one request can ask for.
24
+ */
25
+ function listLimit(limit: string | null): number {
26
+ const asked = Number.parseInt(limit || String(DEFAULT_ORDER_COUNT), 10) || DEFAULT_ORDER_COUNT;
27
+ return Math.min(Math.max(asked, 1), MAX_ORDER_COUNT);
28
+ }
29
+
30
+ /**
31
+ * @param gid `gid://shopify/Order/123`, possibly with a query.
32
+ * @returns The numeric id at its end.
33
+ */
34
+ const numericId = (gid: string): string => gid.replace(/\?.*$/, '').replace(/^.*\//, '');
35
+
36
+ /**
37
+ * The order's page: the site's own (`account.orderUrl`, e.g. `/account/order?id={id}`, every `{id}` the numeric
38
+ * order id), else Shopify's status page when it is https.
39
+ */
40
+ function orderUrl(order: AccountOrder): string | null {
41
+ const template = cfg.account?.orderUrl;
42
+ if (template) return template.replaceAll('{id}', encodeURIComponent(numericId(order.id)));
43
+ const statusPage = order.statusPageUrl;
44
+ return statusPage !== null && isHttpsUrl(statusPage) ? statusPage : null;
45
+ }
46
+
47
+ /** @returns The text of each `tether-order-bind` value. */
48
+ const orderTexts = (order: AccountOrder): Record<string, string | undefined> => ({
49
+ name: order.name ?? '',
50
+ date: formatDate(order.processedAt),
51
+ total: formatMoney(order.totalPrice),
52
+ status: order.financialStatus?.toLowerCase(),
53
+ fulfillment: order.fulfillmentStatus?.toLowerCase(),
54
+ items: order.lineItems.nodes.map((line) => `${String(line.quantity)}× ${line.title ?? ''}`).join(', '),
55
+ });
56
+
57
+ /** @param node A copy of the list's template. */
58
+ function fillOrder(node: HTMLElement, order: AccountOrder): void {
59
+ const lines = order.lineItems.nodes;
60
+ const url = orderUrl(order);
61
+ const texts = orderTexts(order);
62
+ qsa(node, sel('order-bind')).forEach((el) => {
63
+ const key = get(el, 'order-bind') ?? '';
64
+ if (key === 'url') {
65
+ if (url) el.setAttribute('href', url);
66
+ return;
67
+ }
68
+ if (key === 'image') {
69
+ const image = lines.find((line) => line.image)?.image;
70
+ if (image) setImage(el, image.url, image.altText || lines[0]?.title || '');
71
+ return;
72
+ }
73
+ const text = ownValue(texts, key);
74
+ if (text !== undefined) setText(el, text);
75
+ });
76
+ qsa(node, sel('order-lines')).forEach((box) => {
77
+ fillLines(box, lines, null);
78
+ });
79
+ if (get(node, 'order-bind') === 'url' && url) node.setAttribute('href', url);
80
+ }
81
+
82
+ /**
83
+ * Fills every `tether-orders` list in `root`. A failure is reported and leaves the lists empty, without claiming the
84
+ * customer has no orders.
85
+ */
86
+ export async function renderOrderList(
87
+ root: ParentNode,
88
+ loadOrders: (first: number) => Promise<AccountOrder[]>,
89
+ ): Promise<void> {
90
+ const lists = qsa(root, sel('orders'));
91
+ const first = lists[0];
92
+ if (!first) return;
93
+ showIn(root, 'show', { 'orders-loading': true, 'orders-empty': false });
94
+ const limit = listLimit(get(first, 'limit'));
95
+ /** `null` after a failure. */
96
+ let orders: AccountOrder[] | null;
97
+ try {
98
+ orders = await loadOrders(limit);
99
+ } catch (error) {
100
+ reportError(error, 'orders');
101
+ orders = null;
102
+ }
103
+ showIn(root, 'show', { 'orders-loading': false, 'orders-empty': orders?.length === 0 });
104
+ lists.forEach((list) => {
105
+ repeat(list, orders ?? [], fillOrder);
106
+ });
107
+ }