@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
package/src/index.ts ADDED
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Tether's entry: `window.Tether`, `init()`, `mount()` and the start of the plain `<script>` setup.
3
+ *
4
+ * <script>window.TETHER_CONFIG = { domain: 'x.myshopify.com', token: '...' }</script>
5
+ * <script src="tether.min.js" defer></script>
6
+ *
7
+ * With page transitions (Barba, Swup): set `autoMount: false`, call `Tether.init()` once and
8
+ * `Tether.mount(container)` after every page enter.
9
+ */
10
+ import { cfg, configView, configure, isConfigured, log, warn, reportError } from './config.ts';
11
+ import { query, TetherError, withContext } from './client.ts';
12
+ import { Cart } from './cart.ts';
13
+ import { scopes } from './product.ts';
14
+ import { ListScope, listSelector } from './collection.ts';
15
+ import {
16
+ getProduct,
17
+ getProducts,
18
+ getRecommendations,
19
+ clearProductCache,
20
+ primeProduct,
21
+ normalizeProductKey,
22
+ } from './loader.ts';
23
+ import * as plans from './selling-plans.ts';
24
+ import { resolveProductKey } from './status.ts';
25
+ import { sel, emit, isOwnEvent, ANALYTICS_EVENTS, formatMoney, imageUrl, pageGlobal } from './dom.ts';
26
+ import { loadPart, coreModules } from './lazy.ts';
27
+ import {
28
+ mountedLists,
29
+ liveLists,
30
+ pruneLive,
31
+ within,
32
+ mountProduct,
33
+ hydrateFromPage,
34
+ settleMarket,
35
+ trackMarket,
36
+ } from './mounting.ts';
37
+ import { applyStartMarket, setCountry } from './page-market.ts';
38
+ import { accountApi, mountAccountIfUsed } from './account-api.ts';
39
+ import { forgetBrowserModeTokens } from './account-marks.ts';
40
+ import { bindCartControls } from './controls/cart-controls.ts';
41
+ import { bindDiscountControls } from './controls/discount.ts';
42
+ import { addGroup, bindExtrasControls } from './controls/extras.ts';
43
+ import { bindNavigation, rememberDocumentMount } from './controls/navigation.ts';
44
+ import { field } from '../server/src/shared/json.ts';
45
+ import type { TetherConfig, TetherOptions, MarketsConfig } from './config.ts';
46
+ import type { ShopifyCart } from './cart/store.ts';
47
+ import type { ProductScope } from './product.ts';
48
+ import type { DiagnoseOptions } from './diagnose.ts';
49
+ import type { TetherEvents } from './markup.ts';
50
+ import type { TetherInternals } from './lazy.ts';
51
+
52
+ export { addGroup, setCountry };
53
+
54
+ // The types a site uses by name, under the names the docs give them.
55
+ export type { TetherOptions as TetherConfig, TetherConfig as ResolvedTetherConfig } from './config.ts';
56
+ export type { MarketsConfig, CartWarning } from './config.ts';
57
+ export type { Money } from './shopify-values.ts';
58
+ export type {
59
+ Image as ShopifyImage,
60
+ Metafield as ShopifyMetafield,
61
+ Product as ShopifyProduct,
62
+ ProductVariant as ShopifyVariant,
63
+ SellingPlan as ShopifySellingPlanNode,
64
+ SellingPlanAdjustmentValue,
65
+ SellingPlanAllocation as ShopifySellingPlanAllocation,
66
+ SellingPlanGroup as ShopifySellingPlanGroup,
67
+ } from './fragments.ts';
68
+ export type {
69
+ PlanInGroup as ShopifySellingPlan,
70
+ PlanAdjustment,
71
+ ProductWithPlans,
72
+ VariantWithPlans,
73
+ } from './selling-plans.ts';
74
+ export type { CartStatus, CartVariant as ShopifyCartVariant, ShopifyCart, ShopifyCartLine } from './cart/store.ts';
75
+ export type { Attributes, LineSpec as LineInput } from './cart/mutations.ts';
76
+ export type { TetherError, TetherErrorCode, GraphQLError, UserError } from './client.ts';
77
+ export type { ProductScope } from './product.ts';
78
+ export type { Purchase } from './product-state.ts';
79
+ export type { ProductPage, ProductQuery } from './loader.ts';
80
+ export type { DetectedMarket, Localization, MarketSource } from './markets.ts';
81
+ export type { SetCountryOptions } from './page-market.ts';
82
+ export type { DiagnoseOptions, DiagnoseReport } from './diagnose.ts';
83
+ export type { TetherEvents } from './markup.ts';
84
+ export type { AccountCustomer } from './account/answers.ts';
85
+ export type { AccountImage as ImageRef, AccountOrder, AccountOrderLine } from '../server/src/shared/account-orders.ts';
86
+ export type { AddressFields as CustomerAddressInput } from './account/operations.ts';
87
+ export type {
88
+ CustomerAddress,
89
+ Fulfillment,
90
+ MailingAddress,
91
+ OrderDetail,
92
+ OrderDetailLine,
93
+ SubscriptionContract,
94
+ SubscriptionLine,
95
+ } from './account/page-answers.ts';
96
+ /** The helpers for selling plans, `Tether.plans`. */
97
+ export type SellingPlanHelpers = typeof plans;
98
+
99
+ export const VERSION = '0.7.0';
100
+
101
+ /** Milliseconds `observe` waits after the last added node, so a burst of additions mounts once. */
102
+ const OBSERVE_DEBOUNCE_MS = 50;
103
+
104
+ /** @returns The keys of `window.TETHER_CONFIG`, none when it is no object. */
105
+ function pageConfig(): Readonly<Record<string, unknown>> {
106
+ const config = pageGlobal('TETHER_CONFIG');
107
+ return typeof config === 'object' && config !== null ? Object.fromEntries(Object.entries(config)) : {};
108
+ }
109
+
110
+ let initPromise: Promise<Tether | null> | null = null;
111
+
112
+ let observer: MutationObserver | null = null;
113
+
114
+ /** The analytics part's queue, created once by the first `init()` with `analytics`. */
115
+ let analyticsQueue: Event[] | null = null;
116
+
117
+ /** The document-wide listeners are bound once, by the first configured `init()`. */
118
+ let controlsBound = false;
119
+
120
+ /**
121
+ * Mounts the lists of `root` and the product scopes in them as they render.
122
+ *
123
+ * @returns The lists mounted.
124
+ */
125
+ function mountLists(root: ParentNode): number {
126
+ let count = 0;
127
+ within(root, listSelector()).forEach((el) => {
128
+ if (mountedLists.has(el)) return;
129
+ mountedLists.add(el);
130
+ count++;
131
+ const list = new ListScope(el);
132
+ liveLists.add(list);
133
+ el.addEventListener(`${cfg.prefix}:list-rendered`, (event) => {
134
+ // The list emits this once its products are in the page, with the element they are in.
135
+ if (isOwnEvent(event, 'list-rendered')) mount(event.detail.container);
136
+ });
137
+ void trackMarket(list.load());
138
+ });
139
+ return count;
140
+ }
141
+
142
+ /**
143
+ * Loads each optional part `root` uses: the country select, customer accounts and search as you type.
144
+ */
145
+ function mountParts(root: ParentNode): void {
146
+ if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
147
+ loadPart('markets')
148
+ .then((part) => part.renderCountrySelects(root))
149
+ .catch((error: unknown) => {
150
+ reportError(error, 'markets');
151
+ });
152
+ }
153
+ mountAccountIfUsed(root);
154
+ if (within(root, sel('search')).length) {
155
+ loadPart('search')
156
+ .then((part) => {
157
+ part.mountSearch(root);
158
+ })
159
+ .catch((error: unknown) => {
160
+ reportError(error, 'search');
161
+ });
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Mounts the lists, product scopes and parts inside `root`, after its hydrate tags were read (products the server
167
+ * rendered into the page need no request), without the page-wide work that follows a mount.
168
+ *
169
+ * @returns The scopes and lists newly mounted.
170
+ */
171
+ function mountWithin(root: Document | Element): number {
172
+ let count = mountLists(root);
173
+ within(root, sel('product')).forEach((el) => {
174
+ if (mountProduct(el)) count++;
175
+ });
176
+ mountParts(root);
177
+ return count;
178
+ }
179
+
180
+ /**
181
+ * Mounts every product scope and product list inside `root`. Safe to call repeatedly.
182
+ *
183
+ * @returns The scopes and lists newly mounted.
184
+ */
185
+ export function mount(root: Document | Element = document): number {
186
+ if (!isConfigured()) return 0;
187
+ if (root === document) rememberDocumentMount();
188
+ // Page transitions detach old roots: they aren't kept alive.
189
+ pruneLive();
190
+ hydrateFromPage(root.ownerDocument ?? document);
191
+ const count = mountWithin(root);
192
+ settleMarket();
193
+ Cart.render();
194
+ log(`mounted ${count} scope(s)`, root);
195
+ return count;
196
+ }
197
+
198
+ /**
199
+ * `observe`: mounts a burst of added elements at once. Each root is scanned on its own (an element inside another
200
+ * added one is covered by it), and the page-wide work (market state, the cart's markup) runs once for the burst.
201
+ */
202
+ function mountAdded(added: readonly Element[]): void {
203
+ const roots = added.filter(
204
+ (root) => root.isConnected && !added.some((other) => other !== root && other.contains(root)),
205
+ );
206
+ if (!roots.length || !isConfigured()) return;
207
+ pruneLive();
208
+ // Only the added elements are read for hydrate tags: the rest of the page was read when it was mounted. A load-more
209
+ // fragment may bring its tag after the cards it is for, so every tag is read before any card mounts.
210
+ roots.forEach((root) => {
211
+ hydrateFromPage(root);
212
+ });
213
+ const count = roots.reduce((sum, root) => sum + mountWithin(root), 0);
214
+ settleMarket();
215
+ Cart.render();
216
+ log(`mounted ${count} scope(s) in ${roots.length} added element(s)`);
217
+ }
218
+
219
+ /** Call before a page transition removes the page: closes the cart drawer. */
220
+ export function unmount(): void {
221
+ Cart.close();
222
+ }
223
+
224
+ /** @returns The product scope `el` is in. */
225
+ export function getScope(el: Element | null | undefined): ProductScope | null {
226
+ const root = el?.closest(sel('product'));
227
+ return root ? (scopes.get(root) ?? null) : null;
228
+ }
229
+
230
+ function bindControls() {
231
+ if (controlsBound) return;
232
+ controlsBound = true;
233
+ bindCartControls();
234
+ bindDiscountControls();
235
+ bindExtrasControls();
236
+ bindNavigation({ ready: () => initPromise ?? Promise.resolve(null), mount, unmount });
237
+ }
238
+
239
+ /** Buffers Tether's events until the analytics part has loaded, so none are missed. */
240
+ function startAnalyticsQueue() {
241
+ const queue: Event[] = [];
242
+ analyticsQueue = queue;
243
+ ANALYTICS_EVENTS.forEach((name) => {
244
+ document.addEventListener(`${cfg.prefix}:${name}`, (event) => queue.push(event));
245
+ });
246
+ document.addEventListener('astro:page-load', (event) => queue.push(event));
247
+ loadPart('analytics')
248
+ .then((part) => part.startAnalytics(queue))
249
+ .catch((error: unknown) => {
250
+ reportError(error, 'analytics');
251
+ });
252
+ }
253
+
254
+ /**
255
+ * A saved cart from another market moves to this one, so its prices match the page's.
256
+ */
257
+ async function cartInPageMarket(cart: ShopifyCart | null): Promise<ShopifyCart | null> {
258
+ const country = cfg.country;
259
+ const cartCountry = cart?.buyerIdentity.countryCode;
260
+ if (!cart || !country || !cartCountry || cartCountry === country) return cart;
261
+ try {
262
+ return await Cart.setBuyerIdentity({ countryCode: country }, { create: false });
263
+ } catch (error) {
264
+ reportError(error, 'cart country');
265
+ return cart;
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Configures Tether (`window.TETHER_CONFIG` with `overrides` over it), applies the visitor's market and loads the
271
+ * cart. A second call without overrides returns the first one's promise.
272
+ *
273
+ * @returns `null` without a domain and token, when Tether stays idle.
274
+ */
275
+ export function init(overrides: TetherOptions = {}): Promise<Tether | null> {
276
+ if (initPromise && !Object.keys(overrides).length) return initPromise;
277
+ configure({ ...pageConfig(), ...overrides });
278
+ if (!isConfigured()) {
279
+ warn('TETHER_CONFIG is missing domain or token. Tether is idle.');
280
+ initPromise = Promise.resolve(null);
281
+ return initPromise;
282
+ }
283
+ forgetBrowserModeTokens();
284
+ bindControls();
285
+ if (cfg.observe) startObserver();
286
+ if (cfg.analytics && !analyticsQueue) startAnalyticsQueue();
287
+ initPromise = applyStartMarket(overrides)
288
+ .then(() => Cart.init())
289
+ .then(cartInPageMarket)
290
+ .then((cart) => {
291
+ emit('ready', { cart });
292
+ return api;
293
+ });
294
+ return initPromise;
295
+ }
296
+
297
+ /** `observe`: mounts the elements added to the page later. */
298
+ function startObserver() {
299
+ if (observer || typeof MutationObserver === 'undefined') return;
300
+ let timer: ReturnType<typeof setTimeout> | undefined;
301
+ const queued = new Set<Element>();
302
+ observer = new MutationObserver((mutations) => {
303
+ mutations.forEach((mutation) => {
304
+ mutation.addedNodes.forEach((node) => {
305
+ if (node instanceof window.Element) queued.add(node);
306
+ });
307
+ });
308
+ clearTimeout(timer);
309
+ timer = setTimeout(() => {
310
+ const added = [...queued];
311
+ queued.clear();
312
+ mountAdded(added);
313
+ }, OBSERVE_DEBOUNCE_MS);
314
+ });
315
+ observer.observe(document.body, { childList: true, subtree: true });
316
+ }
317
+
318
+ /** @param markets Default `markets` of the config. */
319
+ const detectMarket = (markets?: MarketsConfig | null) =>
320
+ loadPart('markets').then((part) => part.detectMarket(markets ?? cfg.markets));
321
+
322
+ const getLocalization = () => loadPart('markets').then((part) => part.getLocalization());
323
+
324
+ const diagnose = (options?: Omit<DiagnoseOptions, 'version'>) =>
325
+ loadPart('diagnose').then((part) => part.diagnose({ ...options, version: VERSION }));
326
+
327
+ /**
328
+ * Listens to the Tether event `<prefix>:<name>`. Only the events Tether dispatched reach the listener: one another
329
+ * script dispatches under the same name is ignored, so the detail is always the one `TetherEvents` gives.
330
+ *
331
+ * @returns Stops listening.
332
+ */
333
+ function on<Name extends keyof TetherEvents>(
334
+ name: Name,
335
+ listener: (detail: TetherEvents[Name], event: CustomEvent<TetherEvents[Name]>) => void,
336
+ ): () => void {
337
+ const type = `${cfg.prefix}:${name}`;
338
+ const handler = (event: Event) => {
339
+ if (isOwnEvent(event, name)) listener(event.detail, event);
340
+ };
341
+ document.addEventListener(type, handler);
342
+ return () => {
343
+ document.removeEventListener(type, handler);
344
+ };
345
+ }
346
+
347
+ const publicApi = {
348
+ VERSION,
349
+ init,
350
+ mount,
351
+ unmount,
352
+ /** The live config, read-only. */
353
+ get config(): Readonly<TetherConfig> {
354
+ return configView;
355
+ },
356
+ get ready(): Promise<Tether | null> {
357
+ return initPromise ?? Promise.resolve(null);
358
+ },
359
+ query,
360
+ withContext,
361
+ cart: Cart,
362
+ getProduct,
363
+ getProducts,
364
+ getRecommendations,
365
+ getScope,
366
+ addGroup,
367
+ account: accountApi,
368
+ primeProduct,
369
+ normalizeProductKey,
370
+ clearProductCache,
371
+ formatMoney,
372
+ imageUrl,
373
+ plans,
374
+ TetherError,
375
+ resolveProductKey,
376
+ setCountry,
377
+ detectMarket,
378
+ getLocalization,
379
+ diagnose,
380
+ on,
381
+ };
382
+
383
+ /** `window.Tether`, and what `import Tether from '@occluze/tether'` gives. */
384
+ export type Tether = typeof publicApi;
385
+
386
+ const internals: TetherInternals = { __core: coreModules, __parts: {} };
387
+
388
+ /** `Tether`, also carrying `TetherInternals`, which the published type leaves out. */
389
+ export const api: Tether = Object.assign(publicApi, internals);
390
+
391
+ /** @alias `api`, for `import Tether from '@occluze/tether'`. */
392
+ export default api;
393
+
394
+ /**
395
+ * The plain `<script>` setup starts by itself, unless `autoMount: false` or a framework integration (Astro), which
396
+ * starts Tether itself.
397
+ */
398
+ function autoStart() {
399
+ // `window.Tether` is declared by the published types, which describe this object to sites; the source doesn't
400
+ // depend on them.
401
+ Reflect.set(window, 'Tether', api);
402
+ const config = pageGlobal('TETHER_CONFIG');
403
+ if (!config || field(config, 'autoMount') === false || pageGlobal('TETHER_INTEGRATION')) return;
404
+ const start = () => {
405
+ init()
406
+ .then(() => mount(document))
407
+ .catch((error: unknown) => {
408
+ reportError(error, 'init');
409
+ });
410
+ };
411
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
412
+ else start();
413
+ }
414
+
415
+ if (typeof window !== 'undefined' && typeof document !== 'undefined') autoStart();
package/src/initial.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What the browser script shows first for a product, as plain data, so a server renders the same and nothing flashes
3
+ * when Tether takes over. Public as `initialState()` of `@occluze/tether/server`; the rules are `product-state.ts`'s,
4
+ * the ones the browser scope uses.
5
+ */
6
+ import { DEFAULTS } from './config.ts';
7
+ import { TetherError } from './client.ts';
8
+ import { initialChoice, isValueAvailable, productView } from './product-state.ts';
9
+ import { allPlans, allocationFor, planPrice, planSavings } from './selling-plans.ts';
10
+ import type { Money } from './shopify-values.ts';
11
+ import type { Product, ProductVariant } from './fragments.ts';
12
+ import type { PlanInGroup } from './selling-plans.ts';
13
+ import type { Selection, ShowCondition } from './product-state.ts';
14
+
15
+ export interface InitialOptions {
16
+ /** The page's `?variant=`; the first variant for sale without. */
17
+ variant?: string | null | undefined;
18
+ /** The page's `?selling_plan=`: subscribes on that plan. */
19
+ sellingPlan?: string | null | undefined;
20
+ /** `subscription` subscribes on the first plan. */
21
+ defaultPurchase?: string | null | undefined;
22
+ /** `low-stock` at this many or fewer. */
23
+ lowStock?: number | undefined;
24
+ }
25
+ /** A plan in the plan list (`tether-plan-list`). */
26
+ interface InitialPlan {
27
+ id: string;
28
+ name: string;
29
+ /** On this plan, for the variant. */
30
+ price: Money | null;
31
+ /** In %, 0 without savings. */
32
+ savings: number;
33
+ /** `is-selected`: the line is bought on this plan. */
34
+ selected: boolean;
35
+ /** The variant is on this plan; `is-unavailable` when not. */
36
+ available: boolean;
37
+ }
38
+ export interface InitialState {
39
+ variant: ProductVariant;
40
+ /** Option name → value of the variant: `is-selected` on those buttons. */
41
+ selected: Selection;
42
+ purchase: 'one-time' | 'subscription';
43
+ /** The plan the line is bought on, `null` when bought once. */
44
+ plan: PlanInGroup | null;
45
+ plans: InitialPlan[];
46
+ /** The offered plan's savings in % (`tether-bind="subscription-savings"`), 0 without. */
47
+ savings: number;
48
+ price: Money;
49
+ /** Only when on sale. */
50
+ compareAtPrice: Money | null;
51
+ available: boolean;
52
+ /** The `tether-show` conditions: `hidden={!state.show['out-of-stock']}`. */
53
+ show: Record<ShowCondition, boolean>;
54
+ /**
55
+ * Per option, the values without a variant for sale (given the other selected values), as plain data for
56
+ * frameworks that serialise props.
57
+ */
58
+ unavailable: Record<string, string[]>;
59
+ /** The same as a function: `is-unavailable`. */
60
+ isUnavailable: (name: string, value: string) => boolean;
61
+ }
62
+
63
+ /** @throws TetherError For a product without variants, which Shopify never sends: every product has one. */
64
+ export function initialState(product: Product, options?: InitialOptions): InitialState;
65
+ /** @returns `null` without a product. */
66
+ export function initialState(product: Product | null | undefined, options?: InitialOptions): InitialState | null;
67
+ export function initialState(product: Product | null | undefined, options: InitialOptions = {}): InitialState | null {
68
+ if (!product) return null;
69
+ const { variant, sellingPlan, defaultPurchase = DEFAULTS.defaultPurchase, lowStock = DEFAULTS.lowStock } = options;
70
+ const choice = initialChoice(product, { variant, sellingPlan, defaultPurchase });
71
+ const view = productView(product, { ...choice, quantity: 1 }, lowStock);
72
+ if (!view) throw new TetherError(`Product ${product.handle} has no variants`);
73
+ const { selected, line } = view;
74
+ const isUnavailable = (name: string, value: string) => !isValueAvailable(product, selected, name, value);
75
+ return {
76
+ variant: view.variant,
77
+ selected,
78
+ purchase: view.purchase.kind,
79
+ plan: line.kind === 'subscription' ? view.plan : null,
80
+ plans: allPlans(product).map((plan) => ({
81
+ id: plan.id,
82
+ name: plan.name,
83
+ price: planPrice(view.variant, plan.id),
84
+ savings: planSavings(view.variant, plan.id).percentage,
85
+ selected: line.kind === 'subscription' && plan.id === line.planId,
86
+ available: Boolean(allocationFor(view.variant, plan.id)),
87
+ })),
88
+ savings: view.savings.percentage,
89
+ price: view.price,
90
+ compareAtPrice: view.compareAtPrice,
91
+ available: view.variant.availableForSale,
92
+ show: view.show,
93
+ unavailable: Object.fromEntries(
94
+ product.options.map((option) => [
95
+ option.name,
96
+ option.optionValues.map((value) => value.name).filter((value) => isUnavailable(option.name, value)),
97
+ ]),
98
+ ),
99
+ isUnavailable,
100
+ };
101
+ }