@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.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +773 -442
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +648 -318
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +331 -242
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3176 -2015
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +69 -298
- package/src/account.js +280 -323
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +320 -488
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -287
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/index.js
CHANGED
|
@@ -1,346 +1,290 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tether
|
|
2
|
+
* Tether's entry: `window.Tether`, `init()`, `mount()` and the start of the plain `<script>` setup.
|
|
3
3
|
*
|
|
4
4
|
* <script>window.TETHER_CONFIG = { domain: 'x.myshopify.com', token: '...' }</script>
|
|
5
5
|
* <script src="tether.min.js" defer></script>
|
|
6
6
|
*
|
|
7
|
-
* With page transitions (Barba, Swup): set autoMount: false
|
|
8
|
-
* Tether.
|
|
7
|
+
* With page transitions (Barba, Swup): set `autoMount: false`, call `Tether.init()` once and
|
|
8
|
+
* `Tether.mount(container)` after every page enter.
|
|
9
9
|
*/
|
|
10
10
|
import { cfg, configure, isConfigured, log, warn, reportError } from './config.js';
|
|
11
11
|
import { gql, TetherError, withContext } from './client.js';
|
|
12
|
-
import { Cart
|
|
12
|
+
import { Cart } from './cart.js';
|
|
13
13
|
import { scopes } from './product.js';
|
|
14
14
|
import { ListScope, listSelector } from './collection.js';
|
|
15
15
|
import {
|
|
16
|
-
getProduct,
|
|
16
|
+
getProduct,
|
|
17
|
+
getProducts,
|
|
18
|
+
getRecommendations,
|
|
19
|
+
clearProductCache,
|
|
20
|
+
primeProduct,
|
|
21
|
+
normalizeProductKey,
|
|
17
22
|
} from './loader.js';
|
|
18
23
|
import * as plans from './selling-plans.js';
|
|
19
24
|
import { resolveProductKey } from './status.js';
|
|
20
|
-
import {
|
|
25
|
+
import { sel, emit, formatMoney, imageUrl } from './dom.js';
|
|
21
26
|
import { loadPart, coreModules } from './lazy.js';
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
mountedLists,
|
|
29
|
+
liveLists,
|
|
30
|
+
pruneLive,
|
|
31
|
+
within,
|
|
32
|
+
mountProduct,
|
|
33
|
+
hydrateFromPage,
|
|
34
|
+
settleMarket,
|
|
35
|
+
trackMarket,
|
|
36
|
+
} from './mounting.js';
|
|
37
|
+
import { applyStartMarket, setCountry } from './page-market.js';
|
|
38
|
+
import { accountApi, mountAccountIfUsed } from './account-api.js';
|
|
39
|
+
import { bindCartControls } from './controls/cart-controls.js';
|
|
40
|
+
import { bindDiscountControls } from './controls/discount.js';
|
|
41
|
+
import { addGroup, bindExtrasControls } from './controls/extras.js';
|
|
42
|
+
import { bindNavigation, rememberDocumentMount } from './controls/navigation.js';
|
|
43
|
+
/** @import { TetherConfig, TetherOptions, MarketsConfig } from './config.js' */
|
|
44
|
+
/** @import { ShopifyCart } from './cart/store.js' */
|
|
45
|
+
/** @import { ProductScope } from './product.js' */
|
|
46
|
+
/** @import { DiagnoseOptions } from './diagnose.js' */
|
|
47
|
+
|
|
48
|
+
export { addGroup, setCountry };
|
|
49
|
+
|
|
50
|
+
export const VERSION = '0.6.8';
|
|
51
|
+
|
|
52
|
+
/** The events the analytics part reports, buffered from `init()` until the part has loaded. */
|
|
53
|
+
const ANALYTICS_EVENTS = ['product-ready', 'list-ready', 'cart-updated', 'cart-toggled', 'checkout', 'search'];
|
|
54
|
+
|
|
55
|
+
/** Milliseconds `observe` waits after the last added node, so a burst of additions mounts once. */
|
|
56
|
+
const OBSERVE_DEBOUNCE_MS = 50;
|
|
23
57
|
|
|
24
|
-
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {object} TetherGlobals What the page sets on `window` for Tether, and `window.Tether` itself.
|
|
60
|
+
* @property {TetherOptions} [TETHER_CONFIG] The site's config; `configure()` validates it.
|
|
61
|
+
* @property {string} [TETHER_INTEGRATION] Set by a framework integration (Astro) that starts Tether itself.
|
|
62
|
+
* @property {TetherApi} [Tether]
|
|
63
|
+
*/
|
|
25
64
|
|
|
65
|
+
/** @returns {Window & TetherGlobals} */
|
|
66
|
+
const pageGlobals = () => window;
|
|
67
|
+
|
|
68
|
+
/** @type {Promise<TetherApi | null> | null} */
|
|
26
69
|
let initPromise = null;
|
|
70
|
+
|
|
71
|
+
/** @type {MutationObserver | null} */
|
|
27
72
|
let observer = null;
|
|
28
|
-
let mountedBody = null; // the page body mount(document) last ran on (Astro's first page-load repeats it)
|
|
29
73
|
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
pruneLive(); // page transitions detach old roots: don't keep them alive
|
|
36
|
-
hydrateFromPage(root); // products the server already rendered: no request needed
|
|
74
|
+
/** @type {Event[] | null} The analytics part's queue, created once by the first `init()` with `analytics`. */
|
|
75
|
+
let analyticsQueue = null;
|
|
76
|
+
|
|
77
|
+
/** @type {boolean} The document-wide listeners are bound once, by the first configured `init()`. */
|
|
78
|
+
let controlsBound = false;
|
|
37
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Mounts the lists of `root` and the product scopes in them as they render.
|
|
82
|
+
*
|
|
83
|
+
* @param {ParentNode} root
|
|
84
|
+
* @returns {number} The lists mounted.
|
|
85
|
+
*/
|
|
86
|
+
function mountLists(root) {
|
|
87
|
+
let count = 0;
|
|
38
88
|
within(root, listSelector()).forEach((el) => {
|
|
39
89
|
if (mountedLists.has(el)) return;
|
|
40
90
|
mountedLists.add(el);
|
|
41
91
|
count++;
|
|
42
92
|
const list = new ListScope(el);
|
|
43
93
|
liveLists.add(list);
|
|
44
|
-
el.addEventListener(`${cfg.prefix}:list-rendered`, (
|
|
45
|
-
|
|
94
|
+
el.addEventListener(`${cfg.prefix}:list-rendered`, (event) => {
|
|
95
|
+
// The list emits this CustomEvent once its products are in the page.
|
|
96
|
+
mount(/** @type {CustomEvent<{ container: ParentNode }>} */ (event).detail.container);
|
|
97
|
+
});
|
|
98
|
+
void trackMarket(list.load());
|
|
46
99
|
});
|
|
100
|
+
return count;
|
|
101
|
+
}
|
|
47
102
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Loads each optional part `root` uses: the country select, customer accounts and search as you type.
|
|
105
|
+
*
|
|
106
|
+
* @param {ParentNode} root
|
|
107
|
+
*/
|
|
108
|
+
function mountParts(root) {
|
|
51
109
|
if (within(root, `${sel('country-select')},${sel('country-bind')}`).length) {
|
|
52
|
-
loadPart('markets')
|
|
110
|
+
loadPart('markets')
|
|
111
|
+
.then((part) => part.renderCountrySelects(root))
|
|
112
|
+
.catch((/** @type {unknown} */ error) => {
|
|
113
|
+
reportError(error, 'markets');
|
|
114
|
+
});
|
|
53
115
|
}
|
|
54
|
-
|
|
55
|
-
// Customer accounts: their own optional part, when configured and the page (or a login) needs it.
|
|
56
|
-
if ((cfg.account?.clientId || cfg.account?.server) && (within(root, ['account-login', 'account-bind', 'account-link', 'orders', 'order', 'addresses', 'subscriptions', 'profile-form', 'address-form'].map((n) => `[${attr(n)}]`).join(',') + `,[${attr('show')}^="logged-"]`).length
|
|
57
|
-
|| /[?&]code=/.test(window.location.search) || hasAccountSession())) {
|
|
58
|
-
loadPart('account').then((m) => m.mountAccount(root)).catch((err) => reportError(err, 'account'));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Search as you type: its own optional part, only for pages that have it.
|
|
116
|
+
mountAccountIfUsed(root);
|
|
62
117
|
if (within(root, sel('search')).length) {
|
|
63
|
-
loadPart('search')
|
|
118
|
+
loadPart('search')
|
|
119
|
+
.then((part) => {
|
|
120
|
+
part.mountSearch(root);
|
|
121
|
+
})
|
|
122
|
+
.catch((/** @type {unknown} */ error) => {
|
|
123
|
+
reportError(error, 'search');
|
|
124
|
+
});
|
|
64
125
|
}
|
|
126
|
+
}
|
|
65
127
|
|
|
66
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Mounts every product scope and product list inside `root`. Safe to call repeatedly.
|
|
130
|
+
*
|
|
131
|
+
* @param {ParentNode} [root]
|
|
132
|
+
* @returns {number} The scopes and lists newly mounted.
|
|
133
|
+
*/
|
|
134
|
+
export function mount(root = document) {
|
|
135
|
+
if (!isConfigured()) return 0;
|
|
136
|
+
if (root === document) rememberDocumentMount();
|
|
137
|
+
// Page transitions detach old roots: they aren't kept alive.
|
|
138
|
+
pruneLive();
|
|
139
|
+
// Products the server rendered into the page need no request.
|
|
140
|
+
hydrateFromPage(root.ownerDocument ?? document);
|
|
141
|
+
let count = mountLists(root);
|
|
142
|
+
within(root, sel('product')).forEach((el) => {
|
|
143
|
+
if (mountProduct(el)) count++;
|
|
144
|
+
});
|
|
145
|
+
mountParts(root);
|
|
67
146
|
settleMarket();
|
|
68
147
|
Cart.render();
|
|
69
148
|
log(`mounted ${count} scope(s)`, root);
|
|
70
149
|
return count;
|
|
71
150
|
}
|
|
72
151
|
|
|
73
|
-
|
|
74
|
-
// Markets: country select + switching
|
|
75
|
-
// ---------------------------------------------------------------------------
|
|
76
|
-
|
|
77
|
-
/** Call before a page transition removes `root` (closes the drawer etc.). */
|
|
152
|
+
/** Call before a page transition removes the page: closes the cart drawer. */
|
|
78
153
|
export function unmount() {
|
|
79
154
|
Cart.close();
|
|
80
155
|
}
|
|
81
156
|
|
|
157
|
+
/**
|
|
158
|
+
* @param {Element | null | undefined} el
|
|
159
|
+
* @returns {ProductScope | null} The product scope `el` is in.
|
|
160
|
+
*/
|
|
82
161
|
export function getScope(el) {
|
|
83
|
-
const root = el?.closest
|
|
84
|
-
return root ? scopes.get(root)
|
|
162
|
+
const root = el?.closest(sel('product'));
|
|
163
|
+
return root ? (scopes.get(root) ?? null) : null;
|
|
85
164
|
}
|
|
86
165
|
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
// Document-wide controls (delegated, so they survive page swaps)
|
|
89
|
-
// ---------------------------------------------------------------------------
|
|
90
|
-
|
|
91
|
-
let controlsBound = false;
|
|
92
|
-
|
|
93
166
|
function bindControls() {
|
|
94
167
|
if (controlsBound) return;
|
|
95
168
|
controlsBound = true;
|
|
169
|
+
bindCartControls();
|
|
170
|
+
bindDiscountControls();
|
|
171
|
+
bindExtrasControls();
|
|
172
|
+
bindNavigation({ ready: () => initPromise ?? Promise.resolve(null), mount, unmount });
|
|
173
|
+
}
|
|
96
174
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const clear = t.closest(sel('cart-clear'));
|
|
105
|
-
if (clear) { e.preventDefault(); busy(clear, () => Cart.clear().catch((err) => reportError(err, 'cart clear'))); return; }
|
|
106
|
-
|
|
107
|
-
const checkout = t.closest(sel('checkout'));
|
|
108
|
-
if (checkout) { e.preventDefault(); busy(checkout, () => Cart.checkout()); return; }
|
|
109
|
-
|
|
110
|
-
const line = t.closest(dataKeySel());
|
|
111
|
-
if (line && line.closest(sel('cart-lines'))) {
|
|
112
|
-
const id = line.getAttribute(`data-${cfg.prefix}-key`);
|
|
113
|
-
const current = Cart.lines.find((l) => l.id === id);
|
|
114
|
-
const run = (fn) => { e.preventDefault(); fn().catch((err) => reportError(err, 'cart line')); };
|
|
115
|
-
if (t.closest(sel('line-remove'))) { line.classList.add('is-busy'); return run(() => Cart.removeLine(id)); }
|
|
116
|
-
if (current && t.closest(sel('line-plus'))) return run(() => Cart.changeQuantity(id, 1));
|
|
117
|
-
if (current && t.closest(sel('line-minus'))) return run(() => Cart.changeQuantity(id, -1));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const removeCode = t.closest(sel('discount-remove'));
|
|
121
|
-
if (removeCode) {
|
|
122
|
-
e.preventDefault();
|
|
123
|
-
const code = removeCode.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
|
|
124
|
-
if (code) Cart.removeDiscount(code).catch((err) => reportError(err, 'discount'));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const groupBtn = t.closest(sel('group-add'));
|
|
129
|
-
if (groupBtn) { e.preventDefault(); busy(groupBtn, () => addGroup(groupBtn)); }
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
document.addEventListener('change', (e) => {
|
|
133
|
-
const t = e.target;
|
|
134
|
-
if (!t?.closest) return;
|
|
135
|
-
const lineEl = t.closest(dataKeySel());
|
|
136
|
-
const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
|
|
137
|
-
// The line shows is-busy / aria-busy until Shopify has answered (removed by the next render).
|
|
138
|
-
const busyLine = (p, what) => { lineEl.classList.add('is-busy'); lineEl.setAttribute('aria-busy', 'true'); p.catch((err) => reportError(err, what)); };
|
|
139
|
-
if (id && t.matches(`input${sel('line', 'quantity')}`)) {
|
|
140
|
-
busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), 'cart line');
|
|
141
|
-
} else if (id && t.matches(`select${sel('line-plan')}`)) {
|
|
142
|
-
busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), 'cart line plan');
|
|
143
|
-
} else if (t.matches(`select${sel('country-select')}`)) {
|
|
144
|
-
if (t.value) setCountry(t.value).catch((err) => reportError(err, 'country'));
|
|
145
|
-
} else if (t.matches(sel('cart-note'))) {
|
|
146
|
-
Cart.setNote(t.value).catch((err) => reportError(err, 'cart note'));
|
|
147
|
-
}
|
|
175
|
+
/** Buffers Tether's events until the analytics part has loaded, so none are missed. */
|
|
176
|
+
function startAnalyticsQueue() {
|
|
177
|
+
/** @type {Event[]} */
|
|
178
|
+
const queue = [];
|
|
179
|
+
analyticsQueue = queue;
|
|
180
|
+
ANALYTICS_EVENTS.forEach((name) => {
|
|
181
|
+
document.addEventListener(`${cfg.prefix}:${name}`, (event) => queue.push(event));
|
|
148
182
|
});
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const input = form.querySelector(sel('discount-input')) || form.querySelector('input');
|
|
155
|
-
const code = input?.value.trim();
|
|
156
|
-
if (!code) return;
|
|
157
|
-
busy(form, async () => {
|
|
158
|
-
const mark = (bad) => {
|
|
159
|
-
form.classList.toggle('is-invalid', bad);
|
|
160
|
-
if (input) { if (bad) input.setAttribute('aria-invalid', 'true'); else input.removeAttribute('aria-invalid'); }
|
|
161
|
-
};
|
|
162
|
-
try {
|
|
163
|
-
await Cart.applyDiscount(code);
|
|
164
|
-
mark(false);
|
|
165
|
-
emit('discount-applied', { code }, form);
|
|
166
|
-
if (input) input.value = '';
|
|
167
|
-
} catch (err) {
|
|
168
|
-
mark(true);
|
|
169
|
-
if (err?.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form); else reportError(err, 'discount');
|
|
170
|
-
}
|
|
183
|
+
document.addEventListener('astro:page-load', () => queue.push(new CustomEvent(`${cfg.prefix}:page`)));
|
|
184
|
+
loadPart('analytics')
|
|
185
|
+
.then((part) => part.startAnalytics(queue))
|
|
186
|
+
.catch((/** @type {unknown} */ error) => {
|
|
187
|
+
reportError(error, 'analytics');
|
|
171
188
|
});
|
|
172
|
-
|
|
173
|
-
document.addEventListener('input', (e) => {
|
|
174
|
-
const f = e.target.closest?.(sel('discount-form'));
|
|
175
|
-
if (f) { f.classList.remove('is-invalid'); e.target.removeAttribute?.('aria-invalid'); }
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// Other tabs: the cart or the market changed there (handled by the extras part, loaded then).
|
|
179
|
-
window.addEventListener('storage', (e) => {
|
|
180
|
-
if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart('extras').then((m) => m.onStorage(e)).catch(() => {});
|
|
181
|
-
});
|
|
182
|
-
// Shareable variant links: only when turned on.
|
|
183
|
-
document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
|
|
184
|
-
const root = e.target;
|
|
185
|
-
if (cfg.variantUrl || root.hasAttribute?.(attr('variant-url'))) loadPart('extras').then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {});
|
|
186
|
-
});
|
|
189
|
+
}
|
|
187
190
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
else if (mountedBody !== document.body) mount(document); // the first load is already mounted: no second round of requests
|
|
204
|
-
}));
|
|
205
|
-
document.addEventListener('astro:before-swap', () => { mountedBody = null; unmount(); });
|
|
191
|
+
/**
|
|
192
|
+
* A saved cart from another market moves to this one, so its prices match the page's.
|
|
193
|
+
*
|
|
194
|
+
* @param {ShopifyCart | null} cart
|
|
195
|
+
* @returns {Promise<ShopifyCart | null>}
|
|
196
|
+
*/
|
|
197
|
+
async function cartInPageMarket(cart) {
|
|
198
|
+
const country = cfg.country?.toUpperCase();
|
|
199
|
+
const cartCountry = cart?.buyerIdentity.countryCode;
|
|
200
|
+
if (!cart || !country || !cartCountry || cartCountry === country) return cart;
|
|
201
|
+
try {
|
|
202
|
+
return await Cart.setBuyerIdentity({ countryCode: country }, { create: false });
|
|
203
|
+
} catch (error) {
|
|
204
|
+
reportError(error, 'cart country');
|
|
205
|
+
return cart;
|
|
206
206
|
}
|
|
207
|
-
|
|
208
|
-
// Back online after a failed load: try the failed products and lists again.
|
|
209
|
-
window.addEventListener('online', () => {
|
|
210
|
-
retryFailed();
|
|
211
|
-
liveLists.forEach((l) => { if (l.root.getAttribute(attr('state')) === 'error') l.load(); });
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
document.addEventListener('keydown', (e) => {
|
|
215
|
-
if (!Cart.isOpen()) return;
|
|
216
|
-
if (e.key === 'Escape') { Cart.close(); return; }
|
|
217
|
-
if (e.key !== 'Tab' || cfg.cartFocus === false) return;
|
|
218
|
-
// Keep Tab inside the open cart.
|
|
219
|
-
const d = Cart.drawers().find((x) => x.classList.contains(cfg.cartDrawerOpenClass));
|
|
220
|
-
const f = d ? focusables(d) : [];
|
|
221
|
-
if (!f.length) return;
|
|
222
|
-
const first = f[0];
|
|
223
|
-
const last = f[f.length - 1];
|
|
224
|
-
if (!d.contains(document.activeElement) || (e.shiftKey && document.activeElement === first) || (!e.shiftKey && document.activeElement === last)) {
|
|
225
|
-
e.preventDefault();
|
|
226
|
-
(e.shiftKey ? last : first).focus();
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
207
|
}
|
|
230
208
|
|
|
231
|
-
function dataKeySel() { return `[data-${cfg.prefix}-key]`; }
|
|
232
|
-
|
|
233
209
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
210
|
+
* Configures Tether (`window.TETHER_CONFIG` with `overrides` over it), applies the visitor's market and loads the
|
|
211
|
+
* cart. A second call without overrides returns the first one's promise.
|
|
212
|
+
*
|
|
213
|
+
* @param {TetherOptions} [overrides]
|
|
214
|
+
* @returns {Promise<TetherApi | null>} `null` without a domain and token, when Tether stays idle.
|
|
237
215
|
*/
|
|
238
|
-
export const addGroup = (btn) => loadPart('extras').then((m) => m.addGroup(btn));
|
|
239
|
-
|
|
240
|
-
// ---------------------------------------------------------------------------
|
|
241
|
-
// Init
|
|
242
|
-
// ---------------------------------------------------------------------------
|
|
243
|
-
|
|
244
|
-
let analyticsQueue = null;
|
|
245
|
-
|
|
246
216
|
export function init(overrides = {}) {
|
|
247
217
|
if (initPromise && !Object.keys(overrides).length) return initPromise;
|
|
248
|
-
configure({ ...(
|
|
249
|
-
|
|
218
|
+
configure({ ...pageGlobals().TETHER_CONFIG, ...overrides });
|
|
250
219
|
if (!isConfigured()) {
|
|
251
220
|
warn('TETHER_CONFIG is missing domain or token. Tether is idle.');
|
|
252
221
|
initPromise = Promise.resolve(null);
|
|
253
222
|
return initPromise;
|
|
254
223
|
}
|
|
255
|
-
|
|
256
224
|
bindControls();
|
|
257
225
|
if (cfg.observe) startObserver();
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
226
|
+
if (cfg.analytics && !analyticsQueue) startAnalyticsQueue();
|
|
227
|
+
initPromise = applyStartMarket(overrides)
|
|
228
|
+
.then(() => Cart.init())
|
|
229
|
+
.then(cartInPageMarket)
|
|
230
|
+
.then((cart) => {
|
|
231
|
+
emit('ready', { cart });
|
|
232
|
+
return api;
|
|
233
|
+
});
|
|
234
|
+
return initPromise;
|
|
235
|
+
}
|
|
265
236
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
237
|
+
/** `observe`: mounts the elements added to the page later. */
|
|
238
|
+
function startObserver() {
|
|
239
|
+
if (observer || typeof MutationObserver === 'undefined') return;
|
|
240
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
241
|
+
let timer;
|
|
242
|
+
/** @type {Set<Element>} */
|
|
243
|
+
const queued = new Set();
|
|
244
|
+
observer = new MutationObserver((mutations) => {
|
|
245
|
+
mutations.forEach((mutation) => {
|
|
246
|
+
mutation.addedNodes.forEach((node) => {
|
|
247
|
+
if (node instanceof window.Element) queued.add(node);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
timer = setTimeout(() => {
|
|
252
|
+
queued.forEach((node) => {
|
|
253
|
+
if (node.isConnected) mount(node);
|
|
254
|
+
});
|
|
255
|
+
queued.clear();
|
|
256
|
+
}, OBSERVE_DEBOUNCE_MS);
|
|
274
257
|
});
|
|
275
|
-
|
|
258
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
276
259
|
}
|
|
277
260
|
|
|
278
261
|
/**
|
|
279
|
-
*
|
|
280
|
-
* Without markets.detect only the visitor's saved choice applies, read here without loading
|
|
281
|
-
* the markets part.
|
|
282
|
-
*/
|
|
283
|
-
/**
|
|
284
|
-
* A page built for one market (e.g. /de/ pages): <meta name="tether-page-country" content="DE">,
|
|
285
|
-
* tether-page-language, tether-page-locale. Wins over the visitor's saved choice, isn't remembered.
|
|
262
|
+
* @param {MarketsConfig | null} [markets] Default `markets` of the config.
|
|
286
263
|
*/
|
|
287
|
-
|
|
288
|
-
const get = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute('content') || undefined;
|
|
289
|
-
const country = get('country');
|
|
290
|
-
return country ? { country: country.toUpperCase(), language: get('language'), locale: get('locale') } : null;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let baseMarket = null; // the configured market, before a visitor's or page's market applied
|
|
294
|
-
let onPageMarket = false;
|
|
295
|
-
|
|
296
|
-
async function applyMarket(overrides) {
|
|
297
|
-
const m = cfg.markets || {};
|
|
298
|
-
baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
|
|
299
|
-
const page = pageMarket();
|
|
300
|
-
onPageMarket = Boolean(page);
|
|
301
|
-
if (page) {
|
|
302
|
-
cfg.country = page.country;
|
|
303
|
-
cfg.language = page.language ? page.language.toUpperCase() : undefined;
|
|
304
|
-
if (page.locale) cfg.locale = page.locale;
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
if (m.detect === false) return;
|
|
308
|
-
let market = null;
|
|
309
|
-
if (m.detect) {
|
|
310
|
-
try { market = (await loadPart('markets')).detectMarket(m); } catch (err) { reportError(err, 'markets'); return; }
|
|
311
|
-
} else if (m.remember !== false) {
|
|
312
|
-
try { market = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || 'null'); } catch { /* none */ }
|
|
313
|
-
if (market && !/^[A-Z]{2}$/.test(market.country || '')) market = null;
|
|
314
|
-
}
|
|
315
|
-
if (!market) return;
|
|
316
|
-
if (market.country) cfg.country = market.country;
|
|
317
|
-
if (market.language) cfg.language = market.language;
|
|
318
|
-
if (market.locale && !overrides.locale) cfg.locale = market.locale; // the visitor's market wins over the default
|
|
319
|
-
log('market', market);
|
|
320
|
-
}
|
|
264
|
+
const detectMarket = (markets) => loadPart('markets').then((part) => part.detectMarket(markets ?? cfg.markets));
|
|
321
265
|
|
|
322
|
-
|
|
323
|
-
try { return Boolean(sessionStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account-hint:${cfg.domain}`]); } catch { return false; }
|
|
324
|
-
}
|
|
266
|
+
const getLocalization = () => loadPart('markets').then((part) => part.getLocalization());
|
|
325
267
|
|
|
326
|
-
/**
|
|
327
|
-
const
|
|
268
|
+
/** @param {Omit<DiagnoseOptions, 'version'>} [options] */
|
|
269
|
+
const diagnose = (options) => loadPart('diagnose').then((part) => part.diagnose({ ...options, version: VERSION }));
|
|
328
270
|
|
|
329
|
-
/**
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
});
|
|
343
|
-
|
|
271
|
+
/**
|
|
272
|
+
* Listens to the Tether event `<prefix>:<name>`.
|
|
273
|
+
*
|
|
274
|
+
* @param {string} name
|
|
275
|
+
* @param {(detail: unknown, event: CustomEvent) => void} listener
|
|
276
|
+
* @returns {() => void} Stops listening.
|
|
277
|
+
*/
|
|
278
|
+
function on(name, listener) {
|
|
279
|
+
/** @param {Event} event */
|
|
280
|
+
const handler = (event) => {
|
|
281
|
+
const custom = /** @type {CustomEvent} Tether's events are CustomEvents. */ (event);
|
|
282
|
+
listener(custom.detail, custom);
|
|
283
|
+
};
|
|
284
|
+
document.addEventListener(`${cfg.prefix}:${name}`, handler);
|
|
285
|
+
return () => {
|
|
286
|
+
document.removeEventListener(`${cfg.prefix}:${name}`, handler);
|
|
287
|
+
};
|
|
344
288
|
}
|
|
345
289
|
|
|
346
290
|
export const api = {
|
|
@@ -348,8 +292,14 @@ export const api = {
|
|
|
348
292
|
init,
|
|
349
293
|
mount,
|
|
350
294
|
unmount,
|
|
351
|
-
|
|
352
|
-
get
|
|
295
|
+
/** @returns {TetherConfig} */
|
|
296
|
+
get config() {
|
|
297
|
+
return cfg;
|
|
298
|
+
},
|
|
299
|
+
/** @returns {Promise<TetherApi | null>} */
|
|
300
|
+
get ready() {
|
|
301
|
+
return initPromise ?? Promise.resolve(null);
|
|
302
|
+
},
|
|
353
303
|
query: gql,
|
|
354
304
|
withContext,
|
|
355
305
|
cart: Cart,
|
|
@@ -368,28 +318,36 @@ export const api = {
|
|
|
368
318
|
TetherError,
|
|
369
319
|
resolveProductKey,
|
|
370
320
|
setCountry,
|
|
371
|
-
detectMarket
|
|
372
|
-
getLocalization
|
|
373
|
-
diagnose
|
|
321
|
+
detectMarket,
|
|
322
|
+
getLocalization,
|
|
323
|
+
diagnose,
|
|
374
324
|
__core: coreModules,
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
document.addEventListener(`${cfg.prefix}:${name}`, h);
|
|
379
|
-
return () => document.removeEventListener(`${cfg.prefix}:${name}`, h);
|
|
380
|
-
},
|
|
325
|
+
// The `<script>` build's parts register here when their file has loaded.
|
|
326
|
+
__parts: /** @type {Record<string, unknown>} */ ({}),
|
|
327
|
+
on,
|
|
381
328
|
};
|
|
382
329
|
|
|
330
|
+
/** @typedef {typeof api} TetherApi */
|
|
331
|
+
|
|
383
332
|
export default api;
|
|
384
333
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
334
|
+
/**
|
|
335
|
+
* The plain `<script>` setup starts by itself, unless `autoMount: false` or a framework integration (Astro), which
|
|
336
|
+
* starts Tether itself.
|
|
337
|
+
*/
|
|
338
|
+
function autoStart() {
|
|
339
|
+
const globals = pageGlobals();
|
|
340
|
+
globals.Tether = api;
|
|
341
|
+
if (!globals.TETHER_CONFIG || globals.TETHER_CONFIG.autoMount === false || globals.TETHER_INTEGRATION) return;
|
|
342
|
+
const start = () => {
|
|
343
|
+
init()
|
|
344
|
+
.then(() => mount(document))
|
|
345
|
+
.catch((/** @type {unknown} */ error) => {
|
|
346
|
+
reportError(error, 'init');
|
|
347
|
+
});
|
|
348
|
+
};
|
|
349
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start, { once: true });
|
|
350
|
+
else start();
|
|
395
351
|
}
|
|
352
|
+
|
|
353
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') autoStart();
|