@occluze/tether 0.6.6 → 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 +776 -432
  26. package/dist/tether.account-pages.min.js +1 -1
  27. package/dist/tether.account.js +655 -321
  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 +334 -243
  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 +3171 -1998
  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 +68 -284
  58. package/src/account.js +281 -317
  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 +322 -481
  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 -285
  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-QUBHQG3C.js +0 -1
  120. package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
  121. package/dist/esm/chunks/analytics-SLATWPLC.js +0 -1
  122. package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
  123. package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
  124. package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
  125. package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
  126. package/dist/esm/chunks/chunk-L2TRRKYV.js +0 -1
  127. package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
  128. package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
  129. package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
  130. package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
  131. package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
  132. package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
  133. package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
  134. package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
  135. package/dist/esm/chunks/search-NILMGYXG.js +0 -1
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The DOM writes of a product scope that follow the visitor's choice, on every change: `product-state.js`'s view as
3
+ * text, selected and unavailable states, `tether-show` conditions and the add-to-cart buttons.
4
+ */
5
+ import { attr, sel, get, qsa, formatMoney, formatPercent, setText, setImage, setSelected, show } from './dom.js';
6
+ import { STATUS_KEYS, ownedBy } from './status.js';
7
+ import { allocationFor } from './selling-plans.js';
8
+ import { isValueAvailable, planBindTexts, variantBindTexts } from './product-state.js';
9
+ import { isSelect, selectsIn } from './product-render.js';
10
+ /** @import { Formatters, ProductView } from './product-state.js' */
11
+ /** @import { RenderScope } from './product-render.js' */
12
+
13
+ /** @type {Formatters} */
14
+ const FORMAT = { money: formatMoney, percent: formatPercent };
15
+
16
+ /**
17
+ * @param {Element} el
18
+ * @returns {el is HTMLInputElement}
19
+ */
20
+ const isInput = (el) => el.tagName === 'INPUT';
21
+
22
+ /**
23
+ * The element of an add-to-cart button that holds its label, with the label as written kept in
24
+ * `data-tether-label`, so mode-dependent texts (`tether-subscribe-text`, `tether-soldout-text`) can be undone.
25
+ *
26
+ * @param {HTMLElement} button
27
+ * @returns {{ label: HTMLElement, original: string }}
28
+ */
29
+ export function addButtonLabel(button) {
30
+ const label = qsa(button, sel('add-label'))[0] ?? button;
31
+ const original = label.dataset['tetherLabel'] ?? (isInput(label) ? label.value : label.textContent);
32
+ label.dataset['tetherLabel'] = original;
33
+ return { label, original };
34
+ }
35
+
36
+ /**
37
+ * @param {RenderScope} scope
38
+ * @param {ProductView} view
39
+ */
40
+ function renderOptionStates(scope, view) {
41
+ const { product } = scope;
42
+ const selectedValue = (/** @type {string | null} */ authored) => view.selected[scope.optName(authored)] ?? '';
43
+ scope.$(sel('option-selected')).forEach((el) => {
44
+ setText(el, selectedValue(get(el, 'option-selected')));
45
+ });
46
+ scope.$(sel('option-list')).forEach((container) => {
47
+ const name = scope.optName(get(container, 'option-list'));
48
+ qsa(container, sel('option-value')).forEach((node) => {
49
+ const value = get(node, 'option-value') ?? '';
50
+ setSelected(node, view.selected[name] === value);
51
+ const unavailable = !isValueAvailable(product, view.selected, name, value);
52
+ node.classList.toggle('is-unavailable', unavailable);
53
+ node.setAttribute('data-unavailable', unavailable ? 'true' : 'false');
54
+ });
55
+ });
56
+ selectsIn(scope, sel('option')).forEach((select) => {
57
+ select.value = selectedValue(get(select, 'option'));
58
+ });
59
+ selectsIn(scope, sel('variant-select')).forEach((select) => {
60
+ select.value = view.variant.id;
61
+ });
62
+ }
63
+
64
+ /**
65
+ * The purchase toggles and the plan list and select.
66
+ *
67
+ * @param {RenderScope} scope
68
+ * @param {ProductView} view
69
+ */
70
+ function renderPurchaseStates(scope, view) {
71
+ const { variant, purchase, isSubscription } = view;
72
+ scope.$(sel('purchase')).forEach((el) => {
73
+ if (isSelect(el)) {
74
+ el.value = purchase.kind;
75
+ return;
76
+ }
77
+ const mode = get(el, 'purchase');
78
+ setSelected(el, mode === purchase.kind);
79
+ const unavailable = mode === 'subscription' ? !view.subscriptionAvailable : scope.product.requiresSellingPlan;
80
+ el.classList.toggle('is-unavailable', unavailable);
81
+ });
82
+ scope.$(sel('plan-value')).forEach((node) => {
83
+ const planId = get(node, 'plan-value') ?? '';
84
+ setSelected(node, isSubscription && planId === purchase.planId);
85
+ node.classList.toggle('is-unavailable', !allocationFor(variant, planId));
86
+ const plan = scope.plans.find((candidate) => candidate.id === planId);
87
+ const texts = planBindTexts(variant, planId, plan, FORMAT);
88
+ qsa(node, sel('plan-bind')).forEach((el) => {
89
+ const text = texts.get(get(el, 'plan-bind') ?? '');
90
+ if (text) setText(el, text());
91
+ });
92
+ });
93
+ selectsIn(scope, sel('plan')).forEach((select) => {
94
+ select.value = isSubscription ? (purchase.planId ?? '') : '';
95
+ });
96
+ }
97
+
98
+ /**
99
+ * The `tether-show` conditions, with the scope's own status: a product scope that renders is ready.
100
+ *
101
+ * @param {RenderScope} scope
102
+ * @param {ProductView} view
103
+ */
104
+ function renderConditions(scope, view) {
105
+ /** @type {Map<string, boolean>} */
106
+ const conditions = new Map(Object.entries(view.show));
107
+ STATUS_KEYS.forEach((status) => conditions.set(status, status === 'ready'));
108
+ scope.$(sel('show')).forEach((el) => {
109
+ const key = get(el, 'show') ?? '';
110
+ const visible = conditions.get(key);
111
+ if (visible === undefined) return;
112
+ // State blocks of a list nested in this product belong to that list.
113
+ if (STATUS_KEYS.some((status) => status === key) && !ownedBy(el, scope.root)) return;
114
+ show(el, visible);
115
+ });
116
+ }
117
+
118
+ /**
119
+ * @param {RenderScope} scope
120
+ * @param {ProductView} view
121
+ */
122
+ function renderAddButtons(scope, view) {
123
+ const disabled = !view.variant.availableForSale;
124
+ scope.$(sel('add-to-cart')).forEach((button) => {
125
+ button.classList.toggle('is-disabled', disabled);
126
+ button.setAttribute('aria-disabled', String(disabled));
127
+ // A <button> or <input> is disabled; a link or div carries only aria-disabled.
128
+ if ('disabled' in button) button.disabled = disabled;
129
+ const soldOutText = get(button, 'soldout-text');
130
+ const subscribeText = get(button, 'subscribe-text');
131
+ if (!soldOutText && !subscribeText) return;
132
+ const { label, original } = addButtonLabel(button);
133
+ let text = original;
134
+ if (view.isSubscription && subscribeText) text = subscribeText;
135
+ if (disabled && soldOutText) text = soldOutText;
136
+ if (button.getAttribute('aria-busy') !== 'true') setText(label, text);
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Writes what follows the variant and purchase.
142
+ *
143
+ * @param {RenderScope} scope
144
+ * @param {ProductView} view
145
+ */
146
+ export function writeVariant(scope, view) {
147
+ const { variant } = view;
148
+ const { product } = scope;
149
+ const texts = variantBindTexts(view, FORMAT);
150
+ scope.$(sel('bind')).forEach((el) => {
151
+ const key = get(el, 'bind') ?? '';
152
+ if (key === 'variant-image') {
153
+ setImage(el, variant.image?.url || product.featuredImage?.url, variant.image?.altText || product.title);
154
+ return;
155
+ }
156
+ const text = texts.get(key);
157
+ if (text) setText(el, text());
158
+ });
159
+ renderOptionStates(scope, view);
160
+ renderPurchaseStates(scope, view);
161
+ renderConditions(scope, view);
162
+ scope.root.classList.toggle('is-subscription', view.isSubscription);
163
+ scope.root.setAttribute(attr('purchase-mode'), view.isSubscription ? 'subscription' : 'one-time');
164
+ renderAddButtons(scope, view);
165
+ }
package/types/astro.d.ts CHANGED
@@ -1,6 +1,14 @@
1
1
  import type { TetherConfig } from './index.js';
2
2
 
3
- type Serializable<T> = { [K in keyof T as T[K] extends ((...args: any[]) => any) | null | undefined ? (Exclude<T[K], null | undefined> extends (...args: any[]) => any ? never : K) : K]: T[K] };
3
+ type Serializable<T> = {
4
+ [
5
+ K in keyof T as T[K] extends ((...args: any[]) => any) | null | undefined
6
+ ? Exclude<T[K], null | undefined> extends (...args: any[]) => any
7
+ ? never
8
+ : K
9
+ : K
10
+ ]: T[K];
11
+ };
4
12
 
5
13
  export interface TetherAstroOptions extends Partial<Serializable<TetherConfig>> {
6
14
  /** Add the Tether script to every page (default true). False: import and init it yourself. */
@@ -14,4 +22,7 @@ export interface TetherAstroOptions extends Partial<Serializable<TetherConfig>>
14
22
  * (PUBLIC_SHOPIFY_DOMAIN, PUBLIC_SHOPIFY_STOREFRONT_TOKEN). Functions (onError, hooks) go in
15
23
  * window.TETHER_CONFIG in your layout.
16
24
  */
17
- export default function tether(options?: TetherAstroOptions): { name: string; hooks: Record<string, (...args: any[]) => unknown> };
25
+ export default function tether(options?: TetherAstroOptions): {
26
+ name: string;
27
+ hooks: Record<string, (...args: any[]) => unknown>;
28
+ };
package/types/index.d.ts CHANGED
@@ -2,8 +2,16 @@
2
2
  // Shopify Storefront API objects are typed loosely (`ShopifyProduct` etc.): Tether passes them
3
3
  // through as Shopify returns them. See https://shopify.dev/docs/api/storefront for the full shape.
4
4
 
5
- export interface Money { amount: string; currencyCode: string }
6
- export interface ShopifyImage { url: string; altText?: string | null; width?: number; height?: number }
5
+ export interface Money {
6
+ amount: string;
7
+ currencyCode: string;
8
+ }
9
+ export interface ShopifyImage {
10
+ url: string;
11
+ altText?: string | null;
12
+ width?: number;
13
+ height?: number;
14
+ }
7
15
 
8
16
  export interface ShopifySellingPlan {
9
17
  id: string;
@@ -62,7 +70,12 @@ export interface ShopifyCartLine {
62
70
  merchandise: ShopifyVariant & { product: Pick<ShopifyProduct, 'id' | 'handle' | 'title'> & Record<string, unknown> };
63
71
  sellingPlanAllocation?: { sellingPlan: { id: string; name: string } } | null;
64
72
  attributes?: { key: string; value: string }[];
65
- cost: { totalAmount: Money; subtotalAmount: Money; amountPerQuantity: Money; compareAtAmountPerQuantity?: Money | null };
73
+ cost: {
74
+ totalAmount: Money;
75
+ subtotalAmount: Money;
76
+ amountPerQuantity: Money;
77
+ compareAtAmountPerQuantity?: Money | null;
78
+ };
66
79
  [k: string]: unknown;
67
80
  }
68
81
 
@@ -152,9 +165,13 @@ export interface TetherConfig {
152
165
  /** The *.myshopify.com domain, if domain is something else. */
153
166
  shopDomain?: string;
154
167
  locale?: string;
168
+ /** Replaces location.assign for the login redirect (popups, tests). */
169
+ navigate?: (url: string) => void;
155
170
  } | null;
156
171
  /** tether-show="low-stock" at this many or fewer (default 5). */
157
172
  lowStock?: number;
173
+ /** Milliseconds the search part waits after the last keystroke (default 150). */
174
+ searchDelay?: number;
158
175
  /** true: ?variant= (and selling_plan=) follow the visitor's choice on the page's main product. */
159
176
  variantUrl?: boolean;
160
177
  /** Your text for Shopify's cart warnings (stock, codes), e.g. translated by code. Return nothing to keep Shopify's. */
@@ -186,7 +203,8 @@ export interface TetherConfig {
186
203
  debug?: boolean;
187
204
  }
188
205
 
189
- export type TetherErrorCode = 'NOT_FOUND' | 'NETWORK' | 'NOT_CONFIGURED' | 'INVALID_COUNTRY' | 'ERROR' | `HTTP_${number}`;
206
+ export type TetherErrorCode =
207
+ 'NOT_FOUND' | 'NETWORK' | 'NOT_CONFIGURED' | 'INVALID_COUNTRY' | 'ERROR' | `HTTP_${number}`;
190
208
 
191
209
  export declare class TetherError extends Error {
192
210
  name: 'TetherError';
@@ -208,9 +226,32 @@ export interface LineInput {
208
226
  }
209
227
 
210
228
  /** Shopify's note on the last cart change; target is the cart line id it's about. */
211
- export interface CartWarning { code: string; message: string; target?: string }
229
+ export interface CartWarning {
230
+ code: string;
231
+ message: string;
232
+ target?: string;
233
+ }
234
+
235
+ /**
236
+ * What the cart is known to be: `unknown` until the saved cart has loaded, `empty` without a cart, `error` after a
237
+ * failed load or change (with the cart as Shopify last sent it).
238
+ */
239
+ export type CartStatus =
240
+ | { kind: 'unknown' }
241
+ | { kind: 'empty' }
242
+ | { kind: 'ready'; cart: ShopifyCart }
243
+ | { kind: 'error'; error: unknown; cart: ShopifyCart | null };
244
+
245
+ /** The logged-in customer the cart belongs to (set by the account part). */
246
+ export interface CustomerLink {
247
+ /** The Customer Account API token: carts created from now on belong to the customer. */
248
+ accessToken: string | null;
249
+ /** The account's own checkout URL; null from it, or no function, uses the cart's. */
250
+ beforeCheckout: (() => Promise<string | null>) | null;
251
+ }
212
252
 
213
253
  export interface Cart {
254
+ readonly status: CartStatus;
214
255
  readonly state: ShopifyCart | null;
215
256
  readonly lines: ShopifyCartLine[];
216
257
  readonly count: number;
@@ -219,10 +260,16 @@ export interface Cart {
219
260
  readonly warnings: CartWarning[];
220
261
  /** true once the saved cart (or the absence of one) is known. */
221
262
  readonly loaded: boolean;
263
+ /** Links the cart to a logged-in customer, or unlinks it with nulls. Fields left out keep their value. */
264
+ setCustomer(link: Partial<CustomerLink>): void;
222
265
  id(): string | null;
223
266
  init(): Promise<ShopifyCart | null>;
224
267
  /** add(variantId, qty) · add({ variantId, quantity, sellingPlanId, attributes }) · add([line, line]) */
225
- add(input: string | LineInput | LineInput[], quantity?: number, opts?: Omit<LineInput, 'variantId' | 'quantity'>): Promise<ShopifyCart>;
268
+ add(
269
+ input: string | LineInput | LineInput[],
270
+ quantity?: number,
271
+ opts?: Omit<LineInput, 'variantId' | 'quantity'>,
272
+ ): Promise<ShopifyCart>;
226
273
  /** updateLine(id, 3) · updateLine(id, { sellingPlanId: null }) switches to one-time. */
227
274
  updateLine(lineId: string, patch: number | (LineInput & { quantity?: number })): Promise<ShopifyCart>;
228
275
  removeLine(lineIds: string | string[]): Promise<ShopifyCart>;
@@ -237,7 +284,10 @@ export interface Cart {
237
284
  /** Rejects with code INVALID_DISCOUNT (and doesn't keep the code) when Shopify doesn't accept it. */
238
285
  applyDiscount(code: string): Promise<ShopifyCart>;
239
286
  removeDiscount(code: string): Promise<ShopifyCart>;
240
- setBuyerIdentity(identity: { countryCode?: string; email?: string; phone?: string }, opts?: { create?: boolean }): Promise<ShopifyCart | null>;
287
+ setBuyerIdentity(
288
+ identity: { countryCode?: string; email?: string; phone?: string },
289
+ opts?: { create?: boolean },
290
+ ): Promise<ShopifyCart | null>;
241
291
  checkout(target?: '_self' | '_blank'): Promise<void>;
242
292
  open(): void;
243
293
  close(): void;
@@ -246,7 +296,12 @@ export interface Cart {
246
296
  drawers(): HTMLElement[];
247
297
  render(): void;
248
298
  /** For framework islands: fn(cart, meta) now, when the saved cart has loaded and after every change. Returns unsubscribe. */
249
- subscribe(fn: (cart: ShopifyCart | null, meta: { open: boolean; count: number; loaded: boolean; warnings: CartWarning[] }) => void): () => void;
299
+ subscribe(
300
+ fn: (
301
+ cart: ShopifyCart | null,
302
+ meta: { open: boolean; count: number; loaded: boolean; warnings: CartWarning[] },
303
+ ) => void,
304
+ ): () => void;
250
305
  }
251
306
 
252
307
  export interface ProductScope {
@@ -279,10 +334,16 @@ export interface SellingPlanHelpers {
279
334
  planPrice(variant: ShopifyVariant, planId: string): Money | null;
280
335
  planCheckoutCharge(variant: ShopifyVariant, planId: string): Money | null;
281
336
  planSavings(variant: ShopifyVariant, planId: string): { amount: Money | null; percentage: number };
282
- planAdjustment(plan: ShopifySellingPlan | null | undefined): { type: 'percentage' | 'amount' | 'price'; value: number | Money } | null;
337
+ planAdjustment(
338
+ plan: ShopifySellingPlan | null | undefined,
339
+ ): { type: 'percentage' | 'amount' | 'price'; value: number | Money } | null;
283
340
  findPlan(product: ShopifyProduct, ref: string | null | undefined): ShopifySellingPlan | null;
284
341
  toGid(value: string | number, type: string): string | null;
285
- GID: { product(v: string | number): string | null; variant(v: string | number): string | null; sellingPlan(v: string | number): string | null };
342
+ GID: {
343
+ product(v: string | number): string | null;
344
+ variant(v: string | number): string | null;
345
+ sellingPlan(v: string | number): string | null;
346
+ };
286
347
  }
287
348
 
288
349
  export interface ProductPage {
@@ -294,18 +355,41 @@ export interface ProductPage {
294
355
  export interface Localization {
295
356
  country?: { isoCode: string; name: string; currency?: { isoCode: string; symbol: string } };
296
357
  language?: { isoCode: string; name: string };
297
- availableCountries: { isoCode: string; name: string; currency?: { isoCode: string; symbol: string }; availableLanguages?: { isoCode: string; name: string }[] }[];
358
+ availableCountries: {
359
+ isoCode: string;
360
+ name: string;
361
+ currency?: { isoCode: string; symbol: string };
362
+ availableLanguages?: { isoCode: string; name: string }[];
363
+ }[];
364
+ }
365
+
366
+ export type MarketSource = 'saved' | 'server' | 'webflow';
367
+
368
+ /** The market fields detection found, and the source each came from (no sources with detect: false). */
369
+ export interface DetectedMarket {
370
+ country?: string;
371
+ language?: string;
372
+ locale?: string;
373
+ sources?: Partial<Record<'country' | 'language' | 'locale', MarketSource>>;
298
374
  }
299
375
 
300
376
  export interface DiagnoseReport {
301
377
  version: string;
302
378
  config: Record<string, unknown>;
303
379
  apiVersion: { version: string; ok: boolean; monthsLeft?: number; next?: string; note: string };
304
- market: { country?: string; language?: string; locale?: string; sources: Record<string, string> } | null;
380
+ /** The market in use (a visitor's choice included), and what detection would pick (null without markets.detect). */
381
+ market: { country: string | null; language: string | null; locale: string | null; detected: DetectedMarket | null };
305
382
  reachable: boolean;
306
383
  shop: { name?: string; url?: string; currency?: string } | null;
307
384
  latencyMs: number | null;
308
- products: { key: string; handle?: string; title?: string; plans?: number; error?: string }[];
385
+ products: {
386
+ key: string;
387
+ handle?: string;
388
+ title?: string;
389
+ plans?: number;
390
+ requiresSellingPlan?: boolean;
391
+ error?: string;
392
+ }[];
309
393
  sellingPlans: { product: string; group?: string; plan: string; id: string }[];
310
394
  problems: { level: 'error' | 'warn'; message: string; element?: string }[];
311
395
  }
@@ -316,7 +400,13 @@ export interface TetherEvents {
316
400
  'product-ready': { product: ShopifyProduct; scope: ProductScope };
317
401
  'product-updated': { product: ShopifyProduct; scope: ProductScope };
318
402
  'product-not-found': { key: string | null; source: string };
319
- 'variant-changed': { product: ShopifyProduct; variant: ShopifyVariant; selected: Record<string, string>; purchase: 'one-time' | 'subscription'; sellingPlanId: string | null };
403
+ 'variant-changed': {
404
+ product: ShopifyProduct;
405
+ variant: ShopifyVariant;
406
+ selected: Record<string, string>;
407
+ purchase: 'one-time' | 'subscription';
408
+ sellingPlanId: string | null;
409
+ };
320
410
  'product-added': { product: ShopifyProduct; variant: ShopifyVariant; line: LineInput };
321
411
  added: { cart: ShopifyCart; lines: LineInput[] };
322
412
  'group-added': { group: string | null; lines: LineInput[] };
@@ -348,27 +438,74 @@ export interface Tether {
348
438
  withContext(query: string, ctx?: { country?: string; language?: string }): string;
349
439
  cart: Cart;
350
440
  getProduct(handleOrId: string): Promise<ShopifyProduct>;
351
- getProducts(opts?: { collection?: string; query?: string; first?: number; after?: string | null; sortKey?: string; reverse?: boolean; filters?: unknown[] }): Promise<ProductPage>;
352
- getRecommendations(product: string | Pick<ShopifyProduct, 'id'>, intent?: 'RELATED' | 'COMPLEMENTARY'): Promise<ShopifyProduct[]>;
441
+ getProducts(opts?: {
442
+ collection?: string;
443
+ query?: string;
444
+ first?: number;
445
+ after?: string | null;
446
+ sortKey?: string;
447
+ reverse?: boolean;
448
+ filters?: unknown[];
449
+ }): Promise<ProductPage>;
450
+ getRecommendations(
451
+ product: string | Pick<ShopifyProduct, 'id'>,
452
+ intent?: 'RELATED' | 'COMPLEMENTARY',
453
+ ): Promise<ShopifyProduct[]>;
353
454
  getScope(el: Element | null | undefined): ProductScope | null;
354
455
  addGroup(button: Element): Promise<ShopifyCart | null>;
355
456
  /** Customer accounts (loads the account part): login(), logout(), customer(), orders(), query(gql, vars), getToken(). */
356
457
  account: {
357
458
  login(opts?: { returnTo?: string; locale?: string }): Promise<void>;
358
459
  logout(opts?: { returnTo?: string }): Promise<void>;
359
- customer(): Promise<{ id: string; firstName?: string | null; lastName?: string | null; displayName?: string; emailAddress?: { emailAddress: string } | null } | null>;
360
- orders(opts?: { first?: number }): Promise<Array<{ id: string; name: string; processedAt: string; financialStatus?: string; fulfillmentStatus?: string; statusPageUrl: string; totalPrice: Money }>>;
460
+ customer(): Promise<{
461
+ id: string;
462
+ firstName?: string | null;
463
+ lastName?: string | null;
464
+ displayName?: string;
465
+ emailAddress?: { emailAddress: string } | null;
466
+ } | null>;
467
+ orders(opts?: { first?: number }): Promise<
468
+ Array<{
469
+ id: string;
470
+ name: string;
471
+ processedAt: string;
472
+ financialStatus?: string;
473
+ fulfillmentStatus?: string;
474
+ statusPageUrl: string;
475
+ totalPrice: Money;
476
+ }>
477
+ >;
361
478
  query<T = any>(query: string, variables?: Record<string, unknown>): Promise<T>;
362
479
  getToken(): Promise<string | null>;
480
+ /** True while a customer is logged in, as far as this page knows without asking. */
481
+ isLoggedIn(): Promise<boolean>;
482
+ /** Finishes a login coming back from Shopify (browser mode); true when there was one. */
483
+ handleCallback(): Promise<boolean>;
484
+ /** Fills the account markup in root: logged-in/out content, the customer, orders and the account pages. */
485
+ render(root?: ParentNode): Promise<void>;
486
+ /** What mount() runs: finishes a login coming back on the first call, then renders root. */
487
+ mountAccount(root?: ParentNode): Promise<void>;
363
488
  /** One order in full (lines, totals, addresses, fulfillments with tracking, returnable lines); null if not the customer's. */
364
489
  order(id: string | number): Promise<Record<string, any> | null>;
365
- updateProfile(fields: { firstName?: string; lastName?: string }): Promise<{ firstName?: string | null; lastName?: string | null }>;
490
+ updateProfile(fields: {
491
+ firstName?: string;
492
+ lastName?: string;
493
+ }): Promise<{ firstName?: string | null; lastName?: string | null }>;
366
494
  addresses(): Promise<Array<CustomerAddress & { isDefault: boolean }>>;
367
- saveAddress(address: Partial<CustomerAddressInput> | null, opts?: { id?: string; isDefault?: boolean }): Promise<CustomerAddress>;
495
+ saveAddress(
496
+ address: Partial<CustomerAddressInput> | null,
497
+ opts?: { id?: string; isDefault?: boolean },
498
+ ): Promise<CustomerAddress>;
368
499
  deleteAddress(id: string): Promise<string>;
369
500
  subscriptions(): Promise<Array<Record<string, any>>>;
370
- subscriptionAction(id: string | number, action: 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel'): Promise<Record<string, any>>;
371
- requestReturn(orderId: string | number, lines: Array<{ lineItemId: string; quantity: number; reasonId?: string; note?: string }>): Promise<{ id: string; status: string }>;
501
+ subscriptionAction(
502
+ id: string | number,
503
+ action: 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel',
504
+ ): Promise<Record<string, any>>;
505
+ requestReturn(
506
+ orderId: string | number,
507
+ lines: Array<{ lineItemId: string; quantity: number; reasonId?: string; note?: string }>,
508
+ ): Promise<{ id: string; status: string }>;
372
509
  };
373
510
  primeProduct(product: ShopifyProduct): void;
374
511
  normalizeProductKey(raw: string | number): string | null;
@@ -381,12 +518,15 @@ export interface Tether {
381
518
  resolveProductKey(el: Element, location?: Location | URL): { key: string | null; fromUrl: boolean; raw: string };
382
519
  /** Switch market: prices, currency, cart and every product on the page follow. */
383
520
  setCountry(code: string, opts?: SetCountryOptions): Promise<ShopifyCart | null>;
384
- detectMarket(markets?: MarketsConfig): Promise<{ country?: string; language?: string; locale?: string; sources: Record<string, string> }>;
521
+ detectMarket(markets?: MarketsConfig | null): Promise<DetectedMarket>;
385
522
  getLocalization(): Promise<Localization>;
386
523
  /** Logs and returns what's wrong on this page (config, token, API version, plans, markup). */
387
524
  diagnose(opts?: { root?: ParentNode; silent?: boolean }): Promise<DiagnoseReport>;
388
525
  /** Listen to a Tether event; returns a function that stops listening. */
389
- on<K extends keyof TetherEvents>(name: K, fn: (detail: TetherEvents[K], event: CustomEvent<TetherEvents[K]>) => void): () => void;
526
+ on<K extends keyof TetherEvents>(
527
+ name: K,
528
+ fn: (detail: TetherEvents[K], event: CustomEvent<TetherEvents[K]>) => void,
529
+ ): () => void;
390
530
  on(name: string, fn: (detail: any, event: CustomEvent) => void): () => void;
391
531
  }
392
532
 
@@ -418,9 +558,20 @@ declare global {
418
558
  }
419
559
 
420
560
  export interface CustomerAddressInput {
421
- firstName: string; lastName: string; company: string; address1: string; address2: string;
422
- city: string; zip: string; zoneCode: string; countryCode: string; phoneNumber: string;
561
+ firstName: string;
562
+ lastName: string;
563
+ company: string;
564
+ address1: string;
565
+ address2: string;
566
+ city: string;
567
+ zip: string;
568
+ zoneCode: string;
569
+ countryCode: string;
570
+ phoneNumber: string;
423
571
  }
424
572
  export interface CustomerAddress extends Partial<CustomerAddressInput> {
425
- id: string; province?: string | null; country?: string | null; formatted: string[];
573
+ id: string;
574
+ province?: string | null;
575
+ country?: string | null;
576
+ formatted: string[];
426
577
  }