@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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The core's side of customer accounts, which are an optional part: whether a page needs the part, and
3
+ * `Tether.account`, whose functions load it on first call. Every function returns a promise, the part's own sync ones
4
+ * (`isLoggedIn`) too, because the part may still have to load.
5
+ */
6
+ import { cfg, reportError } from './config.ts';
7
+ import { attr } from './dom.ts';
8
+ import { loadPart } from './lazy.ts';
9
+ import { within } from './mounting.ts';
10
+ import { hintKey, isLoginOutcome } from './account-marks.ts';
11
+ import { getStored } from './storage.ts';
12
+ import type { Parts } from './lazy.ts';
13
+
14
+ type AccountPart = Parts['account'];
15
+ type AccountResult<Name extends keyof AccountPart> = Awaited<ReturnType<AccountPart[Name]>>;
16
+ type AccountFunction<Name extends keyof AccountPart> = (
17
+ ...args: Parameters<AccountPart[Name]>
18
+ ) => Promise<AccountResult<Name>>;
19
+ type AccountApi = { [Name in keyof AccountPart]: AccountFunction<Name> };
20
+
21
+ /** The attributes of the account markup: a page with one of them loads the part. */
22
+ const ACCOUNT_ATTRIBUTES = [
23
+ 'account-login',
24
+ 'account-bind',
25
+ 'account-link',
26
+ 'orders',
27
+ 'order',
28
+ 'addresses',
29
+ 'subscriptions',
30
+ 'profile-form',
31
+ 'address-form',
32
+ ];
33
+
34
+ /** @returns A selector for every element the account part fills in or shows. */
35
+ const accountSelector = (): string =>
36
+ [...ACCOUNT_ATTRIBUTES.map((name) => `[${attr(name)}]`), `[${attr('show')}^="logged-"]`].join(',');
37
+
38
+ /**
39
+ * Whether this browser keeps the "may be logged in" hint, so checkout on a page without account markup still knows
40
+ * the customer.
41
+ *
42
+ * @returns False where the browser blocks storage, which then holds no hint.
43
+ */
44
+ function hasLoginHint(): boolean {
45
+ return Boolean(getStored(hintKey()));
46
+ }
47
+
48
+ /**
49
+ * Loads the account part and mounts it on `root`, when accounts are configured and the page needs them: account
50
+ * markup in `root`, a login coming back from the Worker (`?account=`), or the "may be logged in" hint.
51
+ */
52
+ export function mountAccountIfUsed(root: ParentNode): void {
53
+ if (!cfg.account) return;
54
+ const needed =
55
+ within(root, accountSelector()).length > 0 ||
56
+ isLoginOutcome(new URLSearchParams(window.location.search).get('account')) ||
57
+ hasLoginHint();
58
+ if (!needed) return;
59
+ loadPart('account')
60
+ .then((part) => part.mountAccount(root))
61
+ .catch((error: unknown) => {
62
+ reportError(error, 'account');
63
+ });
64
+ }
65
+
66
+ /** @returns `name` of the account part, called once the part has loaded. */
67
+ function fromPart<Name extends keyof AccountPart>(name: Name): AccountFunction<Name> {
68
+ return (...args) =>
69
+ loadPart('account').then((part) => {
70
+ // `Name` picks one function of the part, and `args` are that function's parameters.
71
+ const call = part[name] as (
72
+ ...callArgs: Parameters<AccountPart[Name]>
73
+ ) => AccountResult<Name> | PromiseLike<AccountResult<Name>>;
74
+ return call(...args);
75
+ });
76
+ }
77
+
78
+ /**
79
+ * `Tether.account`: the account part's functions, each loading the part on first call. A plain object, so `await
80
+ * Tether.account` and `Object.keys(Tether.account)` behave as for any object.
81
+ */
82
+ export const accountApi: AccountApi = {
83
+ login: fromPart('login'),
84
+ logout: fromPart('logout'),
85
+ isLoggedIn: fromPart('isLoggedIn'),
86
+ customer: fromPart('customer'),
87
+ orders: fromPart('orders'),
88
+ order: fromPart('order'),
89
+ updateProfile: fromPart('updateProfile'),
90
+ addresses: fromPart('addresses'),
91
+ saveAddress: fromPart('saveAddress'),
92
+ deleteAddress: fromPart('deleteAddress'),
93
+ subscriptions: fromPart('subscriptions'),
94
+ subscriptionAction: fromPart('subscriptionAction'),
95
+ requestReturn: fromPart('requestReturn'),
96
+ render: fromPart('render'),
97
+ mountAccount: fromPart('mountAccount'),
98
+ };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What tells a page that customer accounts are in use in this browser: the "may be logged in" hint the account part
3
+ * keeps in `localStorage`, and the Worker's `?account=` mark after a login. The core reads them to decide whether a
4
+ * page loads the part, and the part writes the hint, so both take them from here.
5
+ */
6
+ import { cfg } from './config.ts';
7
+ import { removeStored } from './storage.ts';
8
+
9
+ /**
10
+ * What the Worker's login redirect says in `?account=`: `in` after a login, `expired` when the login took too long,
11
+ * `error` when it failed.
12
+ */
13
+ type LoginOutcome = 'in' | 'expired' | 'error';
14
+
15
+ /** The "may be logged in" hint's key: it makes the core load the part on the next page, and holds no secret. */
16
+ export const hintKey = (): string => `${cfg.prefix}:account-hint:${cfg.domain}`;
17
+
18
+ /** Removes a key from both storage areas, each on its own: a blocked one doesn't keep the key in the other. */
19
+ function removeFromBothAreas(key: string): void {
20
+ removeStored(key, 'sessionStorage');
21
+ removeStored(key, 'localStorage');
22
+ }
23
+
24
+ /**
25
+ * Removes the customer tokens and pending login that Tether 0.6's browser-mode accounts kept in this browser. The
26
+ * session held a refresh token with the customer's full scope, so it must not stay behind once accounts run through
27
+ * the Worker. TODO: remove in 0.8.0, when no visitor's browser still holds a 0.6 session.
28
+ */
29
+ export function forgetBrowserModeTokens(): void {
30
+ removeFromBothAreas(`${cfg.prefix}:account:${cfg.domain}`);
31
+ removeFromBothAreas(`${cfg.prefix}:account-login:${cfg.domain}`);
32
+ }
33
+
34
+ export const isLoginOutcome = (value: string | null): value is LoginOutcome =>
35
+ value === 'in' || value === 'expired' || value === 'error';
@@ -1,16 +1,17 @@
1
1
  /**
2
2
  * Customer account pages, an optional part the account part loads on pages that use them: the order page with
3
- * returns, the profile form, addresses and subscriptions. The operations and their input checks are shared with the
4
- * Worker (server/src/shared/account-ops.js), so both modes do the same.
3
+ * returns, the profile form, addresses and subscriptions. Each operation is one of the Worker's fixed routes, which
4
+ * checks the input (account/operations.ts).
5
5
  */
6
- import { reportError } from './config.js';
7
- import { qsa, sel } from './dom.js';
8
- import { pagesContext } from './account/operations.js';
9
- import { renderOrder, showOrderState } from './account/order-page.js';
10
- import { renderAddresses } from './account/addresses-view.js';
11
- import { renderSubscriptions } from './account/subscriptions-view.js';
12
- import { listenToPages } from './account/page-events.js';
13
- import { showIn } from './account/markup.js';
6
+ import { reportError } from './config.ts';
7
+ import { qsa, sel } from './dom.ts';
8
+ import { pagesContext } from './account/operations.ts';
9
+ import { renderOrder, showOrderState } from './account/order-page.ts';
10
+ import { renderAddresses } from './account/addresses-view.ts';
11
+ import { renderSubscriptions } from './account/subscriptions-view.ts';
12
+ import { listenToPages } from './account/page-events.ts';
13
+ import { showIn } from './account/markup.ts';
14
+ import type { AccountCustomer } from './account/answers.ts';
14
15
 
15
16
  export {
16
17
  setup,
@@ -22,26 +23,24 @@ export {
22
23
  subscriptions,
23
24
  subscriptionAction,
24
25
  requestReturn,
25
- } from './account/operations.js';
26
+ } from './account/operations.ts';
26
27
 
27
28
  /** The profile fields a `tether-profile-form` is filled with. */
28
- const PROFILE_FIELDS = /** @type {const} */ (['firstName', 'lastName']);
29
+ const PROFILE_FIELDS = ['firstName', 'lastName'] as const;
29
30
 
30
- /**
31
- * Fills the profile form with the customer's name, leaving a field the visitor has typed in alone.
32
- *
33
- * @param {HTMLElement} form
34
- */
35
- async function renderProfile(form) {
36
- const customer = await pagesContext()
37
- .customer()
38
- .catch((/** @type {unknown} */ error) => {
39
- // The form stays empty and still saves: only the prefill is missing.
40
- reportError(error, 'account');
41
- return null;
42
- });
43
- if (!customer || form.tagName !== 'FORM') return;
44
- const fields = /** @type {HTMLFormElement} */ (form).elements;
31
+ /** Fills the profile form with the customer's name, leaving a field the visitor has typed in alone. */
32
+ async function renderProfile(form: HTMLElement): Promise<void> {
33
+ if (!(form instanceof window.HTMLFormElement)) return;
34
+ let customer: AccountCustomer | null;
35
+ try {
36
+ customer = await pagesContext().customer();
37
+ } catch (error) {
38
+ // The form stays empty and still saves: only the prefill is missing.
39
+ reportError(error, 'account');
40
+ return;
41
+ }
42
+ if (!customer) return;
43
+ const fields = form.elements;
45
44
  PROFILE_FIELDS.forEach((name) => {
46
45
  const input = fields.namedItem(name);
47
46
  const isTextField = input instanceof window.HTMLInputElement || input instanceof window.HTMLTextAreaElement;
@@ -51,13 +50,8 @@ async function renderProfile(form) {
51
50
 
52
51
  let listening = false;
53
52
 
54
- /**
55
- * Renders the order page, profile form, addresses and subscriptions in `root`: only what it has.
56
- *
57
- * @param {ParentNode} [root]
58
- * @returns {Promise<void>}
59
- */
60
- export async function renderPages(root = document) {
53
+ /** Renders the order page, profile form, addresses and subscriptions in `root`: only what it has. */
54
+ export async function renderPages(root: ParentNode = document): Promise<void> {
61
55
  if (!listening) {
62
56
  listening = true;
63
57
  listenToPages();
@@ -73,10 +67,8 @@ export async function renderPages(root = document) {
73
67
  /**
74
68
  * The error state of every account page in `root`, for when nobody can say whether a customer is logged in (the
75
69
  * Worker or Shopify can't be reached).
76
- *
77
- * @param {ParentNode} root
78
70
  */
79
- export function showUnavailable(root) {
71
+ export function showUnavailable(root: ParentNode): void {
80
72
  qsa(root, sel('order')).forEach((el) => {
81
73
  showOrderState(el, 'error');
82
74
  });
package/src/account.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Customer accounts (Shopify Customer Account API), as an optional part: loaded only when `account` is configured
3
+ * and the page uses it, or a login is coming back, or the visitor may be logged in.
4
+ *
5
+ * window.TETHER_CONFIG = { …, account: { server: '/_tether/account' } }
6
+ *
7
+ * <button tether-account-login>Log in</button>
8
+ * <button tether-account-logout tether-show="logged-in">Log out</button>
9
+ * <span tether-account-bind="first-name"></span> first-name, last-name, name, email, address
10
+ * <div tether-orders> the customer's orders (first child = template)
11
+ * <a tether-order-bind="url"><span tether-order-bind="name"></span> · <span tether-order-bind="date"></span></a>
12
+ * </div>
13
+ * <p tether-show="logged-out">…</p>
14
+ *
15
+ * The site's Worker logs the customer in and holds the tokens (`account/server-client.ts` is this browser's side of
16
+ * it), so no token is ever in the browser. The order page, profile, addresses, subscriptions and returns are the
17
+ * `account-pages` part, loaded only on pages that use them.
18
+ */
19
+ import { cfg, log, reportError } from './config.ts';
20
+ import { TetherError } from './client.ts';
21
+ import { sel, get, qsa, setText, show, emit } from './dom.ts';
22
+ import { Cart } from './cart.ts';
23
+ import { loadPart } from './lazy.ts';
24
+ import type { Parts } from './lazy.ts';
25
+ import {
26
+ forgetServerLogin,
27
+ isServerLoggedIn,
28
+ serverCheckoutUrl,
29
+ serverFetch,
30
+ serverLogin,
31
+ serverLoginRedirect,
32
+ serverLogout,
33
+ } from './account/server-client.ts';
34
+ import { DEFAULT_ORDER_COUNT, MAX_ORDER_COUNT, isOrderCount, renderOrderList } from './account/order-list.ts';
35
+ import { ownValue } from './account/markup.ts';
36
+ import { toAccountOrders } from './account/answers.ts';
37
+ import type { AccountCustomer } from './account/answers.ts';
38
+ import { isLoginOutcome } from './account-marks.ts';
39
+ import type { AccountOrder } from '../server/src/shared/account-orders.ts';
40
+ import type { AddressFields, ReturnLine } from './account/operations.ts';
41
+ import type {
42
+ AccountAddress,
43
+ CustomerAddress,
44
+ OpenedReturn,
45
+ OrderDetail,
46
+ Profile,
47
+ SubscriptionChange,
48
+ SubscriptionContract,
49
+ } from './account/page-answers.ts';
50
+ import type { SubscriptionAction } from '../server/src/shared/account-routes.ts';
51
+
52
+ /**
53
+ * `unknown` when the Worker or Shopify can't say: the page shows its logged-out content, and the account pages their
54
+ * error state.
55
+ */
56
+ type LoginState = { kind: 'logged-in' } | { kind: 'logged-out' } | { kind: 'unknown'; error: unknown };
57
+
58
+ /** The attributes of the account pages, which are their own part. */
59
+ const PAGE_ATTRIBUTES = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
60
+
61
+ /** @returns True when the Worker last said a customer is logged in; false before it has been asked. */
62
+ export const isLoggedIn = isServerLoggedIn;
63
+
64
+ /**
65
+ * Through the Worker to Shopify's login page; the visitor comes back to `returnTo`.
66
+ *
67
+ * @throws {TetherError} `NOT_CONFIGURED` without `account.server`.
68
+ */
69
+ export function login({
70
+ returnTo = window.location.href,
71
+ locale,
72
+ }: { returnTo?: string; locale?: string } = {}): Promise<void> {
73
+ // In a promise, so a missing `account.server` rejects as it does for every other account function.
74
+ return new Promise((resolve) => {
75
+ serverLoginRedirect({ returnTo, locale });
76
+ resolve();
77
+ });
78
+ }
79
+
80
+ /**
81
+ * @returns The logged-in customer, `null` when nobody is.
82
+ * @throws {TetherError} When the Worker can't be reached or fails.
83
+ */
84
+ export async function customer(): Promise<AccountCustomer | null> {
85
+ const me = await serverLogin();
86
+ return me.loggedIn ? me.customer : null;
87
+ }
88
+
89
+ /**
90
+ * @param options `first`: a whole number from 1 to `MAX_ORDER_COUNT`.
91
+ * @returns The customer's most recent orders, newest first; none when nobody is logged in.
92
+ * @throws {TetherError} `INVALID` for another `first`, which is refused before any request; else when the Worker or
93
+ * Shopify can't answer.
94
+ */
95
+ export async function orders({ first = DEFAULT_ORDER_COUNT }: { first?: number } = {}): Promise<AccountOrder[]> {
96
+ if (!isOrderCount(first)) {
97
+ throw new TetherError(`Customer accounts: orders() takes first from 1 to ${String(MAX_ORDER_COUNT)}`, {
98
+ code: 'INVALID',
99
+ });
100
+ }
101
+ const answer = await serverFetch(`/orders?${new URLSearchParams({ first: String(first) }).toString()}`);
102
+ return answer.kind === 'logged-out' ? [] : toAccountOrders(answer.data['orders']);
103
+ }
104
+
105
+ /**
106
+ * Logs out here and at Shopify, and unlinks the cart. Sends `tether:account-logout`.
107
+ *
108
+ * @param options `returnTo`: where Shopify sends the visitor afterwards.
109
+ */
110
+ export async function logout({
111
+ returnTo = cfg.account?.logoutUrl || `${window.location.origin}/`,
112
+ }: { returnTo?: string } = {}): Promise<void> {
113
+ if ((await serverLogout(returnTo)) === 'stayed') await render(document);
114
+ }
115
+
116
+ async function loginState(): Promise<LoginState> {
117
+ try {
118
+ return (await serverLogin()).loggedIn ? { kind: 'logged-in' } : { kind: 'logged-out' };
119
+ } catch (error) {
120
+ return { kind: 'unknown', error };
121
+ }
122
+ }
123
+
124
+ const usesPages = (root: ParentNode): boolean => PAGE_ATTRIBUTES.some((name) => qsa(root, sel(name)).length > 0);
125
+
126
+ /**
127
+ * `tether-account-link`: Shopify's own pages, for what has to stay there: `subscriptions` and `payment`
128
+ * (`account.subscriptionsUrl` / `paymentUrl`), or the account itself.
129
+ */
130
+ function linkShopifyPages(root: ParentNode): void {
131
+ qsa(root, sel('account-link')).forEach((link) => {
132
+ link.setAttribute('href', shopifyPageUrl(get(link, 'account-link')));
133
+ });
134
+ }
135
+
136
+ /**
137
+ * @param page The `tether-account-link` value.
138
+ * @returns The configured page for it, else the shop's account.
139
+ */
140
+ function shopifyPageUrl(page: string | null): string {
141
+ const account = cfg.account;
142
+ const fallback = `https://${cfg.domain}/account`;
143
+ if (page === 'payment') return account?.paymentUrl || account?.subscriptionsUrl || fallback;
144
+ if (page === 'subscriptions') return account?.subscriptionsUrl || fallback;
145
+ return fallback;
146
+ }
147
+
148
+ /**
149
+ * Fills the account markup in `root`: what shows when logged in or out, the customer, their orders, and the account
150
+ * pages.
151
+ */
152
+ export async function render(root: ParentNode = document): Promise<void> {
153
+ const state = await loginState();
154
+ if (state.kind === 'unknown') reportError(state.error, 'account');
155
+ const loggedIn = state.kind === 'logged-in';
156
+ qsa(root, sel('show')).forEach((el) => {
157
+ const condition = get(el, 'show');
158
+ if (condition === 'logged-in') show(el, loggedIn);
159
+ if (condition === 'logged-out') show(el, !loggedIn);
160
+ });
161
+ linkShopifyPages(root);
162
+ if (state.kind === 'unknown' && usesPages(root)) {
163
+ (await pages()).showUnavailable(root);
164
+ return;
165
+ }
166
+ if (!loggedIn) return;
167
+ await bindCustomer(root);
168
+ await Promise.all([
169
+ renderOrderList(root, (first) => orders({ first })),
170
+ usesPages(root) ? pages().then((part) => part.renderPages(root)) : null,
171
+ ]);
172
+ }
173
+
174
+ /**
175
+ * `tether-account-bind`: the customer's name, email, default address. A failure is reported and leaves the binds as
176
+ * they are.
177
+ */
178
+ async function bindCustomer(root: ParentNode): Promise<void> {
179
+ const binds = qsa(root, sel('account-bind'));
180
+ if (!binds.length) return;
181
+ let profile: AccountCustomer | null;
182
+ try {
183
+ profile = await customer();
184
+ } catch (error) {
185
+ reportError(error, 'account');
186
+ return;
187
+ }
188
+ if (!profile) return;
189
+ const texts: Record<string, string | null | undefined> = {
190
+ 'first-name': profile.firstName,
191
+ 'last-name': profile.lastName,
192
+ name: profile.displayName,
193
+ email: profile.emailAddress?.emailAddress,
194
+ address: profile.defaultAddress?.formatted.join(', '),
195
+ };
196
+ binds.forEach((el) => {
197
+ const text = ownValue(texts, get(el, 'account-bind') ?? '');
198
+ if (text != null) setText(el, text);
199
+ });
200
+ }
201
+
202
+ let pagesPart: Promise<Parts['account-pages']> | null = null;
203
+
204
+ /** @returns The account pages part, sharing this part's session. */
205
+ function pages(): Promise<Parts['account-pages']> {
206
+ pagesPart ??= loadPart('account-pages').then((part) => {
207
+ part.setup({ serverFetch, customer, bindCustomer, reset: forgetServerLogin });
208
+ return part;
209
+ });
210
+ return pagesPart;
211
+ }
212
+
213
+ /** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
214
+ export const order = (id: string | number): Promise<OrderDetail | null> => pages().then((part) => part.order(id));
215
+ export const updateProfile = (fields?: { firstName?: string; lastName?: string }): Promise<Profile> =>
216
+ pages().then((part) => part.updateProfile(fields));
217
+ export const addresses = (): Promise<CustomerAddress[]> => pages().then((part) => part.addresses());
218
+ /** Creates an address, or updates the one with `id`. */
219
+ export const saveAddress = (
220
+ address: AddressFields | null,
221
+ options?: { id?: string; isDefault?: boolean },
222
+ ): Promise<AccountAddress> => pages().then((part) => part.saveAddress(address, options));
223
+ export const deleteAddress = (id: string): Promise<string> => pages().then((part) => part.deleteAddress(id));
224
+ export const subscriptions = (): Promise<SubscriptionContract[]> => pages().then((part) => part.subscriptions());
225
+ export const subscriptionAction = (id: string | number, action: SubscriptionAction): Promise<SubscriptionChange> =>
226
+ pages().then((part) => part.subscriptionAction(id, action));
227
+ export const requestReturn = (orderId: string | number, lines: ReturnLine[]): Promise<OpenedReturn> =>
228
+ pages().then((part) => part.requestReturn(orderId, lines));
229
+
230
+ function onClick(event: MouseEvent): void {
231
+ const target = event.target;
232
+ if (!(target instanceof window.Element)) return;
233
+ if (target.closest(sel('account-login'))) {
234
+ event.preventDefault();
235
+ login().catch((error: unknown) => {
236
+ reportError(error, 'login');
237
+ });
238
+ } else if (target.closest(sel('account-logout'))) {
239
+ event.preventDefault();
240
+ logout().catch((error: unknown) => {
241
+ reportError(error, 'logout');
242
+ });
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Back from the Worker's login (`?account=`): drops the mark and sends `tether:account-login`, or
248
+ * `tether:account-error` with the outcome as its `error` when the login didn't finish.
249
+ */
250
+ function finishServerLogin(): void {
251
+ const url = new URL(window.location.href);
252
+ const outcome = url.searchParams.get('account');
253
+ if (!isLoginOutcome(outcome)) return;
254
+ url.searchParams.delete('account');
255
+ window.history.replaceState(window.history.state, '', url);
256
+ switch (outcome) {
257
+ case 'in':
258
+ emit('account-login', {});
259
+ break;
260
+ case 'expired':
261
+ case 'error':
262
+ emit('account-error', { error: outcome });
263
+ break;
264
+ }
265
+ }
266
+
267
+ let started = false;
268
+
269
+ /**
270
+ * Called by the core on mount: on the first one, finishes a login that's coming back and lets the Worker link the
271
+ * cart at checkout; then fills the page.
272
+ */
273
+ export async function mountAccount(root: ParentNode = document): Promise<void> {
274
+ if (started) {
275
+ await render(root);
276
+ return;
277
+ }
278
+ started = true;
279
+ document.addEventListener('click', onClick);
280
+ finishServerLogin();
281
+ Cart.setCheckoutResolver(serverCheckoutUrl);
282
+ await render(root);
283
+ log('account', isLoggedIn() ? 'logged in' : 'logged out');
284
+ }
package/src/amounts.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Arithmetic on Shopify's money amounts, which are decimal strings (`'27.50'`). Comparisons go digit by digit, as
3
+ * integers of the smallest unit present, so two amounts that differ only past float precision aren't misjudged. Sums
4
+ * and differences are taken in the currency's own smallest unit, so JPY stays whole and KWD keeps its three digits.
5
+ */
6
+ import type { Money } from './shopify-values.ts';
7
+
8
+ const DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
9
+
10
+ /**
11
+ * @param fractionDigits Digits after the point to scale to.
12
+ * @returns The amount in units of 10^-fractionDigits, `null` when it isn't a decimal number.
13
+ */
14
+ function toScaledInteger(amount: string, fractionDigits: number): bigint | null {
15
+ const match = DECIMAL.exec(amount.trim());
16
+ if (!match) return null;
17
+ const [, sign = '', whole = '0', fraction = ''] = match;
18
+ const digits = BigInt(`${whole}${fraction.padEnd(fractionDigits, '0')}`);
19
+ return sign === '-' ? -digits : digits;
20
+ }
21
+
22
+ const fractionLength = (amount: string) => amount.split('.')[1]?.length ?? 0;
23
+
24
+ /** @returns True when both are decimal numbers and `amount` is the larger one. */
25
+ export function isGreaterAmount(amount: string | null | undefined, other: string | null | undefined): boolean {
26
+ if (amount == null || other == null) return false;
27
+ const fractionDigits = Math.max(fractionLength(amount.trim()), fractionLength(other.trim()));
28
+ const scaled = toScaledInteger(amount, fractionDigits);
29
+ const scaledOther = toScaledInteger(other, fractionDigits);
30
+ if (scaled === null || scaledOther === null) return false;
31
+ return scaled > scaledOther;
32
+ }
33
+
34
+ /** For a currency code Intl doesn't know: most currencies have two digits, and `formatMoney` then shows two too. */
35
+ const DEFAULT_FRACTION_DIGITS = 2;
36
+
37
+ /** Currency code → its fraction digits, which never change on a page. */
38
+ const fractionDigitsByCurrency = new Map<string, number>();
39
+
40
+ /** @returns The digits after the point the currency has: 0 for JPY, 2 for EUR, 3 for KWD. */
41
+ function currencyFractionDigits(currencyCode: string): number {
42
+ const known = fractionDigitsByCurrency.get(currencyCode);
43
+ if (known !== undefined) return known;
44
+ let digits = DEFAULT_FRACTION_DIGITS;
45
+ try {
46
+ const format = new Intl.NumberFormat('en', { style: 'currency', currency: currencyCode });
47
+ digits = format.resolvedOptions().maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
48
+ } catch {
49
+ // A code Intl doesn't know (RangeError): see DEFAULT_FRACTION_DIGITS.
50
+ }
51
+ fractionDigitsByCurrency.set(currencyCode, digits);
52
+ return digits;
53
+ }
54
+
55
+ /** @returns The amount in the currency's smallest unit (cents, yen, fils), rounded to a whole one. */
56
+ export function toMinorUnits(money: Money): number {
57
+ return Math.round(parseFloat(money.amount) * 10 ** currencyFractionDigits(money.currencyCode));
58
+ }
59
+
60
+ /** @returns The amount written with the currency's own number of digits. */
61
+ export function fromMinorUnits(minorUnits: number, currencyCode: string): Money {
62
+ const digits = currencyFractionDigits(currencyCode);
63
+ return { amount: (minorUnits / 10 ** digits).toFixed(digits), currencyCode };
64
+ }