@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
@@ -1,192 +0,0 @@
1
- /**
2
- * The order page (`tether-order`): one order in full, with its lines, totals, addresses, shipments and what can be
3
- * returned. `tether-order-state` and `tether-order-show` follow the load: `loading`, then `ready`, `not-found` or
4
- * `error`.
5
- */
6
- import { reportError } from '../config.js';
7
- import { isHttpsUrl, localDate } from '../account-links.js';
8
- import { attr, formatMoney, get, qsa, repeat, sel, setText, show } from '../dom.js';
9
- import { addressText, bindsIn, enumText, ownValue, showIn } from './markup.js';
10
- import { fillLines } from './order-lines.js';
11
- import { order } from './operations.js';
12
- /** @import { Money } from '../shopify-values.js' */
13
- /** @import { OrderLine } from './order-lines.js' */
14
-
15
- /**
16
- * @typedef {object} Fulfillment
17
- * @property {string} status
18
- * @property {string} createdAt
19
- * @property {string | null} estimatedDeliveryAt
20
- * @property {string | null} latestShipmentStatus
21
- * @property {{ company: string | null, number: string | null, url: string | null }[]} trackingInformation
22
- */
23
- /**
24
- * @typedef {object} OrderDetail The order page's document in account-ops.js.
25
- * @property {string} id
26
- * @property {string} name
27
- * @property {string} processedAt
28
- * @property {string | null} cancelledAt
29
- * @property {string | null} financialStatus
30
- * @property {string | null} fulfillmentStatus
31
- * @property {string | null} statusPageUrl
32
- * @property {Money} totalPrice
33
- * @property {Money | null} subtotal
34
- * @property {Money | null} totalShipping
35
- * @property {Money | null} totalTax
36
- * @property {{ formatted: string[] } | null} shippingAddress
37
- * @property {{ formatted: string[] } | null} billingAddress
38
- * @property {{ nodes: (OrderLine & { id: string })[] }} lineItems
39
- * @property {{ nodes: Fulfillment[] }} fulfillments
40
- * @property {{ returnableLineItems: { nodes: { quantity: number, lineItem: { id: string } | null }[] } } | null}
41
- * returnInformation
42
- * @property {{ nodes: { id: string, status: string }[] }} returns
43
- */
44
- /** @typedef {'loading' | 'ready' | 'not-found' | 'error'} OrderState */
45
-
46
- /**
47
- * @param {HTMLElement} el
48
- * @returns {string | null} The order `tether-order` names: an id, or `url:<param>` for one in the page's URL.
49
- */
50
- function orderKey(el) {
51
- const raw = get(el, 'order') || '';
52
- if (!raw.startsWith('url:')) return raw;
53
- return new URL(window.location.href).searchParams.get(raw.slice('url:'.length) || 'id');
54
- }
55
-
56
- /**
57
- * Every state but `ready`, which shows with the order.
58
- *
59
- * @param {HTMLElement} el
60
- * @param {Exclude<OrderState, 'ready'>} state
61
- */
62
- export function showOrderState(el, state) {
63
- el.setAttribute(attr('order-state'), state);
64
- showIn(el, 'order-show', {
65
- loading: state === 'loading',
66
- ready: false,
67
- 'not-found': state === 'not-found',
68
- error: state === 'error',
69
- });
70
- }
71
-
72
- /**
73
- * @param {OrderDetail} detail
74
- * @returns {Record<string, string>} The text of each `tether-order-bind` value.
75
- */
76
- const orderTexts = (detail) => ({
77
- name: detail.name,
78
- date: localDate(detail.processedAt),
79
- total: formatMoney(detail.totalPrice),
80
- subtotal: formatMoney(detail.subtotal),
81
- shipping: formatMoney(detail.totalShipping),
82
- tax: formatMoney(detail.totalTax),
83
- status: enumText(detail.financialStatus),
84
- fulfillment: enumText(detail.fulfillmentStatus),
85
- 'shipping-address': addressText(detail.shippingAddress),
86
- 'billing-address': addressText(detail.billingAddress),
87
- items: detail.lineItems.nodes.map((line) => `${line.quantity}× ${line.title || line.name}`).join(', '),
88
- cancelled: detail.cancelledAt ? localDate(detail.cancelledAt) : '',
89
- });
90
-
91
- /**
92
- * One shipment row: its status and dates, and its tracking link, hidden without an https one.
93
- *
94
- * @param {HTMLElement} row
95
- * @param {Fulfillment} fulfillment
96
- */
97
- function fillFulfillment(row, fulfillment) {
98
- const tracking = fulfillment.trackingInformation[0];
99
- /** @type {Record<string, string>} */
100
- const texts = {
101
- status: enumText(fulfillment.latestShipmentStatus || fulfillment.status),
102
- date: localDate(fulfillment.createdAt),
103
- eta: fulfillment.estimatedDeliveryAt ? localDate(fulfillment.estimatedDeliveryAt) : '',
104
- company: tracking?.company || '',
105
- number: tracking?.number || '',
106
- };
107
- bindsIn(row, 'fulfillment-bind').forEach((el) => {
108
- const key = get(el, 'fulfillment-bind') ?? '';
109
- if (key === 'url') {
110
- const url = tracking?.url;
111
- const linked = isHttpsUrl(url);
112
- if (linked && url) el.setAttribute('href', url);
113
- show(el, linked);
114
- return;
115
- }
116
- const text = ownValue(texts, key);
117
- if (text !== undefined) setText(el, text);
118
- });
119
- }
120
-
121
- /**
122
- * @param {HTMLElement} el
123
- * @param {OrderDetail} detail
124
- */
125
- function fillOrder(el, detail) {
126
- const returnable = new Map(
127
- (detail.returnInformation?.returnableLineItems.nodes ?? []).map((entry) => [
128
- entry.lineItem?.id ?? '',
129
- entry.quantity,
130
- ]),
131
- );
132
- const shipments = detail.fulfillments.nodes;
133
- const texts = orderTexts(detail);
134
- qsa(el, sel('order-bind')).forEach((bind) => {
135
- const key = get(bind, 'order-bind') ?? '';
136
- if (key === 'url') {
137
- if (detail.statusPageUrl && isHttpsUrl(detail.statusPageUrl)) bind.setAttribute('href', detail.statusPageUrl);
138
- return;
139
- }
140
- const text = ownValue(texts, key);
141
- if (text !== undefined) setText(bind, text);
142
- });
143
- qsa(el, sel('order-lines')).forEach((box) => {
144
- fillLines(box, detail.lineItems.nodes, returnable);
145
- });
146
- qsa(el, sel('order-fulfillments')).forEach((box) => {
147
- repeat(box, shipments, fillFulfillment);
148
- });
149
- const returnRequested = el.dataset['tetherReturned'] === '1';
150
- showIn(el, 'order-show', {
151
- loading: false,
152
- ready: true,
153
- error: false,
154
- 'not-found': false,
155
- cancelled: Boolean(detail.cancelledAt),
156
- shipped: shipments.length > 0,
157
- returnable: [...returnable.values()].some((quantity) => quantity > 0),
158
- 'has-returns': !returnRequested && detail.returns.nodes.length > 0,
159
- 'return-requested': returnRequested,
160
- });
161
- el.setAttribute(attr('order-state'), 'ready');
162
- }
163
-
164
- /**
165
- * Loads and renders the order `el` names. A failure is reported and shows the error state.
166
- *
167
- * @param {HTMLElement} el
168
- */
169
- export async function renderOrder(el) {
170
- const key = orderKey(el);
171
- if (!key) {
172
- el.setAttribute(attr('order-state'), 'not-found');
173
- showIn(el, 'order-show', { 'not-found': true });
174
- return;
175
- }
176
- showOrderState(el, 'loading');
177
- /** @type {OrderDetail | null} */
178
- let detail;
179
- try {
180
- detail = await order(key);
181
- } catch (error) {
182
- reportError(error, 'order');
183
- showOrderState(el, 'error');
184
- return;
185
- }
186
- if (!detail) {
187
- showOrderState(el, 'not-found');
188
- return;
189
- }
190
- el.dataset['tetherOrderId'] = detail.id;
191
- fillOrder(el, detail);
192
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * Server mode (`account.server`): the site's Worker holds the Shopify tokens, and this browser only a session cookie
3
- * its scripts can't read. This module is the browser's side of the Worker's account routes.
4
- */
5
- import { cfg } from '../config.js';
6
- import { TetherError } from '../client.js';
7
- import { Cart } from '../cart.js';
8
- import { emit } from '../dom.js';
9
- import {
10
- isRecord,
11
- loginCountry,
12
- loginLanguage,
13
- navigate,
14
- readJsonObjectOrEmpty,
15
- setLoginHint,
16
- textOrNull,
17
- } from './shared.js';
18
- /** @import { UserError } from '../client.js' */
19
-
20
- /**
21
- * @typedef {{ kind: 'answer', data: Record<string, unknown> } | { kind: 'logged-out' }} ServerAnswer The Worker's
22
- * answer, or a 401: the session cookie is missing, has expired, or Shopify ended the login.
23
- */
24
- /**
25
- * @typedef {object} ServerCustomer The profile the Worker's `/me` sends.
26
- * @property {string | null} firstName
27
- * @property {string | null} lastName
28
- * @property {string | null} displayName
29
- * @property {string | null} email
30
- * @property {string[] | null} address The default address, one line per entry.
31
- */
32
- /** @typedef {{ loggedIn: true, customer: ServerCustomer } | { loggedIn: false }} ServerLogin */
33
- /** @typedef {{ method?: 'GET' | 'POST', body?: Record<string, unknown> }} ServerRequest */
34
-
35
- /** @type {ServerLogin | null} The Worker's `/me` answer, until a change of the customer's data. */
36
- let known = null;
37
-
38
- /**
39
- * @returns {string} The Worker's account routes, without a trailing slash; `''` in browser mode. `account.server` is a
40
- * full URL or a path on this site.
41
- */
42
- export function serverBase() {
43
- const server = cfg.account?.server;
44
- if (!server) return '';
45
- return new URL(server, window.location.href).href.replace(/\/$/, '');
46
- }
47
-
48
- /**
49
- * One request to the Worker, with the session cookie. A POST carries the header the Worker requires against
50
- * cross-site forms.
51
- *
52
- * @param {string} path
53
- * @param {ServerRequest} [request]
54
- * @returns {Promise<ServerAnswer>}
55
- * @throws {TetherError} `HTTP_<status>` for any failure but a 401, with the Worker's `userErrors`.
56
- */
57
- export async function serverFetch(path, { method = 'GET', body } = {}) {
58
- /** @type {RequestInit} */
59
- const init = { method, credentials: 'include' };
60
- if (method === 'POST') init.headers = { 'Content-Type': 'application/json', 'X-Tether-Account': '1' };
61
- if (body) init.body = JSON.stringify(body);
62
- const response = await fetch(`${serverBase()}${path}`, init);
63
- if (response.status === 401) return { kind: 'logged-out' };
64
- const data = await readJsonObjectOrEmpty(response);
65
- if (!response.ok) {
66
- const reason = textOrNull(data['error']) ?? String(response.status);
67
- /** @type {TetherError['userErrors']} */
68
- const userErrors = Array.isArray(data['userErrors'])
69
- ? // The Worker sends Shopify's own `userErrors` list unchanged.
70
- /** @type {UserError[]} */ (data['userErrors'])
71
- : undefined;
72
- throw new TetherError(`Customer accounts: ${reason}`, {
73
- status: response.status,
74
- ...(userErrors && { userErrors }),
75
- });
76
- }
77
- return { kind: 'answer', data };
78
- }
79
-
80
- /**
81
- * @param {unknown} value
82
- * @returns {ServerCustomer}
83
- */
84
- function toServerCustomer(value) {
85
- const customer = isRecord(value) ? value : {};
86
- const address = customer['address'];
87
- return {
88
- firstName: textOrNull(customer['firstName']),
89
- lastName: textOrNull(customer['lastName']),
90
- displayName: textOrNull(customer['displayName']),
91
- email: textOrNull(customer['email']),
92
- address: Array.isArray(address) ? address.filter((line) => typeof line === 'string') : null,
93
- };
94
- }
95
-
96
- /**
97
- * Who is logged in, asked once and then kept; the "may be logged in" hint follows the answer.
98
- *
99
- * @returns {Promise<ServerLogin>}
100
- * @throws {TetherError} When the Worker can't be reached or fails: that says nothing about the login.
101
- */
102
- export async function serverLogin() {
103
- if (!known) {
104
- const answer = await serverFetch('/me');
105
- known =
106
- answer.kind === 'answer' && answer.data['loggedIn'] === true
107
- ? { loggedIn: true, customer: toServerCustomer(answer.data['customer']) }
108
- : { loggedIn: false };
109
- }
110
- setLoginHint(known.loggedIn);
111
- return known;
112
- }
113
-
114
- /** @returns {boolean} True when the Worker last said a customer is logged in. */
115
- export const isServerLoggedIn = () => known?.loggedIn === true;
116
-
117
- /** Asks the Worker again on the next `serverLogin()`, after a change of the customer's data. */
118
- export function forgetServerLogin() {
119
- known = null;
120
- }
121
-
122
- /**
123
- * To the Worker's login, which comes back to `returnTo`. The cart is linked later, at checkout, through the Worker's
124
- * protected `POST /cart`.
125
- *
126
- * @param {{ returnTo: string, locale?: string | undefined }} options
127
- */
128
- export function serverLoginRedirect({ returnTo, locale }) {
129
- const url = new URL(`${serverBase()}/login`);
130
- url.searchParams.set('returnTo', returnTo);
131
- const language = loginLanguage(locale);
132
- if (language) url.searchParams.set('locale', language);
133
- const country = loginCountry();
134
- if (country) url.searchParams.set('country', country);
135
- navigate(url.toString());
136
- }
137
-
138
- /**
139
- * The logged-in customer's checkout, for `Cart.beforeCheckout`: the Worker links the cart to the customer and answers
140
- * with its checkout URL.
141
- *
142
- * @returns {Promise<string | null>} `null` when nobody is logged in or the Worker linked no cart, so the cart's own
143
- * checkout URL is used.
144
- * @throws {TetherError} When the Worker fails; the cart reports it and checks out without the login.
145
- */
146
- export async function serverCheckoutUrl() {
147
- const cartId = Cart.id();
148
- if (!(await serverLogin()).loggedIn || !cartId) return null;
149
- const answer = await serverFetch('/cart', { method: 'POST', body: { cartId } });
150
- if (answer.kind === 'logged-out') return null;
151
- const url = textOrNull(answer.data['checkoutUrl']);
152
- if (!url) return null;
153
- return `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
154
- }
155
-
156
- /**
157
- * Ends the session at the Worker, which also unlinks the cart, then goes on to Shopify's logout when the Worker says
158
- * where. Sends `tether:account-logout`.
159
- *
160
- * @param {string} returnTo
161
- * @returns {Promise<'redirected' | 'stayed'>}
162
- */
163
- export async function serverLogout(returnTo) {
164
- const answer = await serverFetch('/logout', { method: 'POST', body: { returnTo, cartId: Cart.id() } });
165
- known = null;
166
- setLoginHint(false);
167
- emit('account-logout', {});
168
- const logoutUrl = answer.kind === 'answer' ? textOrNull(answer.data['logoutUrl']) : null;
169
- if (!logoutUrl) return 'stayed';
170
- navigate(logoutUrl);
171
- return 'redirected';
172
- }
@@ -1,111 +0,0 @@
1
- /**
2
- * What both account modes share: the storage keys, the redirect to Shopify's pages, the login language, and reading
3
- * the JSON that Shopify and the Worker answer with. Storage throws in private mode and where the visitor blocks site
4
- * data; a login then lasts as long as the page.
5
- */
6
- import { cfg } from '../config.js';
7
-
8
- /** @typedef {'session' | 'local'} StorageArea */
9
-
10
- /** @returns {string} The browser-mode session's key. */
11
- export const sessionKey = () => `${cfg.prefix}:account:${cfg.domain}`;
12
-
13
- /** @returns {string} The key of the login in progress (browser mode), between leaving for Shopify and coming back. */
14
- export const pendingLoginKey = () => `${cfg.prefix}:account-login:${cfg.domain}`;
15
-
16
- /**
17
- * Server mode's "may be logged in" mark: it makes the core load this part on the next page, and holds no secret.
18
- *
19
- * @returns {string}
20
- */
21
- const hintKey = () => `${cfg.prefix}:account-hint:${cfg.domain}`;
22
-
23
- /** @returns {StorageArea} Where the browser-mode session lives: `localStorage` with `account.remember`. */
24
- export const sessionArea = () => (cfg.account?.remember ? 'local' : 'session');
25
-
26
- /** @param {StorageArea} area */
27
- const storageOf = (area) => (area === 'local' ? window.localStorage : window.sessionStorage);
28
-
29
- /**
30
- * @param {StorageArea} area
31
- * @param {string} key
32
- * @returns {unknown} The stored JSON value; `null` without one, for text that isn't JSON, or with storage blocked.
33
- */
34
- export function readJsonIfAllowed(area, key) {
35
- try {
36
- return JSON.parse(storageOf(area).getItem(key) ?? 'null');
37
- } catch {
38
- return null;
39
- }
40
- }
41
-
42
- /**
43
- * Keeps `value` as JSON; without storage nothing is kept (see the file's comment).
44
- *
45
- * @param {StorageArea} area
46
- * @param {string} key
47
- * @param {unknown} value `null` removes the key.
48
- */
49
- export function writeJsonIfAllowed(area, key, value) {
50
- try {
51
- if (value === null) storageOf(area).removeItem(key);
52
- else storageOf(area).setItem(key, JSON.stringify(value));
53
- } catch {
54
- // Private mode or blocked storage: see the file's comment.
55
- }
56
- }
57
-
58
- /** @param {boolean} loggedIn */
59
- export function setLoginHint(loggedIn) {
60
- writeJsonIfAllowed('local', hintKey(), loggedIn ? 1 : null);
61
- }
62
-
63
- /**
64
- * @param {unknown} value
65
- * @returns {value is Record<string, unknown>}
66
- */
67
- export const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
68
-
69
- /**
70
- * @param {unknown} value
71
- * @returns {string | null}
72
- */
73
- export const textOrNull = (value) => (typeof value === 'string' ? value : null);
74
-
75
- /**
76
- * An error page that isn't JSON reads as an empty answer: the response's status then says what went wrong.
77
- *
78
- * @param {Response} response
79
- * @returns {Promise<Record<string, unknown>>}
80
- */
81
- export async function readJsonObjectOrEmpty(response) {
82
- /** @type {unknown} */
83
- let json;
84
- try {
85
- json = await response.json();
86
- } catch {
87
- return {};
88
- }
89
- return isRecord(json) ? json : {};
90
- }
91
-
92
- /**
93
- * Goes to one of Shopify's (or the Worker's) pages, through `account.navigate` when the site replaces the redirect.
94
- *
95
- * @param {string} url
96
- */
97
- export function navigate(url) {
98
- const replacement = cfg.account?.navigate;
99
- if (replacement) replacement(url);
100
- else window.location.assign(url);
101
- }
102
-
103
- /**
104
- * @param {string | undefined} locale The caller's choice.
105
- * @returns {string} The language of Shopify's login page: the caller's, `account.locale`, else the site's; `''` for
106
- * Shopify's default.
107
- */
108
- export const loginLanguage = (locale) => locale || cfg.account?.locale || (cfg.locale || '').split('-')[0] || '';
109
-
110
- /** @returns {string} The market's country for Shopify's login page, `''` without one. */
111
- export const loginCountry = () => (cfg.country ? cfg.country.toUpperCase() : '');
@@ -1,150 +0,0 @@
1
- /**
2
- * Shopify's side of the browser-mode login: the OAuth and Customer Account API endpoints, found through the shop's
3
- * `.well-known` documents (a shop with its own accounts domain has other ones), the token endpoint, and the check of
4
- * the id token it answers with.
5
- */
6
- import { cfg } from '../config.js';
7
- import { TetherError } from '../client.js';
8
- import { fromBase64Url } from '../../server/src/shared/base64url.js';
9
- import { isRecord, readJsonObjectOrEmpty, textOrNull } from './shared.js';
10
-
11
- /**
12
- * @typedef {object} Endpoints
13
- * @property {string} issuer The id token's `iss`.
14
- * @property {string} authorizationEndpoint
15
- * @property {string} tokenEndpoint
16
- * @property {string} endSessionEndpoint
17
- * @property {string} graphql The Customer Account API.
18
- */
19
- /**
20
- * @typedef {object} TokenAnswer
21
- * @property {string} access
22
- * @property {string | null} refresh Left out of some refresh answers.
23
- * @property {string | null} idToken Left out of refresh answers.
24
- * @property {number} expiresInSeconds
25
- */
26
-
27
- /** Shopify's access tokens last an hour; an answer without `expires_in` is taken to say so. */
28
- const DEFAULT_TOKEN_SECONDS = 3600;
29
-
30
- /** @type {Endpoints | null} */
31
- let endpoints = null;
32
-
33
- /**
34
- * @param {string} document `openid-configuration` or `customer-account-api`.
35
- * @returns {Promise<Record<string, unknown>>}
36
- */
37
- async function wellKnown(document) {
38
- const shop = cfg.account?.shopDomain || cfg.domain;
39
- const response = await fetch(`https://${shop}/.well-known/${document}`);
40
- if (!response.ok) {
41
- throw new TetherError(`Customer accounts: ${document} answered ${response.status}`, { status: response.status });
42
- }
43
- const json = /** @type {unknown} */ (await response.json());
44
- return isRecord(json) ? json : {};
45
- }
46
-
47
- /**
48
- * @param {Record<string, unknown>} json
49
- * @param {string} name
50
- * @returns {string}
51
- * @throws {TetherError} When Shopify's document lacks the endpoint.
52
- */
53
- function endpointIn(json, name) {
54
- const url = textOrNull(json[name]);
55
- if (!url) throw new TetherError(`Customer accounts: Shopify named no ${name}`);
56
- return url;
57
- }
58
-
59
- /**
60
- * @returns {Promise<Endpoints>} The shop's endpoints, asked once per page.
61
- * @throws {TetherError} When Shopify's documents can't be read.
62
- */
63
- export async function discover() {
64
- if (endpoints) return endpoints;
65
- const [openid, api] = await Promise.all([wellKnown('openid-configuration'), wellKnown('customer-account-api')]);
66
- endpoints = {
67
- issuer: endpointIn(openid, 'issuer'),
68
- authorizationEndpoint: endpointIn(openid, 'authorization_endpoint'),
69
- tokenEndpoint: endpointIn(openid, 'token_endpoint'),
70
- endSessionEndpoint: endpointIn(openid, 'end_session_endpoint'),
71
- graphql: endpointIn(api, 'graphql_api'),
72
- };
73
- return endpoints;
74
- }
75
-
76
- /**
77
- * A token request, as a "simple" request (a form body, no custom header) so the browser sends it without a CORS
78
- * preflight. `account.tokenUrl` replaces Shopify's endpoint for a browser that can't reach it; it must be the shop's
79
- * own first-party endpoint passing the form on to Shopify, because the id token in the answer is trusted unsigned
80
- * (`idTokenClaims`).
81
- *
82
- * @param {Record<string, string>} params
83
- * @returns {Promise<TokenAnswer>}
84
- * @throws {TetherError} `SESSION_ENDED` when Shopify refuses the code or refresh token (400, 401), else `TOKEN_FAILED`.
85
- */
86
- export async function requestToken(params) {
87
- const { tokenEndpoint } = await discover();
88
- const response = await fetch(cfg.account?.tokenUrl || tokenEndpoint, {
89
- method: 'POST',
90
- body: new URLSearchParams(params),
91
- });
92
- const data = await readJsonObjectOrEmpty(response);
93
- const access = textOrNull(data['access_token']);
94
- if (!response.ok || !access) {
95
- const ended = response.status === 400 || response.status === 401;
96
- throw new TetherError(`Customer accounts: token ${response.status} ${textOrNull(data['error']) ?? ''}`.trim(), {
97
- status: response.status,
98
- code: ended ? 'SESSION_ENDED' : 'TOKEN_FAILED',
99
- });
100
- }
101
- return {
102
- access,
103
- refresh: textOrNull(data['refresh_token']) || null,
104
- idToken: textOrNull(data['id_token']) || null,
105
- expiresInSeconds: Number(data['expires_in']) || DEFAULT_TOKEN_SECONDS,
106
- };
107
- }
108
-
109
- /**
110
- * The id token's claims. Its signature isn't checked: the token comes straight from Shopify's token endpoint over
111
- * TLS, in answer to this browser's own code and verifier, so nobody else could have written it. That holds only while
112
- * `account.tokenUrl` (when set) is the shop's own first-party endpoint passing the request on to Shopify (see
113
- * `requestToken`).
114
- *
115
- * @param {string} idToken
116
- * @returns {Record<string, unknown> | null}
117
- */
118
- function idTokenClaims(idToken) {
119
- const payload = idToken.split('.')[1];
120
- if (!payload) return null;
121
- /** @type {unknown} */
122
- let claims;
123
- try {
124
- claims = JSON.parse(new TextDecoder().decode(fromBase64Url(payload)));
125
- } catch {
126
- // Not base64url JSON: not an id token, which the caller refuses.
127
- return null;
128
- }
129
- return isRecord(claims) ? claims : null;
130
- }
131
-
132
- /**
133
- * @param {string | null} idToken
134
- * @param {{ nonce: string, issuer: string, client: string }} expected The login's nonce, Shopify's issuer, our client.
135
- * @returns {boolean} True when the id token answers this login: the expected claims, and not expired.
136
- */
137
- export function answersLogin(idToken, { nonce, issuer, client }) {
138
- const claims = idToken ? idTokenClaims(idToken) : null;
139
- if (!claims) return false;
140
- const audience = claims['aud'];
141
- const audiences = Array.isArray(audience) ? audience : [audience];
142
- const expiresAt = claims['exp'];
143
- return (
144
- claims['nonce'] === nonce &&
145
- claims['iss'] === issuer &&
146
- audiences.includes(client) &&
147
- typeof expiresAt === 'number' &&
148
- expiresAt * 1000 >= Date.now()
149
- );
150
- }