@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
@@ -3,58 +3,75 @@
3
3
  * (`window.Shopify.analytics`) and loads Shopify's consent script and shopify.js, which build and send the events.
4
4
  * Nothing is delivered before the Customer Privacy API allows analytics.
5
5
  */
6
- import { cfg, log, reportError } from './config.js';
7
- import { gql } from './client.js';
8
-
9
- /** @typedef {Record<string, unknown>} EventPayload */
10
- /** @typedef {{ uniqueToken: string, visitToken: string }} TrackingValues */
11
- /** @typedef {{ getTrackingValues: () => TrackingValues }} DestinationContext */
12
- /** @typedef {(payload: EventPayload, context: DestinationContext) => void} Subscriber */
13
- /** @typedef {(event: string, subscriber: Subscriber) => () => void} Subscribe */
14
- /** @typedef {{ shop: { shopId: string, channel: 'headless' }, consent: { mode: string } }} BusConfig */
15
- /**
16
- * @typedef {object} DestinationSpec What shopify.js registers.
17
- * @property {string} name
18
- * @property {(api: { subscribe: Subscribe, getConfig: () => BusConfig }) => unknown} [setup]
19
- */
20
- /**
21
- * @typedef {object} AnalyticsBus Hydrogen's `window.Shopify.analytics`.
22
- * @property {() => BusConfig} getConfig
23
- * @property {(event: string, payload?: EventPayload) => void} publish
24
- * @property {(destination: DestinationSpec) => () => void} addDestination
25
- */
6
+ import { cfg, log, reportError } from './config.ts';
7
+ import { gql, shaped } from './client.ts';
8
+ import type { AnalyticsConfig } from './config.ts';
26
9
 
27
- /**
28
- * The parts of Shopify's Customer Privacy API (`window.Shopify.customerPrivacy`) that decide tracking.
29
- *
30
- * @typedef {object} CustomerPrivacy
31
- * @property {string} [consentStatus]
32
- * @property {() => { analytics?: string } | undefined} [currentVisitorConsent]
33
- * @property {() => boolean} [analyticsProcessingAllowed]
34
- * @property {{ uniqueToken?: TokenReader, visitToken?: TokenReader }} [__internal]
35
- * @property {object} [config]
36
- */
37
- /** @typedef {(options: { generateFallback: boolean, tag: string }) => string} TokenReader */
38
- /**
39
- * `window.Shopify`, which Shopify's scripts read and extend.
40
- *
41
- * @typedef {object} ShopifyGlobal
42
- * @property {AnalyticsBus} [analytics]
43
- * @property {CustomerPrivacy} [customerPrivacy]
44
- * @property {string} [country]
45
- * @property {string} [locale]
46
- * @property {string} [shop]
47
- * @property {object} [routes]
48
- */
10
+ export type EventPayload = Record<string, unknown>;
49
11
 
50
- /** @typedef {{ event: string, payload: EventPayload }} BufferedEvent */
51
- /**
52
- * @typedef {object} Destination
53
- * @property {string} name
54
- * @property {number} next The index of the next buffered event it gets.
55
- * @property {Map<string, Set<Subscriber>>} subscribers
56
- * @property {DestinationContext} context
57
- */
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
+ }
58
75
 
59
76
  /** Events kept for destinations that register late (shopify.js loads after the first page view). */
60
77
  const BUFFER_SIZE = 500;
@@ -64,15 +81,13 @@ const CONSENT_SCRIPTS = {
64
81
  };
65
82
  const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
66
83
 
67
- /** @returns {ShopifyGlobal | undefined} `window.Shopify`, whose shape Shopify's scripts define. */
68
- function shopifyGlobal() {
69
- /** @type {unknown} */
70
- const shopify = Reflect.get(window, 'Shopify');
71
- return /** @type {ShopifyGlobal | undefined} */ (shopify);
72
- }
84
+ /** The page, where Shopify's scripts keep `window.Shopify` in the shape they define. */
85
+ const page = (): Window & { Shopify?: ShopifyGlobal } => window;
73
86
 
74
- /** @returns {{ consent?: string, proxy?: string }} The `analytics.shopify` options; `true` sets none. */
75
- function shopifyOptions() {
87
+ const shopifyGlobal = (): ShopifyGlobal | undefined => page().Shopify;
88
+
89
+ /** The `analytics.shopify` options; `true` sets none. */
90
+ function shopifyOptions(): Exclude<NonNullable<AnalyticsConfig['shopify']>, boolean> {
76
91
  const shopify = cfg.analytics?.shopify;
77
92
  return typeof shopify === 'object' ? shopify : {};
78
93
  }
@@ -80,10 +95,8 @@ function shopifyOptions() {
80
95
  /**
81
96
  * Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
82
97
  * allowed", so nothing is sent without consent; the next consent event asks again.
83
- *
84
- * @returns {boolean}
85
98
  */
86
- function trackingAllowed() {
99
+ function trackingAllowed(): boolean {
87
100
  try {
88
101
  const privacy = shopifyGlobal()?.customerPrivacy;
89
102
  return (
@@ -96,11 +109,8 @@ function trackingAllowed() {
96
109
  }
97
110
  }
98
111
 
99
- /**
100
- * @param {string} name The destination's name, Hydrogen tags its tokens with it.
101
- * @returns {DestinationContext}
102
- */
103
- function destinationContext(name) {
112
+ /** @param name The destination's name, Hydrogen tags its tokens with it. */
113
+ function destinationContext(name: string): DestinationContext {
104
114
  const tag = `hydrogen:${name}`;
105
115
  return {
106
116
  getTrackingValues: () => {
@@ -113,28 +123,20 @@ function destinationContext(name) {
113
123
  };
114
124
  }
115
125
 
116
- /**
117
- * @param {Destination} destination
118
- * @returns {Subscribe}
119
- */
120
- const subscriberOf = (destination) => (event, subscriber) => {
121
- const subscribers = destination.subscribers.get(event) ?? new Set();
122
- destination.subscribers.set(event, subscribers);
123
- subscribers.add(subscriber);
124
- return () => destination.subscribers.get(event)?.delete(subscriber);
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
+ };
126
134
 
127
- /**
128
- * @param {string} shopId
129
- * @returns {AnalyticsBus} A bus that buffers events and delivers them to each destination once consent allows.
130
- */
131
- function createBus(shopId) {
132
- /** @type {Destination[]} */
133
- const destinations = [];
134
- /** @type {BufferedEvent[]} */
135
- const buffer = [];
136
- /** @type {BusConfig} */
137
- const config = {
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 = {
138
140
  shop: { shopId, channel: 'headless' },
139
141
  consent: { mode: shopifyOptions().consent || 'no-banner' },
140
142
  };
@@ -147,8 +149,7 @@ function createBus(shopId) {
147
149
  }
148
150
  }
149
151
  };
150
- /** @type {AnalyticsBus} */
151
- const bus = {
152
+ const bus: AnalyticsBus = {
152
153
  getConfig: () => config,
153
154
  publish(event, payload = {}) {
154
155
  buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
@@ -159,14 +160,18 @@ function createBus(shopId) {
159
160
  deliver();
160
161
  },
161
162
  addDestination(spec) {
162
- /** @type {Destination} */
163
- const destination = { name: spec.name, next: 0, subscribers: new Map(), context: destinationContext(spec.name) };
163
+ const destination: Destination = {
164
+ name: spec.name,
165
+ next: 0,
166
+ subscribers: new Map(),
167
+ context: destinationContext(spec.name),
168
+ };
164
169
  Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
165
170
  .then(() => {
166
171
  destinations.push(destination);
167
172
  deliver();
168
173
  })
169
- .catch((/** @type {unknown} */ error) => {
174
+ .catch((error: unknown) => {
170
175
  reportError(error, `analytics ${spec.name}`);
171
176
  });
172
177
  return () => {
@@ -180,11 +185,7 @@ function createBus(shopId) {
180
185
  return bus;
181
186
  }
182
187
 
183
- /**
184
- * @param {Destination} destination
185
- * @param {BufferedEvent} buffered
186
- */
187
- function deliverTo(destination, { event, payload }) {
188
+ function deliverTo(destination: Destination, { event, payload }: BufferedEvent): void {
188
189
  destination.subscribers.get(event)?.forEach((subscriber) => {
189
190
  try {
190
191
  subscriber(payload, destination.context);
@@ -194,8 +195,7 @@ function deliverTo(destination, { event, payload }) {
194
195
  });
195
196
  }
196
197
 
197
- /** @param {string} src */
198
- function loadScript(src) {
198
+ function loadScript(src: string): void {
199
199
  const script = document.createElement('script');
200
200
  script.src = src;
201
201
  script.async = true;
@@ -205,19 +205,24 @@ function loadScript(src) {
205
205
  /**
206
206
  * Sets up `window.Shopify` for Shopify's scripts and loads them.
207
207
  *
208
- * @param {() => boolean} isProductPage Asked by Shopify's script for the page template.
209
- * @returns {Promise<AnalyticsBus>} The bus, the page's own when a theme already set one up.
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
210
  */
211
- export async function startShopifyAnalytics(isProductPage) {
211
+ export async function startShopifyAnalytics(isProductPage: () => boolean): Promise<AnalyticsBus> {
212
212
  const existing = shopifyGlobal();
213
213
  if (existing?.analytics) return existing.analytics;
214
- /** @type {{ shop: { id: string } }} */
215
- const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
214
+ const data = await gql(
215
+ 'query TetherShopId { shop { id } }',
216
+ {},
217
+ shaped<{ shop: { id: string } }>({ shop: { id: 'string' } }),
218
+ { context: false },
219
+ );
216
220
  const shopify = shopifyGlobal() ?? {};
217
- Reflect.set(window, 'Shopify', shopify);
218
- shopify.country = (cfg.country || 'US').toUpperCase();
221
+ page().Shopify = shopify;
222
+ shopify.country = cfg.country ?? 'US';
219
223
  // Shopify's script requires a language, not a full locale.
220
- shopify.locale = (cfg.locale || 'en').split('-')[0]?.toLowerCase() ?? 'en';
224
+ const [language = 'en'] = (cfg.locale || 'en').split('-');
225
+ shopify.locale = language.toLowerCase();
221
226
  shopify.shop = cfg.domain;
222
227
  shopify.routes = {
223
228
  root: '/',
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Analytics, as an optional part (loaded when config.analytics is set).
3
+ *
4
+ * analytics: {
5
+ * dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
6
+ * shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
7
+ * onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible
8
+ * }
9
+ *
10
+ * Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart (from the cart's
11
+ * changes, so quick +/- and JS calls count too), view_cart (cart opened), begin_checkout, search (search as you type).
12
+ * Purchases happen on Shopify's checkout: use Shopify's Google & YouTube app or a checkout pixel for those.
13
+ * Shopify's analytics are set up in analytics-shopify.ts.
14
+ */
15
+ import { cfg, reportError } from './config.ts';
16
+ import { ANALYTICS_EVENTS, isOwnEvent, sel } from './dom.ts';
17
+ import { Cart } from './cart.ts';
18
+ import { scopes } from './product.ts';
19
+ import { startShopifyAnalytics } from './analytics-shopify.ts';
20
+ import type { Money } from './shopify-values.ts';
21
+ import type { Product, ProductVariant } from './fragments.ts';
22
+ import type { AnalyticsBus, EventPayload } from './analytics-shopify.ts';
23
+ import type { ProductScope } from './product.ts';
24
+ import type { AnalyticsEventName, TetherEvents } from './markup.ts';
25
+
26
+ /** The fields of the cart's product (`fragment TetherCart`) this module reads. */
27
+ interface LineProduct {
28
+ id: string;
29
+ title: string;
30
+ handle: string;
31
+ vendor: string;
32
+ }
33
+
34
+ interface LineVariant {
35
+ id: string;
36
+ title: string;
37
+ sku: string | null;
38
+ price: Money;
39
+ product: LineProduct;
40
+ }
41
+
42
+ interface CartLine {
43
+ id: string;
44
+ quantity: number;
45
+ merchandise: LineVariant;
46
+ }
47
+
48
+ /** The fields of the cart (`fragment TetherCart`) this module reads. */
49
+ interface AnalyticsCart {
50
+ lines: { nodes: CartLine[] };
51
+ cost: { totalAmount: Money };
52
+ discountCodes: { code: string; applicable: boolean }[];
53
+ }
54
+
55
+ /** What a GA4 item is built from: a product, or a cart line's product. */
56
+ interface ItemProduct {
57
+ id: string;
58
+ title: string;
59
+ vendor?: string;
60
+ productType?: string;
61
+ priceRange?: { minVariantPrice: Money };
62
+ }
63
+
64
+ type ItemVariant = Pick<ProductVariant, 'id' | 'title' | 'sku' | 'price'>;
65
+
66
+ /** A GA4 ecommerce item. */
67
+ interface AnalyticsItem {
68
+ item_id: string;
69
+ item_name: string;
70
+ item_variant: string | undefined;
71
+ item_brand: string | undefined;
72
+ item_category: string | undefined;
73
+ price: number;
74
+ quantity: number;
75
+ index?: number;
76
+ }
77
+
78
+ /** Shopify's title for the one variant of a product without options. */
79
+ const DEFAULT_VARIANT_TITLE = 'Default Title';
80
+ const CENTS_PER_UNIT = 100;
81
+ /** GA4 drops an event with more items than this. */
82
+ const MAX_LIST_ITEMS = 200;
83
+
84
+ const amountOf = (money: Money | null | undefined): number => (money ? parseFloat(money.amount) : 0);
85
+
86
+ /** The numeric part of a gid. */
87
+ const numericId = (gid: string | null | undefined): string => (gid ?? '').split('/').pop() ?? '';
88
+
89
+ /** @param index The position in a list. */
90
+ function itemFromVariant(
91
+ product: ItemProduct,
92
+ variant: ItemVariant | null | undefined,
93
+ quantity = 1,
94
+ index?: number,
95
+ ): AnalyticsItem {
96
+ const item: AnalyticsItem = {
97
+ item_id: variant?.sku || numericId(variant?.id) || numericId(product.id),
98
+ item_name: product.title,
99
+ item_variant: variant?.title === DEFAULT_VARIANT_TITLE ? undefined : variant?.title,
100
+ item_brand: product.vendor || undefined,
101
+ item_category: product.productType || undefined,
102
+ price: amountOf(variant?.price ?? product.priceRange?.minVariantPrice),
103
+ quantity,
104
+ };
105
+ if (index != null) item.index = index;
106
+ return item;
107
+ }
108
+
109
+ const itemFromLine = (line: CartLine, quantity = line.quantity) =>
110
+ itemFromVariant(line.merchandise.product, line.merchandise, quantity);
111
+
112
+ /** The items' total, rounded to cents. */
113
+ function totalValue(items: AnalyticsItem[]): number {
114
+ const total = items.reduce((sum, item) => sum + item.price * (item.quantity || 1), 0);
115
+ return Math.round(total * CENTS_PER_UNIT) / CENTS_PER_UNIT;
116
+ }
117
+
118
+ const productPayload = (product: Product, variant: ItemVariant, quantity = 1) => ({
119
+ id: product.id,
120
+ title: product.title,
121
+ price: variant.price,
122
+ vendor: product.vendor || '',
123
+ variantId: variant.id,
124
+ variantTitle: variant.title,
125
+ quantity,
126
+ sku: variant.sku || undefined,
127
+ productType: product.productType || undefined,
128
+ });
129
+
130
+ const linePayload = (line: CartLine) => ({
131
+ id: line.id,
132
+ quantity: line.quantity,
133
+ merchandise: {
134
+ id: line.merchandise.id,
135
+ title: line.merchandise.title,
136
+ price: line.merchandise.price,
137
+ sku: line.merchandise.sku,
138
+ product: {
139
+ id: line.merchandise.product.id,
140
+ title: line.merchandise.product.title,
141
+ vendor: line.merchandise.product.vendor || '',
142
+ handle: line.merchandise.product.handle,
143
+ },
144
+ },
145
+ });
146
+
147
+ /** The GA4 / GTM data layer, created when the page has none. */
148
+ function dataLayer(): unknown[] {
149
+ const { dataLayer: option } = cfg.analytics ?? {};
150
+ const name = typeof option === 'string' ? option : 'dataLayer';
151
+ const existing: unknown = Reflect.get(window, name);
152
+ if (Array.isArray(existing)) return existing;
153
+ const created: unknown[] = [];
154
+ Reflect.set(window, name, created);
155
+ return created;
156
+ }
157
+
158
+ function pushToDataLayer(event: string, ecommerce: Record<string, unknown> | undefined): void {
159
+ const layer = dataLayer();
160
+ if (!ecommerce) {
161
+ layer.push({ event });
162
+ return;
163
+ }
164
+ // Google asks for the previous ecommerce object to be cleared first.
165
+ layer.push({ ecommerce: null });
166
+ layer.push({ event, ecommerce });
167
+ }
168
+
169
+ let bus: AnalyticsBus | null = null;
170
+ /** Variant id to line, to turn cart changes into add and remove events. */
171
+ let lastLines: Map<string, CartLine> | null = null;
172
+ let lastCart: AnalyticsCart | null = null;
173
+ /** Product roots whose view_item was sent on this page. */
174
+ let viewed = new WeakSet<Element>();
175
+
176
+ /** @param name The GA4 event. */
177
+ function send(name: string, data: Record<string, unknown>, shopifyEvent?: string, shopifyPayload?: EventPayload): void {
178
+ if (cfg.analytics?.dataLayer) pushToDataLayer(name, data);
179
+ if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
180
+ try {
181
+ cfg.analytics?.onEvent?.(name, data);
182
+ } catch (error) {
183
+ reportError(error, 'analytics onEvent');
184
+ }
185
+ }
186
+
187
+ /** The scope of the page's own product: a product root outside every list. */
188
+ function mainProduct(): ProductScope | null {
189
+ const el = document.querySelector(sel('product'));
190
+ if (!el || el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`)) return null;
191
+ return scopes.get(el) ?? null;
192
+ }
193
+
194
+ function onProductReady({ product, scope }: TetherEvents['product-ready']): void {
195
+ if (viewed.has(scope.root) || mainProduct() !== scope) return;
196
+ viewed.add(scope.root);
197
+ const variant = scope.variant ?? product.variants.nodes[0];
198
+ const items = [itemFromVariant(product, variant)];
199
+ const products = variant ? [productPayload(product, variant)] : [];
200
+ send('view_item', { currency: variant?.price.currencyCode, value: totalValue(items), items }, 'product_viewed', {
201
+ products,
202
+ });
203
+ }
204
+
205
+ function onListReady({ products, collection }: TetherEvents['list-ready']): void {
206
+ const items = products
207
+ .slice(0, MAX_LIST_ITEMS)
208
+ .map((product, index) => itemFromVariant(product, product.variants.nodes[0], 1, index));
209
+ const list = { item_list_id: collection?.handle || 'products', item_list_name: collection?.title || 'Products' };
210
+ if (!collection) {
211
+ send('view_item_list', { ...list, items });
212
+ return;
213
+ }
214
+ send('view_item_list', { ...list, items }, 'collection_viewed', {
215
+ collection: { id: collection.id, handle: collection.handle },
216
+ });
217
+ }
218
+
219
+ function sendCartChanges(cart: AnalyticsCart | null, now: Map<string, CartLine>, before: Map<string, CartLine>): void {
220
+ const currency = cart?.cost.totalAmount.currencyCode;
221
+ now.forEach((line, variantId) => {
222
+ const previous = before.get(variantId);
223
+ const added = line.quantity - (previous?.quantity ?? 0);
224
+ if (added <= 0) return;
225
+ const items = [itemFromLine(line, added)];
226
+ send('add_to_cart', { currency, value: totalValue(items), items }, 'product_added_to_cart', {
227
+ cart,
228
+ prevCart: lastCart,
229
+ currentLine: linePayload(line),
230
+ prevLine: previous ? linePayload(previous) : undefined,
231
+ });
232
+ });
233
+ before.forEach((line, variantId) => {
234
+ const removed = line.quantity - (now.get(variantId)?.quantity ?? 0);
235
+ if (removed <= 0) return;
236
+ const items = [itemFromLine(line, removed)];
237
+ send('remove_from_cart', { currency, value: totalValue(items), items });
238
+ });
239
+ }
240
+
241
+ function onCartUpdated({ cart, reason }: TetherEvents['cart-updated']): void {
242
+ const now = new Map((cart?.lines.nodes ?? []).map((line) => [line.merchandise.id, line]));
243
+ // Loading the saved cart and showing a pending change aren't changes the visitor made.
244
+ if (lastLines && reason !== 'load' && reason !== 'pending') sendCartChanges(cart, now, lastLines);
245
+ lastLines = now;
246
+ lastCart = cart;
247
+ }
248
+
249
+ function onCartOpened(): void {
250
+ const items = Cart.lines.map((line) => itemFromLine(line));
251
+ send('view_cart', { currency: Cart.state?.cost.totalAmount.currencyCode, value: totalValue(items), items });
252
+ }
253
+
254
+ function onCheckout({ cart }: TetherEvents['checkout']): void {
255
+ if (!cart) return;
256
+ const items = cart.lines.nodes.map((line) => itemFromLine(line));
257
+ const coupons = cart.discountCodes.filter((discount) => discount.applicable).map((discount) => discount.code);
258
+ send('begin_checkout', {
259
+ currency: cart.cost.totalAmount.currencyCode,
260
+ value: totalValue(items),
261
+ coupon: coupons.join(',') || undefined,
262
+ items,
263
+ });
264
+ }
265
+
266
+ /** What each event `ANALYTICS_EVENTS` names sends: one entry per name, so the list and this table can't diverge. */
267
+ const REPORTERS: { [Name in AnalyticsEventName]: (detail: TetherEvents[Name]) => void } = {
268
+ 'product-ready': onProductReady,
269
+ 'list-ready': onListReady,
270
+ 'cart-updated': onCartUpdated,
271
+ 'cart-toggled': ({ open }) => {
272
+ if (open) onCartOpened();
273
+ },
274
+ checkout: onCheckout,
275
+ search: ({ term }) => {
276
+ if (term) send('search', { search_term: term }, 'search_viewed', { searchTerm: term });
277
+ },
278
+ };
279
+
280
+ /** @param event The event Tether created as `name`. */
281
+ function report<Name extends AnalyticsEventName>(event: CustomEvent<TetherEvents[Name]>, name: Name): void {
282
+ REPORTERS[name](event.detail);
283
+ }
284
+
285
+ /**
286
+ * Turns one buffered event into analytics events: a Tether event from `ANALYTICS_EVENTS`, or Astro's `astro:page-load`
287
+ * for a client-side navigation, which starts a new page view. Only events Tether created count (`isOwnEvent`): a
288
+ * look-alike another script dispatches, whatever its detail, sends nothing.
289
+ */
290
+ function handle(event: Event): void {
291
+ if (event.type === 'astro:page-load') {
292
+ viewed = new WeakSet();
293
+ bus?.publish('page_viewed', {});
294
+ return;
295
+ }
296
+ ANALYTICS_EVENTS.forEach((name) => {
297
+ if (isOwnEvent(event, name)) report(event, name);
298
+ });
299
+ }
300
+
301
+ /** Called by the core with the events it buffered while this part loaded; the queue then handles events as they come. */
302
+ export async function startAnalytics(queue: Event[]): Promise<void> {
303
+ if (cfg.analytics?.shopify) {
304
+ bus = await startShopifyAnalytics(() => mainProduct() != null).catch((error: unknown) => {
305
+ reportError(error, 'shopify analytics');
306
+ return null;
307
+ });
308
+ }
309
+ bus?.publish('page_viewed', {});
310
+ queue.splice(0).forEach(handle);
311
+ queue.push = (...events) => {
312
+ events.forEach(handle);
313
+ return queue.length;
314
+ };
315
+ }