@occluze/tether 0.6.7 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-UIPWJQ3Y.js +1 -0
- package/dist/esm/chunks/account-pages-6ROA6Y4V.js +1 -0
- package/dist/esm/chunks/analytics-2V632IRV.js +1 -0
- package/dist/esm/chunks/chunk-4GCXM764.js +1 -0
- package/dist/esm/chunks/chunk-56FCLJJD.js +1 -0
- package/dist/esm/chunks/chunk-5Z5O3767.js +1 -0
- package/dist/esm/chunks/chunk-6OZJCAGZ.js +1 -0
- package/dist/esm/chunks/chunk-A5SYCVQM.js +1 -0
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +1 -0
- package/dist/esm/chunks/chunk-BFTZR4OB.js +4 -0
- package/dist/esm/chunks/chunk-C5JYQCWH.js +1 -0
- package/dist/esm/chunks/chunk-E5YO6274.js +1 -0
- package/dist/esm/chunks/chunk-MPDWM2NN.js +1 -0
- package/dist/esm/chunks/chunk-T44MUL4Y.js +1 -0
- package/dist/esm/chunks/chunk-YLUULATY.js +1 -0
- package/dist/esm/chunks/diagnose-3FEKRIEK.js +14 -0
- package/dist/esm/chunks/extras-6POFBNOP.js +1 -0
- package/dist/esm/chunks/markets-ui-FD6NI2OG.js +1 -0
- package/dist/esm/chunks/search-UJ73YBQ5.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +55 -39
- package/dist/tether.account-pages.js +781 -495
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +462 -381
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +299 -194
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +354 -262
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +82 -45
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3653 -2116
- package/dist/tether.markets.js +238 -150
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +207 -136
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/account-orders.d.ts +44 -0
- package/dist/types/server/src/shared/account-routes.d.ts +153 -0
- package/dist/types/server/src/shared/api-versions.d.ts +13 -0
- package/dist/types/server/src/shared/error-message.d.ts +2 -0
- package/dist/types/server/src/shared/json.d.ts +25 -0
- package/dist/types/server/src/shared/market-codes.d.ts +16 -0
- package/dist/types/src/account/addresses-view.d.ts +5 -0
- package/dist/types/src/account/answers.d.ts +40 -0
- package/dist/types/src/account/markup.d.ts +19 -0
- package/dist/types/src/account/operations.d.ts +41 -0
- package/dist/types/src/account/order-lines.d.ts +13 -0
- package/dist/types/src/account/order-list.d.ts +12 -0
- package/dist/types/src/account/order-page.d.ts +6 -0
- package/dist/types/src/account/page-answers.d.ts +182 -0
- package/dist/types/src/account/page-events.d.ts +2 -0
- package/dist/types/src/account/server-client.d.ts +60 -0
- package/dist/types/src/account/subscriptions-view.d.ts +2 -0
- package/dist/types/src/account-api.d.ts +18 -0
- package/dist/types/src/account-marks.d.ts +15 -0
- package/dist/types/src/account-pages.d.ts +8 -0
- package/dist/types/src/account.d.ts +64 -0
- package/dist/types/src/amounts.d.ts +12 -0
- package/dist/types/src/analytics-shopify.d.ts +41 -0
- package/dist/types/src/analytics.d.ts +2 -0
- package/dist/types/src/api-version.d.ts +28 -0
- package/dist/types/src/astro.d.ts +42 -0
- package/dist/types/src/cart/cart-shape.d.ts +43 -0
- package/dist/types/src/cart/checkout.d.ts +16 -0
- package/dist/types/src/cart/drawer.d.ts +18 -0
- package/dist/types/src/cart/mutations.d.ts +79 -0
- package/dist/types/src/cart/render.d.ts +17 -0
- package/dist/types/src/cart/store.d.ts +182 -0
- package/dist/types/src/cart.d.ts +106 -0
- package/dist/types/src/client.d.ts +100 -0
- package/dist/types/src/collection.d.ts +25 -0
- package/dist/types/src/config-sections.d.ts +40 -0
- package/dist/types/src/config.d.ts +185 -0
- package/dist/types/src/controls/cart-controls.d.ts +2 -0
- package/dist/types/src/controls/discount.d.ts +2 -0
- package/dist/types/src/controls/extras.d.ts +5 -0
- package/dist/types/src/controls/navigation.d.ts +12 -0
- package/dist/types/src/diagnose-markup.d.ts +19 -0
- package/dist/types/src/diagnose.d.ts +60 -0
- package/dist/types/src/dom.d.ts +75 -0
- package/dist/types/src/extras.d.ts +21 -0
- package/dist/types/src/fragments.d.ts +158 -0
- package/dist/types/src/globals.d.ts +15 -0
- package/dist/types/src/images.d.ts +20 -0
- package/dist/types/src/index.d.ts +167 -0
- package/dist/types/src/initial.d.ts +56 -0
- package/dist/types/src/lazy.d.ts +28 -0
- package/dist/types/src/loader.d.ts +84 -0
- package/dist/types/src/market-codes.d.ts +6 -0
- package/dist/types/src/markets-ui.d.ts +32 -0
- package/dist/types/src/markets.d.ts +72 -0
- package/dist/types/src/markup.d.ts +131 -0
- package/dist/types/src/mounting.d.ts +46 -0
- package/dist/types/src/page-market.d.ts +27 -0
- package/dist/types/src/presets/barba.d.ts +56 -0
- package/dist/types/src/product-events.d.ts +2 -0
- package/dist/types/src/product-render.d.ts +17 -0
- package/dist/types/src/product-shape.d.ts +88 -0
- package/dist/types/src/product-state.d.ts +110 -0
- package/dist/types/src/product.d.ts +66 -0
- package/dist/types/src/repeaters.d.ts +16 -0
- package/dist/types/src/search.d.ts +33 -0
- package/dist/types/src/selling-plans.d.ts +64 -0
- package/dist/types/src/server.d.ts +88 -0
- package/dist/types/src/shopify-values.d.ts +46 -0
- package/dist/types/src/status.d.ts +33 -0
- package/dist/types/src/storage.d.ts +12 -0
- package/dist/types/src/store-html.d.ts +39 -0
- package/dist/types/src/storefront/catalog.d.ts +38 -0
- package/dist/types/src/storefront/json-ld.d.ts +20 -0
- package/dist/types/src/variant-render.d.ts +14 -0
- package/package.json +42 -22
- package/src/account/addresses-view.ts +58 -0
- package/src/account/answers.ts +79 -0
- package/src/account/markup.ts +36 -0
- package/src/account/operations.ts +145 -0
- package/src/account/order-lines.ts +75 -0
- package/src/account/order-list.ts +107 -0
- package/src/account/order-page.ts +137 -0
- package/src/account/page-answers.ts +317 -0
- package/src/account/page-events.ts +253 -0
- package/src/account/server-client.ts +183 -0
- package/src/account/subscriptions-view.ts +115 -0
- package/src/account-api.ts +98 -0
- package/src/account-marks.ts +35 -0
- package/src/account-pages.ts +81 -0
- package/src/account.ts +284 -0
- package/src/amounts.ts +64 -0
- package/src/analytics-shopify.ts +246 -0
- package/src/analytics.ts +315 -0
- package/src/api-version.ts +79 -0
- package/src/astro.ts +272 -0
- package/src/cart/cart-shape.ts +25 -0
- package/src/cart/checkout.ts +101 -0
- package/src/cart/drawer.ts +125 -0
- package/src/cart/mutations.ts +237 -0
- package/src/cart/render.ts +266 -0
- package/src/cart/store.ts +279 -0
- package/src/cart.ts +345 -0
- package/src/client.ts +391 -0
- package/src/collection.ts +175 -0
- package/src/config-sections.ts +284 -0
- package/src/config.ts +480 -0
- package/src/controls/cart-controls.ts +123 -0
- package/src/controls/discount.ts +75 -0
- package/src/controls/extras.ts +54 -0
- package/src/controls/navigation.ts +77 -0
- package/src/diagnose-markup.ts +298 -0
- package/src/diagnose.ts +294 -0
- package/src/dom.ts +268 -0
- package/src/extras.ts +136 -0
- package/src/fragments.ts +261 -0
- package/src/globals.d.ts +15 -0
- package/src/images.ts +91 -0
- package/src/index.ts +415 -0
- package/src/initial.ts +101 -0
- package/src/lazy-iife.ts +197 -0
- package/src/lazy.ts +42 -0
- package/src/loader.ts +363 -0
- package/src/market-codes.ts +6 -0
- package/src/markets-ui.ts +227 -0
- package/src/markets.ts +246 -0
- package/src/markup.ts +127 -0
- package/src/mounting.ts +264 -0
- package/src/page-market.ts +110 -0
- package/src/part-integrity.ts +7 -0
- package/src/parts/account-pages.ts +7 -0
- package/src/parts/account.ts +7 -0
- package/src/parts/analytics.ts +7 -0
- package/src/parts/diagnose.ts +7 -0
- package/src/parts/extras.ts +7 -0
- package/src/parts/markets.ts +7 -0
- package/src/parts/search.ts +7 -0
- package/src/presets/barba.ts +194 -0
- package/src/product-events.ts +115 -0
- package/src/product-render.ts +218 -0
- package/src/product-shape.ts +49 -0
- package/src/product-state.ts +318 -0
- package/src/product.ts +308 -0
- package/src/repeaters.ts +161 -0
- package/src/search.ts +305 -0
- package/src/selling-plans.ts +145 -0
- package/src/server.ts +211 -0
- package/src/shopify-values.ts +91 -0
- package/src/status.ts +106 -0
- package/src/storage.ts +34 -0
- package/src/store-html.ts +359 -0
- package/src/storefront/catalog.ts +196 -0
- package/src/storefront/json-ld.ts +144 -0
- package/src/variant-render.ts +139 -0
- 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/account-pages.js +0 -318
- package/src/account.js +0 -385
- package/src/analytics.js +0 -232
- package/src/api-version.js +0 -35
- package/src/astro.js +0 -101
- package/src/cart.js +0 -559
- package/src/client.js +0 -131
- package/src/collection.js +0 -114
- package/src/config.js +0 -85
- package/src/diagnose.js +0 -246
- package/src/dom.js +0 -288
- package/src/extras.js +0 -63
- package/src/fragments.js +0 -144
- package/src/index.js +0 -395
- package/src/initial.js +0 -75
- package/src/lazy-iife.js +0 -46
- package/src/lazy.js +0 -18
- package/src/loader.js +0 -198
- package/src/markets-ui.js +0 -90
- package/src/markets.js +0 -133
- package/src/mounting.js +0 -154
- package/src/parts/account-pages.js +0 -3
- package/src/parts/account.js +0 -3
- package/src/parts/analytics.js +0 -3
- package/src/parts/diagnose.js +0 -3
- package/src/parts/extras.js +0 -3
- package/src/parts/markets.js +0 -3
- package/src/parts/search.js +0 -3
- package/src/presets/barba.js +0 -100
- package/src/product.js +0 -570
- package/src/search.js +0 -153
- package/src/selling-plans.js +0 -94
- package/src/server.js +0 -252
- package/src/status.js +0 -66
- package/types/astro.d.ts +0 -17
- package/types/barba.d.ts +0 -16
- package/types/index.d.ts +0 -426
- package/types/server.d.ts +0 -110
package/src/account.js
DELETED
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Customer accounts (Shopify Customer Account API), as an optional part: loaded only when
|
|
3
|
-
* config.account is set and the page uses it (or a login is in progress / the visitor is logged in).
|
|
4
|
-
*
|
|
5
|
-
* window.TETHER_CONFIG = { …, account: { clientId: '…' } } // Headless channel → Customer Account API
|
|
6
|
-
*
|
|
7
|
-
* <button tether-account-login>Log in</button>
|
|
8
|
-
* <button tether-account-logout tether-show="logged-in">Log out</button>
|
|
9
|
-
* <span tether-account-bind="first-name"></span> first-name, last-name, name, email
|
|
10
|
-
* <div tether-orders> the customer's orders (first child = template)
|
|
11
|
-
* <a tether-order-bind="url"><span tether-order-bind="name"></span> · <span tether-order-bind="date"></span> ·
|
|
12
|
-
* <span tether-order-bind="total"></span> · <span tether-order-bind="status"></span></a>
|
|
13
|
-
* </div>
|
|
14
|
-
* <p tether-show="logged-out">…</p>
|
|
15
|
-
*
|
|
16
|
-
* Everything runs between the visitor's browser and Shopify: login on Shopify's own page (OAuth with
|
|
17
|
-
* PKCE), tokens kept in this browser (window.sessionStorage; account.remember: true for window.localStorage), the
|
|
18
|
-
* cart linked to the customer so checkout knows them. No other server sees the customer's data.
|
|
19
|
-
* Shopify needs HTTPS (no localhost: use a tunnel) and the site's origin, callback and logout URLs
|
|
20
|
-
* registered in the Headless channel's Customer Account API settings (client type: Public).
|
|
21
|
-
*/
|
|
22
|
-
import { cfg, log, reportError } from './config.js';
|
|
23
|
-
import { TetherError } from './client.js';
|
|
24
|
-
import { attr, sel, get, qsa, setText, setImage, repeat, show, formatMoney, emit } from './dom.js';
|
|
25
|
-
import { Cart } from './cart.js';
|
|
26
|
-
import { loadPart } from './lazy.js';
|
|
27
|
-
|
|
28
|
-
const KEY = () => `${cfg.prefix}:account:${cfg.domain}`;
|
|
29
|
-
const PENDING = () => `${cfg.prefix}:account-login:${cfg.domain}`;
|
|
30
|
-
const store = () => (cfg.account?.remember ? window.localStorage : window.sessionStorage);
|
|
31
|
-
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
32
|
-
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
33
|
-
const sha256 = async (s) => b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));
|
|
34
|
-
const go = (url) => (cfg.account?.navigate || ((u) => window.location.assign(u)))(url); // overridable (popup, tests)
|
|
35
|
-
const read = (s, k) => { try { return JSON.parse(s.getItem(k) || 'null'); } catch { return null; } };
|
|
36
|
-
const write = (s, k, v) => { try { if (v) s.setItem(k, JSON.stringify(v)); else s.removeItem(k); } catch { /* private mode */ } };
|
|
37
|
-
|
|
38
|
-
let discovery = null;
|
|
39
|
-
let session = null; // { access, expiresAt, refresh, idToken }
|
|
40
|
-
let refreshing = null;
|
|
41
|
-
let customerCache = null;
|
|
42
|
-
|
|
43
|
-
// Server mode (account.server): the Worker holds the tokens, this browser only a session cookie it
|
|
44
|
-
// can't read. Recommended for live sites.
|
|
45
|
-
// A full URL or a path on this site ('/_tether/account').
|
|
46
|
-
const server = () => (cfg.account?.server ? new URL(String(cfg.account.server), window.location.href).href.replace(/\/$/, '') : '');
|
|
47
|
-
const HINT = () => `${cfg.prefix}:account-hint:${cfg.domain}`; // "may be logged in": loads this part, no secret
|
|
48
|
-
let serverState = null; // { loggedIn, customer }
|
|
49
|
-
async function serverFetch(path, { method = 'GET', body } = {}) {
|
|
50
|
-
const res = await fetch(`${server()}${path}`, {
|
|
51
|
-
method, credentials: 'include',
|
|
52
|
-
headers: method === 'POST' ? { 'Content-Type': 'application/json', 'X-Tether-Account': '1' } : {},
|
|
53
|
-
body: body ? JSON.stringify(body) : undefined,
|
|
54
|
-
});
|
|
55
|
-
const data = await res.json().catch(() => ({}));
|
|
56
|
-
if (!res.ok && res.status !== 401) throw new TetherError(`Customer accounts: ${data.error || res.status}`, { status: res.status, userErrors: data.userErrors });
|
|
57
|
-
return data;
|
|
58
|
-
}
|
|
59
|
-
async function serverMe() {
|
|
60
|
-
serverState ||= await serverFetch('/me');
|
|
61
|
-
try { if (serverState.loggedIn) window.localStorage.setItem(HINT(), '1'); else window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
|
|
62
|
-
return serverState;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Shopify's endpoints for this shop (they differ for shops with their own accounts domain). */
|
|
66
|
-
async function discover() {
|
|
67
|
-
if (discovery) return discovery;
|
|
68
|
-
const shop = cfg.account?.shopDomain || cfg.domain;
|
|
69
|
-
const get = (p) => fetch(`https://${shop}/.well-known/${p}`).then((r) => {
|
|
70
|
-
if (!r.ok) throw new TetherError(`Customer accounts: ${p} answered ${r.status}`, { status: r.status });
|
|
71
|
-
return r.json();
|
|
72
|
-
});
|
|
73
|
-
const [oidc, api] = await Promise.all([get('openid-configuration'), get('customer-account-api')]);
|
|
74
|
-
discovery = { ...oidc, graphql: api.graphql_api };
|
|
75
|
-
return discovery;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const callbackUrl = () => cfg.account?.callbackUrl || `${window.location.origin}${window.location.pathname}`;
|
|
79
|
-
|
|
80
|
-
export function isLoggedIn() { return server() ? Boolean(serverState?.loggedIn) : Boolean(session?.refresh || (session?.access && session.expiresAt > Date.now())); }
|
|
81
|
-
|
|
82
|
-
/** Redirects to Shopify's login page; the visitor comes back to account.callbackUrl (default: this page). */
|
|
83
|
-
export async function login({ returnTo = window.location.href, locale } = {}) {
|
|
84
|
-
if (server()) {
|
|
85
|
-
const u = new URL(`${server()}/login`);
|
|
86
|
-
u.searchParams.set('returnTo', returnTo); // the cart is linked later, through the Worker's protected POST /cart
|
|
87
|
-
const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
|
|
88
|
-
if (lang) u.searchParams.set('locale', lang);
|
|
89
|
-
if (cfg.country) u.searchParams.set('country', String(cfg.country).toUpperCase());
|
|
90
|
-
go(u.toString());
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
if (!cfg.account?.clientId) throw new TetherError('Customer accounts: set account.clientId', { code: 'NOT_CONFIGURED' });
|
|
94
|
-
const d = await discover();
|
|
95
|
-
const state = random();
|
|
96
|
-
const nonce = random();
|
|
97
|
-
const verifier = random();
|
|
98
|
-
const redirect = callbackUrl();
|
|
99
|
-
write(window.sessionStorage, PENDING(), { state, nonce, verifier, redirect, returnTo, at: Date.now() });
|
|
100
|
-
const u = new URL(d.authorization_endpoint);
|
|
101
|
-
const p = {
|
|
102
|
-
client_id: cfg.account.clientId, scope: 'openid email customer-account-api:full', response_type: 'code',
|
|
103
|
-
redirect_uri: redirect, state, nonce, code_challenge: await sha256(verifier), code_challenge_method: 'S256',
|
|
104
|
-
};
|
|
105
|
-
const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
|
|
106
|
-
if (lang) p.locale = lang;
|
|
107
|
-
if (cfg.country) p.region_country = String(cfg.country).toUpperCase();
|
|
108
|
-
Object.entries(p).forEach(([k, v]) => u.searchParams.set(k, v));
|
|
109
|
-
go(u.toString());
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Token endpoint: from the browser (a "simple" request), or account.tokenUrl on your own server. */
|
|
113
|
-
async function tokenRequest(params) {
|
|
114
|
-
const d = await discover();
|
|
115
|
-
const res = await fetch(cfg.account.tokenUrl || d.token_endpoint, { method: 'POST', body: new URLSearchParams(params) });
|
|
116
|
-
const data = await res.json().catch(() => ({}));
|
|
117
|
-
if (!res.ok || !data.access_token) throw new TetherError(`Customer accounts: token ${res.status} ${data.error || ''}`.trim(), { status: res.status, code: res.status === 400 || res.status === 401 ? 'SESSION_ENDED' : 'TOKEN_FAILED' });
|
|
118
|
-
return data;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function decodeJwt(t) {
|
|
122
|
-
try { return JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function keep(data, previous = session) {
|
|
126
|
-
session = {
|
|
127
|
-
access: data.access_token,
|
|
128
|
-
expiresAt: Date.now() + Math.max(0, (Number(data.expires_in) || 3600) - 120) * 1000,
|
|
129
|
-
refresh: data.refresh_token || previous?.refresh,
|
|
130
|
-
idToken: data.id_token || previous?.idToken,
|
|
131
|
-
};
|
|
132
|
-
write(store(), KEY(), session);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** On the callback page: ?code=&state= from Shopify. Returns true when a login finished. */
|
|
136
|
-
export async function handleCallback() {
|
|
137
|
-
const q = new URLSearchParams(window.location.search);
|
|
138
|
-
const pending = read(window.sessionStorage, PENDING());
|
|
139
|
-
if (!pending || (!q.has('code') && !q.has('error')) || q.get('state') !== pending.state) return false;
|
|
140
|
-
write(window.sessionStorage, PENDING(), null);
|
|
141
|
-
const clean = new URL(window.location.href);
|
|
142
|
-
['code', 'state', 'iss', 'error', 'error_description'].forEach((k) => clean.searchParams.delete(k));
|
|
143
|
-
window.history.replaceState(window.history.state, '', clean);
|
|
144
|
-
if (q.has('error')) { emit('account-error', { error: q.get('error') }); return false; }
|
|
145
|
-
if (Date.now() - pending.at > 10 * 60000) throw new TetherError('Customer accounts: the login took too long, please try again');
|
|
146
|
-
const data = await tokenRequest({
|
|
147
|
-
grant_type: 'authorization_code', client_id: cfg.account.clientId, redirect_uri: pending.redirect,
|
|
148
|
-
code: q.get('code'), code_verifier: pending.verifier,
|
|
149
|
-
});
|
|
150
|
-
const d = await discover();
|
|
151
|
-
const claims = decodeJwt(data.id_token || '');
|
|
152
|
-
const aud = [].concat(claims?.aud || []);
|
|
153
|
-
if (!claims || claims.nonce !== pending.nonce || claims.iss !== d.issuer || !aud.includes(cfg.account.clientId) || claims.exp * 1000 < Date.now()) {
|
|
154
|
-
throw new TetherError('Customer accounts: the login answer did not check out (nonce/issuer/audience)');
|
|
155
|
-
}
|
|
156
|
-
keep(data, null);
|
|
157
|
-
await linkCart();
|
|
158
|
-
emit('account-login', {});
|
|
159
|
-
const back = pending.returnTo && new URL(pending.returnTo, window.location.href);
|
|
160
|
-
if (back && back.origin === window.location.origin && back.href !== clean.href) go(back.href);
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** A valid access token (refreshed when needed, one refresh at a time), or null when logged out. */
|
|
165
|
-
export async function getToken() {
|
|
166
|
-
if (server()) return null; // server mode: tokens never come to the browser
|
|
167
|
-
if (!session) session = read(store(), KEY());
|
|
168
|
-
if (!session) return null;
|
|
169
|
-
if (session.access && session.expiresAt > Date.now()) return session.access;
|
|
170
|
-
if (!session.refresh) { endSession(); return null; }
|
|
171
|
-
refreshing ||= (async () => {
|
|
172
|
-
try {
|
|
173
|
-
keep(await tokenRequest({ grant_type: 'refresh_token', client_id: cfg.account.clientId, refresh_token: session.refresh }));
|
|
174
|
-
await linkCart();
|
|
175
|
-
} catch (err) {
|
|
176
|
-
if (err.code === 'SESSION_ENDED') endSession(); else throw err;
|
|
177
|
-
} finally { refreshing = null; }
|
|
178
|
-
})();
|
|
179
|
-
await refreshing;
|
|
180
|
-
return session?.access || null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function endSession() {
|
|
184
|
-
session = null;
|
|
185
|
-
customerCache = null;
|
|
186
|
-
write(store(), KEY(), null);
|
|
187
|
-
Cart.customerAccessToken = null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** The customer's checkout is theirs: saved addresses, vaulted cards, order window.history. */
|
|
191
|
-
async function linkCart() {
|
|
192
|
-
Cart.customerAccessToken = session?.access || null;
|
|
193
|
-
if (Cart.id() && session?.access) {
|
|
194
|
-
await Cart.setBuyerIdentity({ customerAccessToken: session.access }, { create: false }).catch((err) => reportError(err, 'account cart'));
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Any Customer Account API query. */
|
|
199
|
-
export async function query(q, variables = {}) {
|
|
200
|
-
if (server()) throw new TetherError('Customer accounts in server mode only answer customer() and orders()', { code: 'NOT_ALLOWED' });
|
|
201
|
-
const token = await getToken();
|
|
202
|
-
if (!token) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN' });
|
|
203
|
-
const d = await discover();
|
|
204
|
-
const run = (t) => fetch(d.graphql, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: t }, body: JSON.stringify({ query: q, variables }) });
|
|
205
|
-
let res = await run(token);
|
|
206
|
-
if (res.status === 401 && session?.refresh) { session.expiresAt = 0; res = await run(await getToken()); }
|
|
207
|
-
const json = await res.json().catch(() => ({}));
|
|
208
|
-
if (!res.ok || json.errors?.length) throw new TetherError(json.errors?.map((e) => e.message).join(' | ') || `Customer accounts: ${res.status}`, { status: res.status });
|
|
209
|
-
return json.data;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
export async function customer() {
|
|
213
|
-
if (server()) {
|
|
214
|
-
const me = await serverMe();
|
|
215
|
-
return me.loggedIn ? { ...me.customer, emailAddress: { emailAddress: me.customer.email }, defaultAddress: { formatted: me.customer.address } } : null;
|
|
216
|
-
}
|
|
217
|
-
if (customerCache) return customerCache;
|
|
218
|
-
const data = await query(`query TetherCustomer { customer { id firstName lastName displayName emailAddress { emailAddress } defaultAddress { formatted } } }`);
|
|
219
|
-
customerCache = data.customer;
|
|
220
|
-
return customerCache;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export async function orders({ first = 10 } = {}) {
|
|
224
|
-
if (server()) return (await serverFetch(`/orders?first=${first}`)).orders || [];
|
|
225
|
-
const data = await query(`query TetherOrders($first: Int!) { customer { orders(first: $first, sortKey: PROCESSED_AT, reverse: true) {
|
|
226
|
-
nodes { id name processedAt financialStatus fulfillmentStatus statusPageUrl totalPrice { amount currencyCode }
|
|
227
|
-
lineItems(first: 20) { nodes { title variantTitle quantity image { url altText } } } } } } }`, { first });
|
|
228
|
-
return data.customer?.orders?.nodes || [];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
/** Logs out here and at Shopify, unlinks the cart. */
|
|
233
|
-
export async function logout({ returnTo = cfg.account?.logoutUrl || `${window.location.origin}/` } = {}) {
|
|
234
|
-
if (server()) {
|
|
235
|
-
const r = await serverFetch('/logout', { method: 'POST', body: { returnTo, cartId: Cart.id() } });
|
|
236
|
-
serverState = { loggedIn: false };
|
|
237
|
-
try { window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
|
|
238
|
-
emit('account-logout', {});
|
|
239
|
-
if (r.logoutUrl) go(r.logoutUrl); else render(document);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
const idToken = session?.idToken || read(store(), KEY())?.idToken;
|
|
243
|
-
if (Cart.id()) await Cart.setBuyerIdentity({ customerAccessToken: null }, { create: false }).catch(() => {});
|
|
244
|
-
endSession();
|
|
245
|
-
emit('account-logout', {});
|
|
246
|
-
if (!idToken) { render(document); return; }
|
|
247
|
-
const d = await discover();
|
|
248
|
-
const u = new URL(d.end_session_endpoint);
|
|
249
|
-
u.searchParams.set('id_token_hint', idToken);
|
|
250
|
-
u.searchParams.set('post_logout_redirect_uri', returnTo);
|
|
251
|
-
go(u.toString());
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// ---------------------------------------------------------------- markup
|
|
255
|
-
|
|
256
|
-
const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; // links from Shopify: https only
|
|
257
|
-
const date = (s) => { try { return new Date(s).toLocaleDateString(cfg.locale || undefined); } catch { return s; } };
|
|
258
|
-
|
|
259
|
-
export async function render(root = document) {
|
|
260
|
-
const logged = server() ? Boolean((await serverMe().catch(() => ({ loggedIn: false }))).loggedIn) : isLoggedIn() || Boolean(await getToken().catch(() => null));
|
|
261
|
-
qsa(root, sel('show')).forEach((el) => {
|
|
262
|
-
const k = get(el, 'show');
|
|
263
|
-
if (k === 'logged-in') show(el, logged);
|
|
264
|
-
if (k === 'logged-out') show(el, !logged);
|
|
265
|
-
});
|
|
266
|
-
// Shopify's own pages, for what has to stay there: tether-account-link="subscriptions" / "payment"
|
|
267
|
-
// (account.subscriptionsUrl / paymentUrl), or the account itself.
|
|
268
|
-
qsa(root, sel('account-link')).forEach((a) => {
|
|
269
|
-
const k = get(a, 'account-link');
|
|
270
|
-
const u = (k === 'payment' && (cfg.account?.paymentUrl || cfg.account?.subscriptionsUrl)) || (k === 'subscriptions' && cfg.account?.subscriptionsUrl);
|
|
271
|
-
a.setAttribute('href', u || `https://${cfg.domain}/account`);
|
|
272
|
-
});
|
|
273
|
-
if (!logged) return;
|
|
274
|
-
await bindCustomer(root);
|
|
275
|
-
// The order list and the account pages (order, addresses, subscriptions) load side by side.
|
|
276
|
-
await Promise.all([renderOrderList(root), PAGE_ATTRS.some((n) => qsa(root, sel(n)).length) ? pages().then((m) => m.renderPages(root)) : null]);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
async function renderOrderList(root) {
|
|
280
|
-
const lists = qsa(root, sel('orders'));
|
|
281
|
-
if (lists.length) {
|
|
282
|
-
// tether-show="orders-loading" / "orders-empty" (no orders yet).
|
|
283
|
-
const ordersShow = (loading, empty) => qsa(root, sel('show')).forEach((el) => {
|
|
284
|
-
const k = get(el, 'show');
|
|
285
|
-
if (k === 'orders-loading') show(el, loading);
|
|
286
|
-
if (k === 'orders-empty') show(el, empty);
|
|
287
|
-
});
|
|
288
|
-
ordersShow(true, false);
|
|
289
|
-
let failed = false;
|
|
290
|
-
const list = await orders({ first: parseInt(get(lists[0], 'limit') || '10', 10) }).catch((err) => { reportError(err, 'orders'); failed = true; return []; });
|
|
291
|
-
ordersShow(false, !failed && list.length === 0);
|
|
292
|
-
lists.forEach((c) => repeat(c, list, (node, o) => {
|
|
293
|
-
const items = o.lineItems?.nodes || [];
|
|
294
|
-
qsa(node, sel('order-bind')).forEach((el) => {
|
|
295
|
-
const k = get(el, 'order-bind');
|
|
296
|
-
if (k === 'url') { const u = orderUrl(o); if (u) el.setAttribute('href', u); return; }
|
|
297
|
-
if (k === 'image') { const im = items.find((l) => l.image)?.image; if (im) setImage(el, im.url, im.altText || items[0]?.title || ''); return; }
|
|
298
|
-
const v = { name: o.name, date: date(o.processedAt), total: formatMoney(o.totalPrice), status: o.financialStatus?.toLowerCase(), fulfillment: o.fulfillmentStatus?.toLowerCase(), items: items.map((l) => `${l.quantity}× ${l.title}`).join(', ') }[k];
|
|
299
|
-
if (v != null) setText(el, v);
|
|
300
|
-
});
|
|
301
|
-
// Per product of the order: <div tether-order-lines><div><img tether-order-line="image"> <span tether-order-line="title"></span></div></div>
|
|
302
|
-
qsa(node, sel('order-lines')).forEach((box) => fillLines(box, items));
|
|
303
|
-
if (get(node, 'order-bind') === 'url' && orderUrl(o)) node.setAttribute('href', orderUrl(o));
|
|
304
|
-
}));
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const nid = (g) => String(g || '').split('/').pop().split('?')[0];
|
|
310
|
-
/** The order's page: your own (account.orderUrl: '/account/order?id={id}') or Shopify's status page. */
|
|
311
|
-
function orderUrl(o) {
|
|
312
|
-
const t = cfg.account?.orderUrl;
|
|
313
|
-
if (t) return String(t).replace('{id}', encodeURIComponent(nid(o.id)));
|
|
314
|
-
return https(o.statusPageUrl) ? o.statusPageUrl : null;
|
|
315
|
-
}
|
|
316
|
-
function fillLines(box, items) {
|
|
317
|
-
repeat(box, items, (row, l) => {
|
|
318
|
-
qsa(row, sel('order-line')).concat(row.matches(sel('order-line')) ? [row] : []).forEach((el) => {
|
|
319
|
-
const k = get(el, 'order-line');
|
|
320
|
-
if (k === 'image') { if (l.image) setImage(el, l.image.url, l.image.altText || l.title); return; }
|
|
321
|
-
const v = { title: l.title, 'variant-title': l.variantTitle || '', quantity: String(l.quantity) }[k];
|
|
322
|
-
if (v != null) setText(el, v);
|
|
323
|
-
});
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Order page, profile, addresses, subscriptions, returns: their own part (account-pages), loaded
|
|
328
|
-
// only on pages that use them. It gets this module's login state, so both share one session.
|
|
329
|
-
const PAGE_ATTRS = ['order', 'addresses', 'subscriptions', 'profile-form', 'address-form'];
|
|
330
|
-
let pagesPart = null;
|
|
331
|
-
/** tether-account-bind: the customer's name, email, default address (again after a profile change). */
|
|
332
|
-
async function bindCustomer(root) {
|
|
333
|
-
const binds = qsa(root, sel('account-bind'));
|
|
334
|
-
if (!binds.length) return;
|
|
335
|
-
const c = await customer().catch((err) => { reportError(err, 'account'); return null; });
|
|
336
|
-
binds.forEach((el) => {
|
|
337
|
-
const v = { 'first-name': c?.firstName, 'last-name': c?.lastName, name: c?.displayName, email: c?.emailAddress?.emailAddress, address: c?.defaultAddress?.formatted?.join(', ') }[get(el, 'account-bind')];
|
|
338
|
-
if (v != null) setText(el, v);
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
function pages() {
|
|
342
|
-
pagesPart ||= loadPart('account-pages').then((m) => { m.setup({ server, serverFetch, query, customer, bindCustomer, reset: () => { customerCache = null; serverState = null; } }); return m; });
|
|
343
|
-
return pagesPart;
|
|
344
|
-
}
|
|
345
|
-
const viaPages = (name) => (...a) => pages().then((m) => m[name](...a));
|
|
346
|
-
/** One of the customer's orders, in full (lines, totals, addresses, fulfillments, returns). */
|
|
347
|
-
export const order = viaPages('order');
|
|
348
|
-
export const updateProfile = viaPages('updateProfile');
|
|
349
|
-
export const addresses = viaPages('addresses');
|
|
350
|
-
/** saveAddress({ address1, city, zip, countryCode, … }, { id, isDefault }): creates, or updates with an id. */
|
|
351
|
-
export const saveAddress = viaPages('saveAddress');
|
|
352
|
-
export const deleteAddress = viaPages('deleteAddress');
|
|
353
|
-
export const subscriptions = viaPages('subscriptions');
|
|
354
|
-
/** subscriptionAction(id, 'pause' | 'resume' | 'skip' | 'unskip' | 'cancel') */
|
|
355
|
-
export const subscriptionAction = viaPages('subscriptionAction');
|
|
356
|
-
/** requestReturn(orderId, [{ lineItemId, quantity, reasonId, note }]) */
|
|
357
|
-
export const requestReturn = viaPages('requestReturn');
|
|
358
|
-
|
|
359
|
-
let bound = false;
|
|
360
|
-
/** Called by the core on mount: finish a login that's coming back, then fill the page. */
|
|
361
|
-
export async function mountAccount(root = document) {
|
|
362
|
-
if (!bound) {
|
|
363
|
-
bound = true;
|
|
364
|
-
document.addEventListener('click', (e) => {
|
|
365
|
-
const t = e.target;
|
|
366
|
-
if (t.closest?.(sel('account-login'))) { e.preventDefault(); login().catch((err) => reportError(err, 'login')); }
|
|
367
|
-
else if (t.closest?.(sel('account-logout'))) { e.preventDefault(); logout().catch((err) => reportError(err, 'logout')); }
|
|
368
|
-
});
|
|
369
|
-
if (server()) {
|
|
370
|
-
// Back from the Worker's login: drop ?account=in; at checkout the Worker links the cart.
|
|
371
|
-
const u = new URL(window.location.href);
|
|
372
|
-
if (u.searchParams.get('account')) { u.searchParams.delete('account'); window.history.replaceState(window.history.state, '', u); emit('account-login', {}); }
|
|
373
|
-
Cart.beforeCheckout = async () => {
|
|
374
|
-
if (!(await serverMe()).loggedIn || !Cart.id()) return null;
|
|
375
|
-
const r = await serverFetch('/cart', { method: 'POST', body: { cartId: Cart.id() } }).catch(() => null);
|
|
376
|
-
return r?.checkoutUrl ? `${r.checkoutUrl}${r.checkoutUrl.includes('?') ? '&' : '?'}logged_in=true` : null;
|
|
377
|
-
};
|
|
378
|
-
} else {
|
|
379
|
-
try { await handleCallback(); } catch (err) { reportError(err, 'login'); }
|
|
380
|
-
if (!Cart.customerAccessToken && (await getToken().catch(() => null))) Cart.customerAccessToken = session.access;
|
|
381
|
-
}
|
|
382
|
-
log('account', isLoggedIn() ? 'logged in' : 'logged out');
|
|
383
|
-
}
|
|
384
|
-
await render(root);
|
|
385
|
-
}
|
package/src/analytics.js
DELETED
|
@@ -1,232 +0,0 @@
|
|
|
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
|
|
11
|
-
* (from the cart's changes, so quick +/- and JS calls count too), view_cart (cart opened),
|
|
12
|
-
* begin_checkout, search (search as you type). Purchases happen on Shopify's checkout: use
|
|
13
|
-
* Shopify's Google & YouTube app or a checkout pixel for those.
|
|
14
|
-
*
|
|
15
|
-
* Shopify analytics: Tether sets up the same event bus Hydrogen uses (window.Shopify.analytics) and
|
|
16
|
-
* loads Shopify's consent script and shopify.js, which build and send the events. Nothing is sent
|
|
17
|
-
* before the Customer Privacy API allows analytics.
|
|
18
|
-
*/
|
|
19
|
-
import { cfg, log, reportError } from './config.js';
|
|
20
|
-
import { gql } from './client.js';
|
|
21
|
-
import { sel } from './dom.js';
|
|
22
|
-
import { Cart } from './cart.js';
|
|
23
|
-
|
|
24
|
-
const num = (m) => (m ? parseFloat(m.amount) : 0);
|
|
25
|
-
const id = (gid) => String(gid || '').split('/').pop();
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------- shapes
|
|
28
|
-
|
|
29
|
-
function itemFromVariant(p, v, quantity = 1, index) {
|
|
30
|
-
return {
|
|
31
|
-
item_id: v?.sku || id(v?.id) || id(p.id),
|
|
32
|
-
item_name: p.title,
|
|
33
|
-
item_variant: v?.title === 'Default Title' ? undefined : v?.title,
|
|
34
|
-
item_brand: p.vendor || undefined,
|
|
35
|
-
item_category: p.productType || undefined,
|
|
36
|
-
price: num(v?.price || p.priceRange?.minVariantPrice),
|
|
37
|
-
quantity,
|
|
38
|
-
...(index != null ? { index } : {}),
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function itemFromLine(l, quantity = l.quantity) {
|
|
43
|
-
const m = l.merchandise || {};
|
|
44
|
-
return itemFromVariant({ ...(m.product || {}), title: m.product?.title }, m, quantity);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const value = (items) => Math.round(items.reduce((s, i) => s + (i.price || 0) * (i.quantity || 1), 0) * 100) / 100;
|
|
48
|
-
|
|
49
|
-
// ---------------------------------------------------------------- GA4 / GTM
|
|
50
|
-
|
|
51
|
-
function ga(event, ecommerce) {
|
|
52
|
-
const name = typeof cfg.analytics.dataLayer === 'string' ? cfg.analytics.dataLayer : 'dataLayer';
|
|
53
|
-
const dl = (window[name] = window[name] || []);
|
|
54
|
-
if (ecommerce) dl.push({ ecommerce: null }); // Google: clear the previous ecommerce object first
|
|
55
|
-
dl.push(ecommerce ? { event, ecommerce } : { event });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// ---------------------------------------------------------------- Shopify analytics (Hydrogen's bus)
|
|
59
|
-
|
|
60
|
-
let bus = null;
|
|
61
|
-
|
|
62
|
-
function shopifyBus(shopId) {
|
|
63
|
-
const destinations = [];
|
|
64
|
-
const buffer = [];
|
|
65
|
-
const privacy = () => window.Shopify?.customerPrivacy;
|
|
66
|
-
const canTrack = () => {
|
|
67
|
-
try {
|
|
68
|
-
const p = privacy();
|
|
69
|
-
return p?.consentStatus === 'loaded' && p.currentVisitorConsent?.()?.analytics !== 'no' && Boolean(p.analyticsProcessingAllowed?.());
|
|
70
|
-
} catch { return false; }
|
|
71
|
-
};
|
|
72
|
-
const config = { shop: { shopId, channel: 'headless' }, consent: { mode: cfg.analytics.shopify?.consent || 'no-banner' } };
|
|
73
|
-
const deliver = () => {
|
|
74
|
-
if (!canTrack()) return;
|
|
75
|
-
destinations.forEach((d) => {
|
|
76
|
-
while (d.next < buffer.length) {
|
|
77
|
-
const { event, payload } = buffer[d.next++];
|
|
78
|
-
(d.subs.get(event) || []).forEach((cb) => { try { cb(payload, d.context); } catch (err) { reportError(err, `analytics ${d.name}`); } });
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
};
|
|
82
|
-
const api = {
|
|
83
|
-
getConfig: () => config,
|
|
84
|
-
publish(event, payload = {}) {
|
|
85
|
-
buffer.push({ event, payload: { shop: config.shop, url: window.location.href, ...payload } });
|
|
86
|
-
if (buffer.length > 500) { buffer.shift(); destinations.forEach((d) => { d.next = Math.max(0, d.next - 1); }); }
|
|
87
|
-
deliver();
|
|
88
|
-
},
|
|
89
|
-
addDestination(dest) {
|
|
90
|
-
const tag = `hydrogen:${dest.name}`;
|
|
91
|
-
const d = {
|
|
92
|
-
name: dest.name, next: 0, subs: new Map(),
|
|
93
|
-
context: {
|
|
94
|
-
getTrackingValues: () => {
|
|
95
|
-
const i = canTrack() ? privacy()?.__internal : null;
|
|
96
|
-
return { uniqueToken: i?.uniqueToken?.({ generateFallback: true, tag }) ?? '', visitToken: i?.visitToken?.({ generateFallback: true, tag }) ?? '' };
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
};
|
|
100
|
-
const subscribe = (event, cb) => {
|
|
101
|
-
if (!d.subs.has(event)) d.subs.set(event, new Set());
|
|
102
|
-
d.subs.get(event).add(cb);
|
|
103
|
-
return () => d.subs.get(event)?.delete(cb);
|
|
104
|
-
};
|
|
105
|
-
Promise.resolve(dest.setup?.({ subscribe, getConfig: api.getConfig })).then(() => { destinations.push(d); deliver(); })
|
|
106
|
-
.catch((err) => reportError(err, `analytics ${dest.name}`));
|
|
107
|
-
return () => { const i = destinations.indexOf(d); if (i >= 0) destinations.splice(i, 1); };
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
document.addEventListener('consentTrackingApiLoaded', deliver);
|
|
111
|
-
document.addEventListener('visitorConsentCollected', deliver);
|
|
112
|
-
return api;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function loadScript(src) {
|
|
116
|
-
const s = document.createElement('script');
|
|
117
|
-
s.src = src;
|
|
118
|
-
s.async = true;
|
|
119
|
-
document.head.appendChild(s);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function startShopify() {
|
|
123
|
-
if (window.Shopify?.analytics) { bus = window.Shopify.analytics; return; } // already set up (e.g. by the theme)
|
|
124
|
-
const data = await gql('query TetherShopId { shop { id } }', {}, { context: false });
|
|
125
|
-
const country = String(cfg.country || 'US').toUpperCase();
|
|
126
|
-
const S = (window.Shopify = window.Shopify || {});
|
|
127
|
-
Object.assign(S, {
|
|
128
|
-
country,
|
|
129
|
-
locale: (cfg.locale || 'en').split('-')[0].toLowerCase(), // required by Shopify's script
|
|
130
|
-
shop: cfg.domain,
|
|
131
|
-
routes: { root: '/', match: () => ({ pageTemplateName: mainProduct() ? 'product' : 'page' }), ...(S.routes || {}) },
|
|
132
|
-
});
|
|
133
|
-
const proxy = cfg.analytics.shopify?.proxy; // same-origin Storefront API path, for exact visitor counts
|
|
134
|
-
S.customerPrivacy = {
|
|
135
|
-
...(S.customerPrivacy || {}),
|
|
136
|
-
config: proxy
|
|
137
|
-
? { isHeadless: true, asyncConsent: true, asyncVisitorState: true, consentDomain: window.location.host }
|
|
138
|
-
: { isHeadless: true, storefrontAccessToken: cfg.token, checkoutRootDomain: cfg.domain },
|
|
139
|
-
};
|
|
140
|
-
bus = shopifyBus(data.shop.id);
|
|
141
|
-
S.analytics = bus;
|
|
142
|
-
loadScript(cfg.analytics.shopify?.consent === 'default-banner'
|
|
143
|
-
? 'https://cdn.shopify.com/shopifycloud/privacy-banner/storefront-banner.js'
|
|
144
|
-
: 'https://cdn.shopify.com/shopifycloud/consent-tracking-api/v0.2/consent-tracking-api.js');
|
|
145
|
-
loadScript('https://cdn.shopify.com/storefront/analytics/shopify.js');
|
|
146
|
-
log('shopify analytics ready', data.shop.id);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const productPayload = (p, v, quantity = 1) => ({
|
|
150
|
-
id: p.id, title: p.title, price: v.price, vendor: p.vendor || '', variantId: v.id, variantTitle: v.title,
|
|
151
|
-
quantity, sku: v.sku || undefined, productType: p.productType || undefined,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
function analyticsLine(l) {
|
|
155
|
-
const m = l.merchandise || {};
|
|
156
|
-
return { id: l.id, quantity: l.quantity, merchandise: { id: m.id, title: m.title, price: m.price, sku: m.sku, product: { id: m.product?.id, title: m.product?.title, vendor: m.product?.vendor || '', handle: m.product?.handle } } };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ---------------------------------------------------------------- what happened on the page
|
|
160
|
-
|
|
161
|
-
function mainProduct() {
|
|
162
|
-
const el = document.querySelector(sel('product'));
|
|
163
|
-
return el && !el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`) ? window.Tether?.getScope(el) : null;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const send = (name, data, shopifyEvent, shopifyPayload) => {
|
|
167
|
-
if (cfg.analytics.dataLayer) ga(name, data);
|
|
168
|
-
if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
|
|
169
|
-
try { cfg.analytics.onEvent?.(name, data); } catch (err) { reportError(err, 'analytics onEvent'); }
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
let lastLines = null; // variant id -> line, to turn cart changes into add/remove events
|
|
173
|
-
let viewed = new WeakSet();
|
|
174
|
-
|
|
175
|
-
function handle(e) {
|
|
176
|
-
const name = e.type.slice(cfg.prefix.length + 1);
|
|
177
|
-
const d = e.detail || {};
|
|
178
|
-
if (name === 'product-ready') {
|
|
179
|
-
const scope = d.scope;
|
|
180
|
-
if (!scope || viewed.has(scope.root) || mainProduct() !== scope) return;
|
|
181
|
-
viewed.add(scope.root);
|
|
182
|
-
const v = scope.variant || d.product.variants.nodes[0];
|
|
183
|
-
const items = [itemFromVariant(d.product, v)];
|
|
184
|
-
send('view_item', { currency: v?.price?.currencyCode, value: value(items), items }, 'product_viewed', { products: [productPayload(d.product, v)] });
|
|
185
|
-
} else if (name === 'list-ready') {
|
|
186
|
-
const items = (d.products || []).slice(0, 200).map((p, i) => itemFromVariant(p, p.variants?.nodes?.[0], 1, i));
|
|
187
|
-
const list = { item_list_id: d.collection?.handle || 'products', item_list_name: d.collection?.title || 'Products' };
|
|
188
|
-
send('view_item_list', { ...list, items }, d.collection ? 'collection_viewed' : null, d.collection ? { collection: { id: d.collection.id, handle: d.collection.handle } } : null);
|
|
189
|
-
} else if (name === 'cart-updated') {
|
|
190
|
-
const cart = d.cart;
|
|
191
|
-
const now = new Map((cart?.lines?.nodes || []).map((l) => [l.merchandise?.id, l]));
|
|
192
|
-
if (lastLines && d.reason !== 'load' && d.reason !== 'pending') {
|
|
193
|
-
const currency = cart?.cost?.totalAmount?.currencyCode;
|
|
194
|
-
now.forEach((l, k) => {
|
|
195
|
-
const diff = l.quantity - (lastLines.get(k)?.quantity || 0);
|
|
196
|
-
if (diff > 0) {
|
|
197
|
-
const items = [itemFromLine(l, diff)];
|
|
198
|
-
send('add_to_cart', { currency, value: value(items), items }, 'product_added_to_cart', {
|
|
199
|
-
cart, prevCart: lastCart, currentLine: analyticsLine(l), prevLine: lastLines.get(k) ? analyticsLine(lastLines.get(k)) : undefined,
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
lastLines.forEach((l, k) => {
|
|
204
|
-
const diff = l.quantity - (now.get(k)?.quantity || 0);
|
|
205
|
-
if (diff > 0) { const items = [itemFromLine(l, diff)]; send('remove_from_cart', { currency, value: value(items), items }); }
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
lastLines = now;
|
|
209
|
-
lastCart = cart;
|
|
210
|
-
} else if (name === 'cart-toggled' && d.open) {
|
|
211
|
-
const items = Cart.lines.map((l) => itemFromLine(l));
|
|
212
|
-
send('view_cart', { currency: Cart.state?.cost?.totalAmount?.currencyCode, value: value(items), items });
|
|
213
|
-
} else if (name === 'checkout') {
|
|
214
|
-
const items = (d.cart?.lines?.nodes || []).map((l) => itemFromLine(l));
|
|
215
|
-
const coupon = (d.cart?.discountCodes || []).filter((c) => c.applicable).map((c) => c.code).join(',') || undefined;
|
|
216
|
-
send('begin_checkout', { currency: d.cart?.cost?.totalAmount?.currencyCode, value: value(items), coupon, items });
|
|
217
|
-
} else if (name === 'search' && d.term) {
|
|
218
|
-
send('search', { search_term: d.term }, 'search_viewed', { searchTerm: d.term });
|
|
219
|
-
} else if (name === 'page') {
|
|
220
|
-
viewed = new WeakSet();
|
|
221
|
-
if (bus) bus.publish('page_viewed', {});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
let lastCart = null;
|
|
225
|
-
|
|
226
|
-
/** Called by the core with the events it buffered while this part loaded. */
|
|
227
|
-
export async function startAnalytics(queue) {
|
|
228
|
-
if (cfg.analytics.shopify) await startShopify().catch((err) => reportError(err, 'shopify analytics'));
|
|
229
|
-
if (bus) bus.publish('page_viewed', {});
|
|
230
|
-
queue.splice(0).forEach(handle);
|
|
231
|
-
queue.push = (e) => { handle(e); return 0; }; // from now on, handle events as they come
|
|
232
|
-
}
|
package/src/api-version.js
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months.
|
|
3
|
-
* Tether pins a dated version and never targets `unstable` (it changes without notice).
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const parse = (v) => {
|
|
7
|
-
const m = /^(\d{4})-(01|04|07|10)$/.exec(String(v || ''));
|
|
8
|
-
return m ? { y: +m[1], m: +m[2] } : null;
|
|
9
|
-
};
|
|
10
|
-
const fmt = (y, m) => `${y}-${String(m).padStart(2, '0')}`;
|
|
11
|
-
|
|
12
|
-
export const isDatedVersion = (v) => Boolean(parse(v));
|
|
13
|
-
|
|
14
|
-
/** '2026-07' -> '2026-10', '2026-10' -> '2027-01'. */
|
|
15
|
-
export function nextApiVersion(v, steps = 1) {
|
|
16
|
-
const p = parse(v);
|
|
17
|
-
if (!p) throw new Error(`not a quarterly Shopify API version: ${v}`);
|
|
18
|
-
const q = p.y * 4 + (p.m - 1) / 3 + steps;
|
|
19
|
-
return fmt(Math.floor(q / 4), (q % 4) * 3 + 1);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Support window of a version, relative to `now`. */
|
|
23
|
-
export function apiVersionStatus(version, now = new Date()) {
|
|
24
|
-
if (version === 'unstable') return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
|
|
25
|
-
const p = parse(version);
|
|
26
|
-
if (!p) return { version, ok: false, note: 'not a quarterly version (YYYY-01, -04, -07 or -10)' };
|
|
27
|
-
const released = new Date(Date.UTC(p.y, p.m - 1, 1));
|
|
28
|
-
const supportedUntil = new Date(Date.UTC(p.y + 1, p.m - 1, 1));
|
|
29
|
-
const monthsLeft = (supportedUntil - now) / (30.44 * 24 * 3600 * 1000);
|
|
30
|
-
let note = `supported until about ${supportedUntil.toISOString().slice(0, 7)}`;
|
|
31
|
-
if (released > now) note = 'not released yet (release candidate at best)';
|
|
32
|
-
else if (monthsLeft <= 0) note = 'no longer supported: Shopify falls back to the oldest supported version. Upgrade (see README)';
|
|
33
|
-
else if (monthsLeft < 3) note = `${note}: plan the upgrade to ${nextApiVersion(version)} or later (see README)`;
|
|
34
|
-
return { version, ok: released <= now && monthsLeft > 0, monthsLeft: Math.round(monthsLeft), next: nextApiVersion(version), note };
|
|
35
|
-
}
|