@occluze/tether 0.6.7 → 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 (250) 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 -1
  24. package/dist/presets/barba.esm.js +55 -39
  25. package/dist/tether.account-pages.js +781 -495
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +462 -381
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +299 -194
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +354 -262
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +82 -45
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3653 -2116
  36. package/dist/tether.markets.js +238 -150
  37. package/dist/tether.markets.min.js +1 -1
  38. package/dist/tether.min.js +3 -3
  39. package/dist/tether.min.js.map +4 -4
  40. package/dist/tether.search.js +207 -136
  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 +42 -22
  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.ts +75 -0
  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.ts +253 -0
  127. package/src/account/server-client.ts +183 -0
  128. package/src/account/subscriptions-view.ts +115 -0
  129. package/src/account-api.ts +98 -0
  130. package/src/account-marks.ts +35 -0
  131. package/src/account-pages.ts +81 -0
  132. package/src/account.ts +284 -0
  133. package/src/amounts.ts +64 -0
  134. package/src/analytics-shopify.ts +246 -0
  135. package/src/analytics.ts +315 -0
  136. package/src/api-version.ts +79 -0
  137. package/src/astro.ts +272 -0
  138. package/src/cart/cart-shape.ts +25 -0
  139. package/src/cart/checkout.ts +101 -0
  140. package/src/cart/drawer.ts +125 -0
  141. package/src/cart/mutations.ts +237 -0
  142. package/src/cart/render.ts +266 -0
  143. package/src/cart/store.ts +279 -0
  144. package/src/cart.ts +345 -0
  145. package/src/client.ts +391 -0
  146. package/src/collection.ts +175 -0
  147. package/src/config-sections.ts +284 -0
  148. package/src/config.ts +480 -0
  149. package/src/controls/cart-controls.ts +123 -0
  150. package/src/controls/discount.ts +75 -0
  151. package/src/controls/extras.ts +54 -0
  152. package/src/controls/navigation.ts +77 -0
  153. package/src/diagnose-markup.ts +298 -0
  154. package/src/diagnose.ts +294 -0
  155. package/src/dom.ts +268 -0
  156. package/src/extras.ts +136 -0
  157. package/src/fragments.ts +261 -0
  158. package/src/globals.d.ts +15 -0
  159. package/src/images.ts +91 -0
  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.ts +363 -0
  165. package/src/market-codes.ts +6 -0
  166. package/src/markets-ui.ts +227 -0
  167. package/src/markets.ts +246 -0
  168. package/src/markup.ts +127 -0
  169. package/src/mounting.ts +264 -0
  170. package/src/page-market.ts +110 -0
  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.ts +115 -0
  181. package/src/product-render.ts +218 -0
  182. package/src/product-shape.ts +49 -0
  183. package/src/product-state.ts +318 -0
  184. package/src/product.ts +308 -0
  185. package/src/repeaters.ts +161 -0
  186. package/src/search.ts +305 -0
  187. package/src/selling-plans.ts +145 -0
  188. package/src/server.ts +211 -0
  189. package/src/shopify-values.ts +91 -0
  190. package/src/status.ts +106 -0
  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.ts +144 -0
  195. package/src/variant-render.ts +139 -0
  196. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  197. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  198. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  199. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  200. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  201. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  202. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  203. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  204. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  205. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  206. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  207. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  208. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  209. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  210. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  211. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  212. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
  213. package/src/account-pages.js +0 -318
  214. package/src/account.js +0 -385
  215. package/src/analytics.js +0 -232
  216. package/src/api-version.js +0 -35
  217. package/src/astro.js +0 -101
  218. package/src/cart.js +0 -559
  219. package/src/client.js +0 -131
  220. package/src/collection.js +0 -114
  221. package/src/config.js +0 -85
  222. package/src/diagnose.js +0 -246
  223. package/src/dom.js +0 -288
  224. package/src/extras.js +0 -63
  225. package/src/fragments.js +0 -144
  226. package/src/index.js +0 -395
  227. package/src/initial.js +0 -75
  228. package/src/lazy-iife.js +0 -46
  229. package/src/lazy.js +0 -18
  230. package/src/loader.js +0 -198
  231. package/src/markets-ui.js +0 -90
  232. package/src/markets.js +0 -133
  233. package/src/mounting.js +0 -154
  234. package/src/parts/account-pages.js +0 -3
  235. package/src/parts/account.js +0 -3
  236. package/src/parts/analytics.js +0 -3
  237. package/src/parts/diagnose.js +0 -3
  238. package/src/parts/extras.js +0 -3
  239. package/src/parts/markets.js +0 -3
  240. package/src/parts/search.js +0 -3
  241. package/src/presets/barba.js +0 -100
  242. package/src/product.js +0 -570
  243. package/src/search.js +0 -153
  244. package/src/selling-plans.js +0 -94
  245. package/src/server.js +0 -252
  246. package/src/status.js +0 -66
  247. package/types/astro.d.ts +0 -17
  248. package/types/barba.d.ts +0 -16
  249. package/types/index.d.ts +0 -426
  250. package/types/server.d.ts +0 -110
@@ -0,0 +1,81 @@
1
+ /**
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. Each operation is one of the Worker's fixed routes, which
4
+ * checks the input (account/operations.ts).
5
+ */
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';
15
+
16
+ export {
17
+ setup,
18
+ order,
19
+ updateProfile,
20
+ addresses,
21
+ saveAddress,
22
+ deleteAddress,
23
+ subscriptions,
24
+ subscriptionAction,
25
+ requestReturn,
26
+ } from './account/operations.ts';
27
+
28
+ /** The profile fields a `tether-profile-form` is filled with. */
29
+ const PROFILE_FIELDS = ['firstName', 'lastName'] as const;
30
+
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;
44
+ PROFILE_FIELDS.forEach((name) => {
45
+ const input = fields.namedItem(name);
46
+ const isTextField = input instanceof window.HTMLInputElement || input instanceof window.HTMLTextAreaElement;
47
+ if (isTextField && !input.dataset['tetherTouched']) input.value = customer[name] ?? '';
48
+ });
49
+ }
50
+
51
+ let listening = false;
52
+
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> {
55
+ if (!listening) {
56
+ listening = true;
57
+ listenToPages();
58
+ }
59
+ await Promise.all([
60
+ ...qsa(root, sel('order')).map((el) => renderOrder(el)),
61
+ ...qsa(root, sel('profile-form')).map((form) => renderProfile(form)),
62
+ renderAddresses(root),
63
+ renderSubscriptions(root),
64
+ ]);
65
+ }
66
+
67
+ /**
68
+ * The error state of every account page in `root`, for when nobody can say whether a customer is logged in (the
69
+ * Worker or Shopify can't be reached).
70
+ */
71
+ export function showUnavailable(root: ParentNode): void {
72
+ qsa(root, sel('order')).forEach((el) => {
73
+ showOrderState(el, 'error');
74
+ });
75
+ showIn(root, 'show', {
76
+ 'addresses-loading': false,
77
+ 'addresses-error': true,
78
+ 'subscriptions-loading': false,
79
+ 'subscriptions-error': true,
80
+ });
81
+ }
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
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Shopify's own analytics for the analytics part. Tether sets up the same event bus Hydrogen uses
3
+ * (`window.Shopify.analytics`) and loads Shopify's consent script and shopify.js, which build and send the events.
4
+ * Nothing is delivered before the Customer Privacy API allows analytics.
5
+ */
6
+ import { cfg, log, reportError } from './config.ts';
7
+ import { gql, shaped } from './client.ts';
8
+ import type { AnalyticsConfig } from './config.ts';
9
+
10
+ export type EventPayload = Record<string, unknown>;
11
+
12
+ interface TrackingValues {
13
+ uniqueToken: string;
14
+ visitToken: string;
15
+ }
16
+
17
+ interface DestinationContext {
18
+ getTrackingValues: () => TrackingValues;
19
+ }
20
+
21
+ type Subscriber = (payload: EventPayload, context: DestinationContext) => void;
22
+ type Subscribe = (event: string, subscriber: Subscriber) => () => void;
23
+
24
+ interface BusConfig {
25
+ shop: { shopId: string; channel: 'headless' };
26
+ consent: { mode: string };
27
+ }
28
+
29
+ /** What shopify.js registers. */
30
+ interface DestinationSpec {
31
+ name: string;
32
+ setup?: (api: { subscribe: Subscribe; getConfig: () => BusConfig }) => unknown;
33
+ }
34
+
35
+ /** Hydrogen's `window.Shopify.analytics`. */
36
+ export interface AnalyticsBus {
37
+ getConfig: () => BusConfig;
38
+ publish: (event: string, payload?: EventPayload) => void;
39
+ addDestination: (destination: DestinationSpec) => () => void;
40
+ }
41
+
42
+ type TokenReader = (options: { generateFallback: boolean; tag: string }) => string;
43
+
44
+ /** The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking. */
45
+ interface CustomerPrivacy {
46
+ consentStatus?: string;
47
+ currentVisitorConsent?: () => { analytics?: string } | undefined;
48
+ analyticsProcessingAllowed?: () => boolean;
49
+ __internal?: { uniqueToken?: TokenReader; visitToken?: TokenReader };
50
+ config?: object;
51
+ }
52
+
53
+ /** `window.Shopify`, which Shopify's scripts read and extend. */
54
+ interface ShopifyGlobal {
55
+ analytics?: AnalyticsBus;
56
+ customerPrivacy?: CustomerPrivacy;
57
+ country?: string;
58
+ locale?: string;
59
+ shop?: string;
60
+ routes?: object;
61
+ }
62
+
63
+ interface BufferedEvent {
64
+ event: string;
65
+ payload: EventPayload;
66
+ }
67
+
68
+ interface Destination {
69
+ name: string;
70
+ /** The index of the next buffered event it gets. */
71
+ next: number;
72
+ subscribers: Map<string, Set<Subscriber>>;
73
+ context: DestinationContext;
74
+ }
75
+
76
+ /** Events kept for destinations that register late (shopify.js loads after the first page view). */
77
+ const BUFFER_SIZE = 500;
78
+ const CONSENT_SCRIPTS = {
79
+ 'default-banner': 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js',
80
+ other: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js',
81
+ };
82
+ const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
83
+
84
+ /** The page, where Shopify's scripts keep `window.Shopify` in the shape they define. */
85
+ const page = (): Window & { Shopify?: ShopifyGlobal } => window;
86
+
87
+ const shopifyGlobal = (): ShopifyGlobal | undefined => page().Shopify;
88
+
89
+ /** The `analytics.shopify` options; `true` sets none. */
90
+ function shopifyOptions(): Exclude<NonNullable<AnalyticsConfig['shopify']>, boolean> {
91
+ const shopify = cfg.analytics?.shopify;
92
+ return typeof shopify === 'object' ? shopify : {};
93
+ }
94
+
95
+ /**
96
+ * Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
97
+ * allowed", so nothing is sent without consent; the next consent event asks again.
98
+ */
99
+ function trackingAllowed(): boolean {
100
+ try {
101
+ const privacy = shopifyGlobal()?.customerPrivacy;
102
+ return (
103
+ privacy?.consentStatus === 'loaded' &&
104
+ privacy.currentVisitorConsent?.()?.analytics !== 'no' &&
105
+ Boolean(privacy.analyticsProcessingAllowed?.())
106
+ );
107
+ } catch {
108
+ return false;
109
+ }
110
+ }
111
+
112
+ /** @param name The destination's name, Hydrogen tags its tokens with it. */
113
+ function destinationContext(name: string): DestinationContext {
114
+ const tag = `hydrogen:${name}`;
115
+ return {
116
+ getTrackingValues: () => {
117
+ const internal = trackingAllowed() ? shopifyGlobal()?.customerPrivacy?.__internal : undefined;
118
+ return {
119
+ uniqueToken: internal?.uniqueToken?.({ generateFallback: true, tag }) ?? '',
120
+ visitToken: internal?.visitToken?.({ generateFallback: true, tag }) ?? '',
121
+ };
122
+ },
123
+ };
124
+ }
125
+
126
+ const subscriberOf =
127
+ (destination: Destination): Subscribe =>
128
+ (event, subscriber) => {
129
+ const subscribers = destination.subscribers.get(event) ?? new Set();
130
+ destination.subscribers.set(event, subscribers);
131
+ subscribers.add(subscriber);
132
+ return () => destination.subscribers.get(event)?.delete(subscriber);
133
+ };
134
+
135
+ /** A bus that buffers events and delivers them to each destination once consent allows. */
136
+ function createBus(shopId: string): AnalyticsBus {
137
+ const destinations: Destination[] = [];
138
+ const buffer: BufferedEvent[] = [];
139
+ const config: BusConfig = {
140
+ shop: { shopId, channel: 'headless' },
141
+ consent: { mode: shopifyOptions().consent || 'no-banner' },
142
+ };
143
+ const deliver = () => {
144
+ if (!trackingAllowed()) return;
145
+ for (const destination of destinations) {
146
+ for (; destination.next < buffer.length; destination.next += 1) {
147
+ const buffered = buffer[destination.next];
148
+ if (buffered) deliverTo(destination, buffered);
149
+ }
150
+ }
151
+ };
152
+ const bus: AnalyticsBus = {
153
+ getConfig: () => config,
154
+ publish(event, payload = {}) {
155
+ buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
156
+ if (buffer.length > BUFFER_SIZE) {
157
+ buffer.shift();
158
+ for (const destination of destinations) destination.next = Math.max(0, destination.next - 1);
159
+ }
160
+ deliver();
161
+ },
162
+ addDestination(spec) {
163
+ const destination: Destination = {
164
+ name: spec.name,
165
+ next: 0,
166
+ subscribers: new Map(),
167
+ context: destinationContext(spec.name),
168
+ };
169
+ Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
170
+ .then(() => {
171
+ destinations.push(destination);
172
+ deliver();
173
+ })
174
+ .catch((error: unknown) => {
175
+ reportError(error, `analytics ${spec.name}`);
176
+ });
177
+ return () => {
178
+ const index = destinations.indexOf(destination);
179
+ if (index >= 0) destinations.splice(index, 1);
180
+ };
181
+ },
182
+ };
183
+ document.addEventListener('consentTrackingApiLoaded', deliver);
184
+ document.addEventListener('visitorConsentCollected', deliver);
185
+ return bus;
186
+ }
187
+
188
+ function deliverTo(destination: Destination, { event, payload }: BufferedEvent): void {
189
+ destination.subscribers.get(event)?.forEach((subscriber) => {
190
+ try {
191
+ subscriber(payload, destination.context);
192
+ } catch (error) {
193
+ reportError(error, `analytics ${destination.name}`);
194
+ }
195
+ });
196
+ }
197
+
198
+ function loadScript(src: string): void {
199
+ const script = document.createElement('script');
200
+ script.src = src;
201
+ script.async = true;
202
+ document.head.appendChild(script);
203
+ }
204
+
205
+ /**
206
+ * Sets up `window.Shopify` for Shopify's scripts and loads them.
207
+ *
208
+ * @param isProductPage Asked by Shopify's script for the page template.
209
+ * @returns The bus, the page's own when a theme already set one up.
210
+ */
211
+ export async function startShopifyAnalytics(isProductPage: () => boolean): Promise<AnalyticsBus> {
212
+ const existing = shopifyGlobal();
213
+ if (existing?.analytics) return existing.analytics;
214
+ const data = await gql(
215
+ 'query TetherShopId { shop { id } }',
216
+ {},
217
+ shaped<{ shop: { id: string } }>({ shop: { id: 'string' } }),
218
+ { context: false },
219
+ );
220
+ const shopify = shopifyGlobal() ?? {};
221
+ page().Shopify = shopify;
222
+ shopify.country = cfg.country ?? 'US';
223
+ // Shopify's script requires a language, not a full locale.
224
+ const [language = 'en'] = (cfg.locale || 'en').split('-');
225
+ shopify.locale = language.toLowerCase();
226
+ shopify.shop = cfg.domain;
227
+ shopify.routes = {
228
+ root: '/',
229
+ match: () => ({ pageTemplateName: isProductPage() ? 'product' : 'page' }),
230
+ ...shopify.routes,
231
+ };
232
+ // A same-origin Storefront API path gives Shopify exact visitor counts.
233
+ const { proxy, consent } = shopifyOptions();
234
+ shopify.customerPrivacy = {
235
+ ...shopify.customerPrivacy,
236
+ config: proxy
237
+ ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
238
+ : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
239
+ };
240
+ const bus = createBus(data.shop.id);
241
+ shopify.analytics = bus;
242
+ loadScript(consent === 'default-banner' ? CONSENT_SCRIPTS['default-banner'] : CONSENT_SCRIPTS.other);
243
+ loadScript(ANALYTICS_SCRIPT);
244
+ log('shopify analytics ready', data.shop.id);
245
+ return bus;
246
+ }