@occluze/tether 0.6.7 → 0.6.8

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 (135) hide show
  1. package/README.md +3 -3
  2. package/dist/esm/astro.js +2 -2
  3. package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
  4. package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
  5. package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
  6. package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
  7. package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
  8. package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
  9. package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
  10. package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
  11. package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
  12. package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
  13. package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
  14. package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
  15. package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
  16. package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
  17. package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
  18. package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
  19. package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
  20. package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
  21. package/dist/esm/chunks/search-OU75RNQL.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 +56 -36
  25. package/dist/tether.account-pages.js +773 -442
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +648 -318
  28. package/dist/tether.account.min.js +1 -1
  29. package/dist/tether.analytics.js +318 -191
  30. package/dist/tether.analytics.min.js +1 -1
  31. package/dist/tether.diagnose.js +331 -242
  32. package/dist/tether.diagnose.min.js +2 -2
  33. package/dist/tether.extras.js +78 -33
  34. package/dist/tether.extras.min.js +1 -1
  35. package/dist/tether.js +3176 -2015
  36. package/dist/tether.markets.js +231 -126
  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 +221 -126
  41. package/dist/tether.search.min.js +1 -1
  42. package/package.json +23 -4
  43. package/src/account/addresses-view.js +92 -0
  44. package/src/account/browser-session.js +359 -0
  45. package/src/account/markup.js +48 -0
  46. package/src/account/operations.js +180 -0
  47. package/src/account/order-lines.js +96 -0
  48. package/src/account/order-list.js +91 -0
  49. package/src/account/order-page.js +192 -0
  50. package/src/account/page-events.js +288 -0
  51. package/src/account/server-client.js +172 -0
  52. package/src/account/shared.js +111 -0
  53. package/src/account/shopify-auth.js +150 -0
  54. package/src/account/subscriptions-view.js +158 -0
  55. package/src/account-api.js +118 -0
  56. package/src/account-links.js +35 -0
  57. package/src/account-pages.js +69 -298
  58. package/src/account.js +280 -323
  59. package/src/amounts.js +36 -0
  60. package/src/analytics-shopify.js +241 -0
  61. package/src/analytics.js +304 -184
  62. package/src/api-version.js +75 -25
  63. package/src/astro.js +233 -68
  64. package/src/cart/checkout.js +97 -0
  65. package/src/cart/drawer.js +138 -0
  66. package/src/cart/mutations.js +234 -0
  67. package/src/cart/render.js +294 -0
  68. package/src/cart/store.js +326 -0
  69. package/src/cart.js +320 -488
  70. package/src/client.js +214 -78
  71. package/src/collection.js +126 -57
  72. package/src/config.js +323 -59
  73. package/src/controls/cart-controls.js +155 -0
  74. package/src/controls/discount.js +96 -0
  75. package/src/controls/extras.js +63 -0
  76. package/src/controls/navigation.js +83 -0
  77. package/src/diagnose-markup.js +333 -0
  78. package/src/diagnose.js +251 -202
  79. package/src/dom.js +219 -220
  80. package/src/extras.js +119 -37
  81. package/src/fragments.js +115 -22
  82. package/src/images.js +104 -0
  83. package/src/index.js +245 -287
  84. package/src/initial.js +78 -66
  85. package/src/lazy-iife.js +92 -22
  86. package/src/lazy.js +25 -4
  87. package/src/loader.js +263 -124
  88. package/src/market-codes.js +35 -0
  89. package/src/markets-ui.js +169 -55
  90. package/src/markets.js +286 -85
  91. package/src/markup.js +64 -0
  92. package/src/mounting.js +214 -91
  93. package/src/page-market.js +116 -0
  94. package/src/parts/account-pages.js +4 -1
  95. package/src/parts/account.js +4 -1
  96. package/src/parts/analytics.js +4 -1
  97. package/src/parts/diagnose.js +4 -1
  98. package/src/parts/extras.js +4 -1
  99. package/src/parts/markets.js +4 -1
  100. package/src/parts/search.js +4 -1
  101. package/src/presets/barba.js +120 -30
  102. package/src/product-events.js +130 -0
  103. package/src/product-render.js +257 -0
  104. package/src/product-state.js +318 -0
  105. package/src/product.js +233 -459
  106. package/src/repeaters.js +173 -0
  107. package/src/search.js +277 -107
  108. package/src/selling-plans.js +107 -47
  109. package/src/server.js +168 -205
  110. package/src/shopify-values.js +89 -0
  111. package/src/status.js +99 -39
  112. package/src/store-html.js +0 -0
  113. package/src/storefront/catalog.js +199 -0
  114. package/src/storefront/json-ld.js +167 -0
  115. package/src/variant-render.js +165 -0
  116. package/types/astro.d.ts +13 -2
  117. package/types/index.d.ts +178 -27
  118. package/types/server.d.ts +89 -17
  119. package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
  120. package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
  121. package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
  122. package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
  123. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  124. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  125. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  126. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  127. package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
  128. package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
  129. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  130. package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
  131. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  132. package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
  133. package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
  134. package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/amounts.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shopify sends money amounts as decimal strings (`'27.50'`). Comparing them as floats can misjudge two amounts that
3
+ * differ only past float precision, so they are compared digit by digit, as integers of the smallest unit present.
4
+ */
5
+
6
+ const DECIMAL = /^(-?)(\d+)(?:\.(\d+))?$/;
7
+
8
+ /**
9
+ * @param {string} amount
10
+ * @param {number} fractionDigits Digits after the point to scale to.
11
+ * @returns {bigint | null} The amount in units of 10^-fractionDigits, `null` when it isn't a decimal number.
12
+ */
13
+ function toScaledInteger(amount, fractionDigits) {
14
+ const match = DECIMAL.exec(amount.trim());
15
+ if (!match) return null;
16
+ const [, sign = '', whole = '0', fraction = ''] = match;
17
+ const digits = BigInt(`${whole}${fraction.padEnd(fractionDigits, '0')}`);
18
+ return sign === '-' ? -digits : digits;
19
+ }
20
+
21
+ /** @param {string} amount */
22
+ const fractionLength = (amount) => amount.split('.')[1]?.length ?? 0;
23
+
24
+ /**
25
+ * @param {string | null | undefined} amount
26
+ * @param {string | null | undefined} other
27
+ * @returns {boolean} True when both are decimal numbers and `amount` is the larger one.
28
+ */
29
+ export function isGreaterAmount(amount, other) {
30
+ if (amount == null || other == null) return false;
31
+ const fractionDigits = Math.max(fractionLength(amount.trim()), fractionLength(other.trim()));
32
+ const scaled = toScaledInteger(amount, fractionDigits);
33
+ const scaledOther = toScaledInteger(other, fractionDigits);
34
+ if (scaled === null || scaledOther === null) return false;
35
+ return scaled > scaledOther;
36
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Shopify's own analytics for the analytics part. Tether sets up the same event bus Hydrogen uses
3
+ * (`window.Shopify.analytics`) and loads Shopify's consent script and shopify.js, which build and send the events.
4
+ * Nothing is delivered before the Customer Privacy API allows analytics.
5
+ */
6
+ import { cfg, log, reportError } from './config.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
+ */
26
+
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
+ */
49
+
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
+ */
58
+
59
+ /** Events kept for destinations that register late (shopify.js loads after the first page view). */
60
+ const BUFFER_SIZE = 500;
61
+ const CONSENT_SCRIPTS = {
62
+ 'default-banner': 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js',
63
+ other: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js',
64
+ };
65
+ const ANALYTICS_SCRIPT = 'https://cdn.shopify.com/storefront/analytics/shopify.js';
66
+
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
+ }
73
+
74
+ /** @returns {{ consent?: string, proxy?: string }} The `analytics.shopify` options; `true` sets none. */
75
+ function shopifyOptions() {
76
+ const shopify = cfg.analytics?.shopify;
77
+ return typeof shopify === 'object' ? shopify : {};
78
+ }
79
+
80
+ /**
81
+ * Shopify's privacy API can throw while its script is still setting up. An answer it can't give counts as "not
82
+ * allowed", so nothing is sent without consent; the next consent event asks again.
83
+ *
84
+ * @returns {boolean}
85
+ */
86
+ function trackingAllowed() {
87
+ try {
88
+ const privacy = shopifyGlobal()?.customerPrivacy;
89
+ return (
90
+ privacy?.consentStatus === 'loaded' &&
91
+ privacy.currentVisitorConsent?.()?.analytics !== 'no' &&
92
+ Boolean(privacy.analyticsProcessingAllowed?.())
93
+ );
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * @param {string} name The destination's name, Hydrogen tags its tokens with it.
101
+ * @returns {DestinationContext}
102
+ */
103
+ function destinationContext(name) {
104
+ const tag = `hydrogen:${name}`;
105
+ return {
106
+ getTrackingValues: () => {
107
+ const internal = trackingAllowed() ? shopifyGlobal()?.customerPrivacy?.__internal : undefined;
108
+ return {
109
+ uniqueToken: internal?.uniqueToken?.({ generateFallback: true, tag }) ?? '',
110
+ visitToken: internal?.visitToken?.({ generateFallback: true, tag }) ?? '',
111
+ };
112
+ },
113
+ };
114
+ }
115
+
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
+
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 = {
138
+ shop: { shopId, channel: 'headless' },
139
+ consent: { mode: shopifyOptions().consent || 'no-banner' },
140
+ };
141
+ const deliver = () => {
142
+ if (!trackingAllowed()) return;
143
+ for (const destination of destinations) {
144
+ for (; destination.next < buffer.length; destination.next += 1) {
145
+ const buffered = buffer[destination.next];
146
+ if (buffered) deliverTo(destination, buffered);
147
+ }
148
+ }
149
+ };
150
+ /** @type {AnalyticsBus} */
151
+ const bus = {
152
+ getConfig: () => config,
153
+ publish(event, payload = {}) {
154
+ buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
155
+ if (buffer.length > BUFFER_SIZE) {
156
+ buffer.shift();
157
+ for (const destination of destinations) destination.next = Math.max(0, destination.next - 1);
158
+ }
159
+ deliver();
160
+ },
161
+ addDestination(spec) {
162
+ /** @type {Destination} */
163
+ const destination = { name: spec.name, next: 0, subscribers: new Map(), context: destinationContext(spec.name) };
164
+ Promise.resolve(spec.setup?.({ subscribe: subscriberOf(destination), getConfig: bus.getConfig }))
165
+ .then(() => {
166
+ destinations.push(destination);
167
+ deliver();
168
+ })
169
+ .catch((/** @type {unknown} */ error) => {
170
+ reportError(error, `analytics ${spec.name}`);
171
+ });
172
+ return () => {
173
+ const index = destinations.indexOf(destination);
174
+ if (index >= 0) destinations.splice(index, 1);
175
+ };
176
+ },
177
+ };
178
+ document.addEventListener('consentTrackingApiLoaded', deliver);
179
+ document.addEventListener('visitorConsentCollected', deliver);
180
+ return bus;
181
+ }
182
+
183
+ /**
184
+ * @param {Destination} destination
185
+ * @param {BufferedEvent} buffered
186
+ */
187
+ function deliverTo(destination, { event, payload }) {
188
+ destination.subscribers.get(event)?.forEach((subscriber) => {
189
+ try {
190
+ subscriber(payload, destination.context);
191
+ } catch (error) {
192
+ reportError(error, `analytics ${destination.name}`);
193
+ }
194
+ });
195
+ }
196
+
197
+ /** @param {string} src */
198
+ function loadScript(src) {
199
+ const script = document.createElement('script');
200
+ script.src = src;
201
+ script.async = true;
202
+ document.head.appendChild(script);
203
+ }
204
+
205
+ /**
206
+ * Sets up `window.Shopify` for Shopify's scripts and loads them.
207
+ *
208
+ * @param {() => 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.
210
+ */
211
+ export async function startShopifyAnalytics(isProductPage) {
212
+ const existing = shopifyGlobal();
213
+ if (existing?.analytics) return existing.analytics;
214
+ /** @type {{ shop: { id: string } }} */
215
+ const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
216
+ const shopify = shopifyGlobal() ?? {};
217
+ Reflect.set(window, 'Shopify', shopify);
218
+ shopify.country = (cfg.country || 'US').toUpperCase();
219
+ // Shopify's script requires a language, not a full locale.
220
+ shopify.locale = (cfg.locale || 'en').split('-')[0]?.toLowerCase() ?? 'en';
221
+ shopify.shop = cfg.domain;
222
+ shopify.routes = {
223
+ root: '/',
224
+ match: () => ({ pageTemplateName: isProductPage() ? 'product' : 'page' }),
225
+ ...shopify.routes,
226
+ };
227
+ // A same-origin Storefront API path gives Shopify exact visitor counts.
228
+ const { proxy, consent } = shopifyOptions();
229
+ shopify.customerPrivacy = {
230
+ ...shopify.customerPrivacy,
231
+ config: proxy
232
+ ? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
233
+ : { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
234
+ };
235
+ const bus = createBus(data.shop.id);
236
+ shopify.analytics = bus;
237
+ loadScript(consent === 'default-banner' ? CONSENT_SCRIPTS['default-banner'] : CONSENT_SCRIPTS.other);
238
+ loadScript(ANALYTICS_SCRIPT);
239
+ log('shopify analytics ready', data.shop.id);
240
+ return bus;
241
+ }