@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/diagnose.ts
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Tether.diagnose()`: one call in the browser console that explains what's wrong on a page. Logs a readable report
|
|
3
|
+
* and returns it as an object. The markup checks are in diagnose-markup.ts.
|
|
4
|
+
*
|
|
5
|
+
* await Tether.diagnose()
|
|
6
|
+
* await Tether.diagnose({ silent: true }) // just the object
|
|
7
|
+
*/
|
|
8
|
+
import { cfg, isConfigured } from './config.ts';
|
|
9
|
+
import { gql, endpoint, shaped } from './client.ts';
|
|
10
|
+
import { sel, get, qsa } from './dom.ts';
|
|
11
|
+
import { getProduct } from './loader.ts';
|
|
12
|
+
import { allPlans } from './selling-plans.ts';
|
|
13
|
+
import { resolveProductKey } from './status.ts';
|
|
14
|
+
import { apiVersionStatus } from './api-version.ts';
|
|
15
|
+
import { loadPart } from './lazy.ts';
|
|
16
|
+
import { checkMarkup, didYouMean } from './diagnose-markup.ts';
|
|
17
|
+
import type { ApiVersionStatus } from './api-version.ts';
|
|
18
|
+
import type { DetectedMarket } from './markets.ts';
|
|
19
|
+
import type { Problem } from './diagnose-markup.ts';
|
|
20
|
+
import type { Product } from './fragments.ts';
|
|
21
|
+
|
|
22
|
+
export { apiVersionStatus, checkMarkup, didYouMean };
|
|
23
|
+
|
|
24
|
+
/** The market in use (a visitor's choice included), and what detection would pick. */
|
|
25
|
+
interface MarketReport {
|
|
26
|
+
country: string | null;
|
|
27
|
+
language: string | null;
|
|
28
|
+
locale: string | null;
|
|
29
|
+
/** `null` without `markets.detect`. */
|
|
30
|
+
detected: DetectedMarket | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ShopReport {
|
|
34
|
+
name: string;
|
|
35
|
+
url: string;
|
|
36
|
+
currency: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ProductReport =
|
|
40
|
+
| { key: string; handle: string; title: string; plans: number; requiresSellingPlan: boolean }
|
|
41
|
+
| { key: string; error: string | undefined };
|
|
42
|
+
|
|
43
|
+
interface SellingPlanReport {
|
|
44
|
+
product: string;
|
|
45
|
+
group: string;
|
|
46
|
+
plan: string;
|
|
47
|
+
id: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface DiagnoseReport {
|
|
51
|
+
/** Tether's version, `''` when the caller didn't say. */
|
|
52
|
+
version: string;
|
|
53
|
+
/** The config, with the token masked. */
|
|
54
|
+
config: Record<string, unknown>;
|
|
55
|
+
apiVersion: ApiVersionStatus;
|
|
56
|
+
market: MarketReport;
|
|
57
|
+
reachable: boolean;
|
|
58
|
+
shop: ShopReport | null;
|
|
59
|
+
latencyMs: number | null;
|
|
60
|
+
products: ProductReport[];
|
|
61
|
+
sellingPlans: SellingPlanReport[];
|
|
62
|
+
problems: Problem[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type StorefrontCheck =
|
|
66
|
+
| { kind: 'not-configured' }
|
|
67
|
+
| { kind: 'reachable'; shop: ShopReport; latencyMs: number }
|
|
68
|
+
| { kind: 'unreachable'; error: unknown; latencyMs: number };
|
|
69
|
+
|
|
70
|
+
export interface DiagnoseOptions {
|
|
71
|
+
root?: ParentNode;
|
|
72
|
+
/** Return the report without logging it. */
|
|
73
|
+
silent?: boolean;
|
|
74
|
+
/** Tether's version, for the report. */
|
|
75
|
+
version?: string;
|
|
76
|
+
/** The date the API version's support is measured against. */
|
|
77
|
+
now?: Date;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Products checked at most, so a long CMS page doesn't send a request per card. */
|
|
81
|
+
const MAX_PRODUCTS_CHECKED = 20;
|
|
82
|
+
/** Matches the warning window of `apiVersionStatus`. */
|
|
83
|
+
const API_UPGRADE_WARNING_MONTHS = 3;
|
|
84
|
+
const TOKEN_EDGE_CHARACTERS = 4;
|
|
85
|
+
|
|
86
|
+
function maskToken(token: string): string {
|
|
87
|
+
if (!token) return '(missing)';
|
|
88
|
+
return `${token.slice(0, TOKEN_EDGE_CHARACTERS)}…${token.slice(-TOKEN_EDGE_CHARACTERS)} (${token.length} chars)`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The error's message, else its code (a `TetherError` has one), else `fallback`. */
|
|
92
|
+
function errorText(error: unknown, fallback: string): string {
|
|
93
|
+
if (error instanceof Error && error.message) return error.message;
|
|
94
|
+
const code: unknown = typeof error === 'object' && error !== null ? Reflect.get(error, 'code') : undefined;
|
|
95
|
+
return typeof code === 'string' && code ? code : fallback;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function configSummary(): Record<string, unknown> {
|
|
99
|
+
return {
|
|
100
|
+
domain: cfg.domain || '(missing)',
|
|
101
|
+
token: maskToken(cfg.token),
|
|
102
|
+
apiVersion: cfg.apiVersion,
|
|
103
|
+
endpoint: cfg.domain ? endpoint() : null,
|
|
104
|
+
country: cfg.country,
|
|
105
|
+
language: cfg.language,
|
|
106
|
+
locale: cfg.locale,
|
|
107
|
+
markets: cfg.markets,
|
|
108
|
+
prefix: cfg.prefix,
|
|
109
|
+
productPath: cfg.productPath,
|
|
110
|
+
metafields: cfg.metafields,
|
|
111
|
+
autoMount: cfg.autoMount,
|
|
112
|
+
observe: cfg.observe,
|
|
113
|
+
onError: typeof cfg.onError === 'function',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Detection runs in the markets part, which init has already loaded whenever `markets.detect` is set.
|
|
119
|
+
*/
|
|
120
|
+
async function marketSummary(): Promise<MarketReport> {
|
|
121
|
+
return {
|
|
122
|
+
country: cfg.country || null,
|
|
123
|
+
language: cfg.language || null,
|
|
124
|
+
locale: cfg.locale || null,
|
|
125
|
+
detected: cfg.markets?.detect ? (await loadPart('markets')).detectMarket(cfg.markets) : null,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An error for an unsupported version, a warning when support ends soon. */
|
|
130
|
+
function apiVersionProblem({ ok, monthsLeft, note }: ApiVersionStatus): Problem | null {
|
|
131
|
+
if (ok && (monthsLeft === undefined || monthsLeft >= API_UPGRADE_WARNING_MONTHS)) return null;
|
|
132
|
+
return { level: ok ? 'warn' : 'error', message: `apiVersion ${cfg.apiVersion}: ${note}` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isShopAnswer = shaped<{
|
|
136
|
+
shop: { name: string; primaryDomain: { url: string }; paymentSettings: { currencyCode: string } };
|
|
137
|
+
}>({ shop: { name: 'string', primaryDomain: { url: 'string' }, paymentSettings: { currencyCode: 'string' } } });
|
|
138
|
+
|
|
139
|
+
async function checkStorefront(): Promise<StorefrontCheck> {
|
|
140
|
+
if (!isConfigured()) return { kind: 'not-configured' };
|
|
141
|
+
const startedAt = Date.now();
|
|
142
|
+
try {
|
|
143
|
+
const data = await gql(
|
|
144
|
+
'query TetherDiagnose { shop { name primaryDomain { url } paymentSettings { currencyCode } } }',
|
|
145
|
+
{},
|
|
146
|
+
isShopAnswer,
|
|
147
|
+
{ context: false },
|
|
148
|
+
);
|
|
149
|
+
const shop = {
|
|
150
|
+
name: data.shop.name,
|
|
151
|
+
url: data.shop.primaryDomain.url,
|
|
152
|
+
currency: data.shop.paymentSettings.currencyCode,
|
|
153
|
+
};
|
|
154
|
+
return { kind: 'reachable', shop, latencyMs: Date.now() - startedAt };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { kind: 'unreachable', error, latencyMs: Date.now() - startedAt };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Options a visitor can't choose on this page get the first available variant's value.
|
|
162
|
+
*/
|
|
163
|
+
function checkOptions(root: ParentNode, key: string, product: Product): Problem[] {
|
|
164
|
+
const problems: Problem[] = [];
|
|
165
|
+
const names = product.options.map((option) => option.name);
|
|
166
|
+
const roots = qsa(root, sel('product')).filter(
|
|
167
|
+
(el) => resolveProductKey(el).key === key && el.querySelector(sel('add-to-cart')),
|
|
168
|
+
);
|
|
169
|
+
for (const el of roots) {
|
|
170
|
+
const shown = new Set(
|
|
171
|
+
qsa(el, `${sel('option-list')},select${sel('option')}`).map(
|
|
172
|
+
(list) => get(list, 'option-list') || get(list, 'option'),
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
if (!shown.size && qsa(el, `select${sel('variant-select')}`).length) continue;
|
|
176
|
+
// Translated option names (a language in the market) don't match the names in the markup.
|
|
177
|
+
if (!cfg.language) {
|
|
178
|
+
for (const name of shown) {
|
|
179
|
+
if (!name || names.includes(name)) continue;
|
|
180
|
+
problems.push({
|
|
181
|
+
level: 'warn',
|
|
182
|
+
message: `Product "${product.handle}" has no option "${name}" (its options: ${names.join(', ') || 'none'}).${didYouMean(name, names)}`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
for (const option of product.options) {
|
|
187
|
+
if (option.optionValues.length <= 1 || shown.has(option.name)) continue;
|
|
188
|
+
problems.push({
|
|
189
|
+
level: 'warn',
|
|
190
|
+
message: `Product "${product.handle}": option "${option.name}" (${option.optionValues.length} values) has no tether-option-list, so visitors can't choose it.`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return problems;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Loads the page's products and adds what it finds about each to `report`.
|
|
199
|
+
*/
|
|
200
|
+
async function checkProducts(root: ParentNode, report: DiagnoseReport): Promise<void> {
|
|
201
|
+
const allKeys = qsa(root, sel('product')).map((el) => resolveProductKey(el).key);
|
|
202
|
+
const keys = [...new Set(allKeys.filter((key) => key !== null && key !== ''))].slice(0, MAX_PRODUCTS_CHECKED);
|
|
203
|
+
const results = await Promise.allSettled(keys.map((key) => getProduct(key)));
|
|
204
|
+
results.forEach((result, index) => {
|
|
205
|
+
const key = keys[index] ?? '';
|
|
206
|
+
if (result.status === 'rejected') {
|
|
207
|
+
const reason = errorText(result.reason, 'not loaded');
|
|
208
|
+
report.products.push({ key, error: result.reason instanceof Error ? result.reason.message : undefined });
|
|
209
|
+
report.problems.push({
|
|
210
|
+
level: 'error',
|
|
211
|
+
message: `Product "${key}": ${reason}. Is it published to the Headless channel?`,
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const product = result.value;
|
|
216
|
+
const plans = allPlans(product);
|
|
217
|
+
report.products.push({
|
|
218
|
+
key,
|
|
219
|
+
handle: product.handle,
|
|
220
|
+
title: product.title,
|
|
221
|
+
plans: plans.length,
|
|
222
|
+
requiresSellingPlan: product.requiresSellingPlan,
|
|
223
|
+
});
|
|
224
|
+
for (const plan of plans) {
|
|
225
|
+
report.sellingPlans.push({ product: product.handle, group: plan.group.name, plan: plan.name, id: plan.id });
|
|
226
|
+
}
|
|
227
|
+
report.problems.push(...checkOptions(root, key, product));
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** What's wrong on the page: config, token, API version, products, plans, markup. */
|
|
232
|
+
export async function diagnose({
|
|
233
|
+
root = document,
|
|
234
|
+
silent = false,
|
|
235
|
+
version = '',
|
|
236
|
+
now = new Date(),
|
|
237
|
+
}: DiagnoseOptions = {}): Promise<DiagnoseReport> {
|
|
238
|
+
const storefront = await checkStorefront();
|
|
239
|
+
const report: DiagnoseReport = {
|
|
240
|
+
version,
|
|
241
|
+
config: configSummary(),
|
|
242
|
+
apiVersion: apiVersionStatus(cfg.apiVersion, now),
|
|
243
|
+
market: await marketSummary(),
|
|
244
|
+
reachable: storefront.kind === 'reachable',
|
|
245
|
+
shop: storefront.kind === 'reachable' ? storefront.shop : null,
|
|
246
|
+
latencyMs: storefront.kind === 'not-configured' ? null : storefront.latencyMs,
|
|
247
|
+
products: [],
|
|
248
|
+
sellingPlans: [],
|
|
249
|
+
problems: [],
|
|
250
|
+
};
|
|
251
|
+
switch (storefront.kind) {
|
|
252
|
+
case 'not-configured':
|
|
253
|
+
report.problems.push({ level: 'error', message: 'TETHER_CONFIG needs domain and token.' });
|
|
254
|
+
break;
|
|
255
|
+
case 'unreachable':
|
|
256
|
+
report.problems.push({
|
|
257
|
+
level: 'error',
|
|
258
|
+
message: `Storefront API not reachable: ${errorText(storefront.error, 'unknown error')}`,
|
|
259
|
+
});
|
|
260
|
+
break;
|
|
261
|
+
case 'reachable':
|
|
262
|
+
await checkProducts(root, report);
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
const versionProblem = apiVersionProblem(report.apiVersion);
|
|
266
|
+
if (versionProblem) report.problems.push(versionProblem);
|
|
267
|
+
if (typeof document !== 'undefined') report.problems.push(...checkMarkup(root));
|
|
268
|
+
if (!silent) print(report);
|
|
269
|
+
return report;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function print(report: DiagnoseReport): void {
|
|
273
|
+
const errors = report.problems.filter((problem) => problem.level === 'error').length;
|
|
274
|
+
const warnings = report.problems.length - errors;
|
|
275
|
+
console.group(`[${cfg.prefix}] diagnose: ${errors ? `${errors} error(s)` : 'no errors'}, ${warnings} warning(s)`);
|
|
276
|
+
console.log('version', report.version, '| API', report.apiVersion.version, `(${report.apiVersion.note})`);
|
|
277
|
+
console.log('config', report.config);
|
|
278
|
+
const { country, language, detected } = report.market;
|
|
279
|
+
const inUse = [country, language].filter(Boolean).join(' / ') || 'the store default';
|
|
280
|
+
console.log('market', `in use: ${inUse}`, detected ? `(detection picks ${JSON.stringify(detected)})` : '');
|
|
281
|
+
console.log(
|
|
282
|
+
report.shop
|
|
283
|
+
? `Storefront API reachable: ${report.shop.name} (${report.latencyMs ?? 0} ms)`
|
|
284
|
+
: 'Storefront API NOT reachable',
|
|
285
|
+
);
|
|
286
|
+
if (report.products.length) console.table(report.products);
|
|
287
|
+
if (report.sellingPlans.length) console.table(report.sellingPlans);
|
|
288
|
+
else if (report.reachable) console.log('No selling plans on the products of this page.');
|
|
289
|
+
for (const problem of report.problems) {
|
|
290
|
+
if (problem.level === 'error') console.error(problem.message, problem.el ?? '');
|
|
291
|
+
else console.warn(problem.message, problem.el ?? '');
|
|
292
|
+
}
|
|
293
|
+
console.groupEnd();
|
|
294
|
+
}
|
package/src/dom.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser modules' DOM toolkit: what Shopify values look like on the page (money, dates, text, images, lists)
|
|
3
|
+
* and how elements show, hide and report layout changes. Attribute names go through `attr()` so the prefix is
|
|
4
|
+
* configurable. The optional parts import all of it from here (`window.Tether.__core['dom.ts']`).
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError, warn } from './config.ts';
|
|
7
|
+
import { attr, emit, get } from './markup.ts';
|
|
8
|
+
import { storeHtmlFragment } from './store-html.ts';
|
|
9
|
+
import { isRecord } from '../server/src/shared/json.ts';
|
|
10
|
+
import { formatMoney as formatMoneyIn, productUrl as productUrlFor } from './shopify-values.ts';
|
|
11
|
+
import type { Money } from './shopify-values.ts';
|
|
12
|
+
|
|
13
|
+
export { attr, sel, get, has, qsa, scoped, emit, ownEvent, isOwnEvent, ANALYTICS_EVENTS } from './markup.ts';
|
|
14
|
+
export { imageUrl, cdnUrl, sameImage, setImage } from './images.ts';
|
|
15
|
+
export { hasTemplate, repeat, repeatKeyed } from './repeaters.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* State Tether keeps on elements, as `data-tether-*` attributes whatever the prefix: sites style and test against
|
|
19
|
+
* these names, so they stay fixed where the `tether-*` markup attributes follow `prefix`.
|
|
20
|
+
*/
|
|
21
|
+
const SEEN = 'data-tether-seen';
|
|
22
|
+
const SHOWN = 'data-tether-shown';
|
|
23
|
+
const PREVIOUS_DISPLAY = 'data-tether-prev-display';
|
|
24
|
+
|
|
25
|
+
/** Async renders change the page height in bursts; one notification per burst. */
|
|
26
|
+
const LAYOUT_DEBOUNCE_MS = 120;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The product page of a product (`productPath`).
|
|
30
|
+
*
|
|
31
|
+
* @returns `null` without the handle or id the path needs.
|
|
32
|
+
*/
|
|
33
|
+
export function productUrl(handle: string | null | undefined, id?: string | null): string | null {
|
|
34
|
+
return productUrlFor(cfg.productPath || '/product', handle, id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `Intl` throws a RangeError for a locale or currency it doesn't know; the caller then shows a plain fallback. */
|
|
38
|
+
function formatOr<T>(format: () => T, fallback: T): T {
|
|
39
|
+
try {
|
|
40
|
+
return format();
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof RangeError) return fallback;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `30` → `30%` (en, nl) or `30 %` (de, fr), in the configured locale.
|
|
49
|
+
*
|
|
50
|
+
* @returns `''` for no or a zero percentage.
|
|
51
|
+
*/
|
|
52
|
+
export function formatPercent(percentage: number | null | undefined, locale: string | undefined = cfg.locale): string {
|
|
53
|
+
if (!percentage) return '';
|
|
54
|
+
const format = () =>
|
|
55
|
+
new Intl.NumberFormat(locale || undefined, { style: 'percent', maximumFractionDigits: 0 }).format(percentage / 100);
|
|
56
|
+
return formatOr(format, `${percentage}%`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Money in the configured locale, through the site's `formatMoney` when it has one. */
|
|
60
|
+
export function formatMoney(money: Money | null | undefined): string {
|
|
61
|
+
if (!money) return '';
|
|
62
|
+
if (cfg.formatMoney) return cfg.formatMoney(money, cfg.locale);
|
|
63
|
+
return formatMoneyIn(money, cfg.locale);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @returns The date in the configured locale, `''` without one. */
|
|
67
|
+
export function formatDate(iso: string | null | undefined): string {
|
|
68
|
+
if (!iso) return '';
|
|
69
|
+
return formatOr(() => new Date(iso).toLocaleDateString(cfg.locale || undefined), '');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Writes HTML from the Shopify admin (product and collection descriptions) into `el`, with script removed
|
|
74
|
+
* (`store-html.ts`). Everything else from Shopify is written as text (`setText`).
|
|
75
|
+
*/
|
|
76
|
+
export function setStoreHtml(el: Element, html: string | null | undefined): void {
|
|
77
|
+
el.replaceChildren(storeHtmlFragment(html, el.ownerDocument, cfg.embedOrigins));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isTextField = (el: Element): el is HTMLInputElement | HTMLTextAreaElement =>
|
|
81
|
+
el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
|
|
82
|
+
|
|
83
|
+
const isInput = (el: Element): el is HTMLInputElement => el.tagName === 'INPUT';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Sets the text of `el`, or its value when it is a form field.
|
|
87
|
+
*
|
|
88
|
+
* @param value `null` and `undefined` clear it.
|
|
89
|
+
*/
|
|
90
|
+
export function setText(el: Element, value: string | number | boolean | null | undefined): void {
|
|
91
|
+
const text = value == null ? '' : String(value);
|
|
92
|
+
if (isTextField(el)) el.value = text;
|
|
93
|
+
else el.textContent = text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const readText = (el: Element) => (isTextField(el) ? el.value : el.textContent);
|
|
97
|
+
|
|
98
|
+
/** `setText` that hands a change after the first render to `onValueChange`, for scramble and roll effects. */
|
|
99
|
+
export function setTextTracked(el: HTMLElement, value: string | number | boolean | null | undefined): void {
|
|
100
|
+
const before = readText(el);
|
|
101
|
+
setText(el, value);
|
|
102
|
+
if (before !== readText(el) && el.hasAttribute(SEEN) && cfg.onValueChange) {
|
|
103
|
+
try {
|
|
104
|
+
cfg.onValueChange(el, before);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
reportError(error, 'onValueChange');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
el.setAttribute(SEEN, '1');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Shows an element hidden before the script ran (a class, an inline `display: none`, a `hidden` attribute), as
|
|
114
|
+
* `block` or the value of `tether-display`.
|
|
115
|
+
*/
|
|
116
|
+
function reveal(el: HTMLElement): void {
|
|
117
|
+
if (el.hasAttribute(SHOWN)) return;
|
|
118
|
+
el.setAttribute(SHOWN, '');
|
|
119
|
+
el.removeAttribute('hidden');
|
|
120
|
+
const previous = el.getAttribute(PREVIOUS_DISPLAY);
|
|
121
|
+
if (previous !== null) {
|
|
122
|
+
el.style.display = previous;
|
|
123
|
+
el.removeAttribute(PREVIOUS_DISPLAY);
|
|
124
|
+
} else if (el.style.display === 'none') {
|
|
125
|
+
el.style.display = '';
|
|
126
|
+
}
|
|
127
|
+
if (typeof getComputedStyle === 'function' && getComputedStyle(el).display === 'none') {
|
|
128
|
+
el.style.display = el.getAttribute(attr('display')) || 'block';
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Shows or hides `el`, restoring its own display value when shown again. */
|
|
133
|
+
export function show(el: HTMLElement, visible: boolean): void {
|
|
134
|
+
if (visible) {
|
|
135
|
+
reveal(el);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
el.removeAttribute(SHOWN);
|
|
139
|
+
// The display value to restore is a real one, never 'none'.
|
|
140
|
+
if (!el.hasAttribute(PREVIOUS_DISPLAY) && el.style.display !== 'none') {
|
|
141
|
+
el.setAttribute(PREVIOUS_DISPLAY, el.style.display);
|
|
142
|
+
}
|
|
143
|
+
el.style.display = 'none';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Marks an option button or input as chosen: `is-selected`, `aria-pressed`, and its own or inner radio/checkbox. */
|
|
147
|
+
export function setSelected(el: Element, selected: boolean): void {
|
|
148
|
+
el.classList.toggle('is-selected', selected);
|
|
149
|
+
el.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
|
150
|
+
if (isInput(el) && (el.type === 'radio' || el.type === 'checkbox')) el.checked = selected;
|
|
151
|
+
const input = el.querySelector('input[type="radio"],input[type="checkbox"]');
|
|
152
|
+
if (input && isInput(input)) input.checked = selected;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let layoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A global the page's own scripts set on `window`: `TETHER_CONFIG`, the site's config, `TETHER_INTEGRATION`, set by a
|
|
159
|
+
* framework integration (Astro) that starts Tether itself, and GSAP's `ScrollTrigger`. Another script wrote it, so it
|
|
160
|
+
* is read as unknown and checked where it is used.
|
|
161
|
+
*/
|
|
162
|
+
export const pageGlobal = (name: 'TETHER_CONFIG' | 'TETHER_INTEGRATION' | 'ScrollTrigger'): unknown =>
|
|
163
|
+
Reflect.get(window, name);
|
|
164
|
+
|
|
165
|
+
/** Refreshes GSAP's ScrollTrigger, when the site has it: it measures positions that a height change makes stale. */
|
|
166
|
+
function refreshScrollTrigger(): void {
|
|
167
|
+
// ScrollTrigger is a class, and `refresh` a static method of it.
|
|
168
|
+
const scrollTrigger = pageGlobal('ScrollTrigger');
|
|
169
|
+
if (typeof scrollTrigger !== 'function') return;
|
|
170
|
+
const refresh: unknown = Reflect.get(scrollTrigger, 'refresh');
|
|
171
|
+
if (typeof refresh === 'function') Reflect.apply(refresh, scrollTrigger, []);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Async renders change the page height after the site's page-transition hooks ran. After a burst of them this calls
|
|
176
|
+
* `onLayoutChange` (else refreshes ScrollTrigger) and emits `tether:layout-changed`, also when either of those throws.
|
|
177
|
+
*/
|
|
178
|
+
export function notifyLayoutChanged(reason: string): void {
|
|
179
|
+
clearTimeout(layoutTimer);
|
|
180
|
+
layoutTimer = setTimeout(() => {
|
|
181
|
+
try {
|
|
182
|
+
if (cfg.onLayoutChange) cfg.onLayoutChange(reason);
|
|
183
|
+
else refreshScrollTrigger();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
reportError(error, 'layout-changed');
|
|
186
|
+
}
|
|
187
|
+
emit('layout-changed', { reason });
|
|
188
|
+
}, LAYOUT_DEBOUNCE_MS);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Notifies a layout change (`<reason>:images`) once every image in `root` that is still loading has loaded or failed. */
|
|
192
|
+
export function watchImages(root: ParentNode, reason: string): void {
|
|
193
|
+
const loading = [...root.querySelectorAll('img')].filter((img) => !img.complete);
|
|
194
|
+
let left = loading.length;
|
|
195
|
+
const done = () => {
|
|
196
|
+
left -= 1;
|
|
197
|
+
if (left === 0) notifyLayoutChanged(`${reason}:images`);
|
|
198
|
+
};
|
|
199
|
+
loading.forEach((img) => {
|
|
200
|
+
img.addEventListener('load', done, { once: true });
|
|
201
|
+
img.addEventListener('error', done, { once: true });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Runs `task` with `aria-busy` on `el`; does nothing while `el` is already busy.
|
|
207
|
+
*
|
|
208
|
+
* @returns The task's result, `undefined` when it didn't run.
|
|
209
|
+
*/
|
|
210
|
+
export function busy<T>(el: Element, task: () => T | Promise<T>): Promise<T | undefined> {
|
|
211
|
+
if (el.getAttribute('aria-busy') === 'true') return Promise.resolve(undefined);
|
|
212
|
+
el.setAttribute('aria-busy', 'true');
|
|
213
|
+
return Promise.resolve()
|
|
214
|
+
.then(task)
|
|
215
|
+
.finally(() => {
|
|
216
|
+
el.removeAttribute('aria-busy');
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** @returns The attribute's JSON, `null` when it is missing or not JSON (with a warning). */
|
|
221
|
+
export function parseJSONAttr(el: Element | null | undefined, name: string): unknown {
|
|
222
|
+
const raw = el ? get(el, name) : null;
|
|
223
|
+
if (!raw) return null;
|
|
224
|
+
try {
|
|
225
|
+
const json: unknown = JSON.parse(raw);
|
|
226
|
+
return json;
|
|
227
|
+
} catch {
|
|
228
|
+
warn(`invalid JSON in ${attr(name)}`, el);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export type AttributeValue = string | number | boolean | null | undefined;
|
|
234
|
+
|
|
235
|
+
/** @returns A JSON value as attribute text: `''` for none, an object or array as its JSON. */
|
|
236
|
+
function attributeText(value: unknown): string {
|
|
237
|
+
if (value === null || value === undefined) return '';
|
|
238
|
+
if (typeof value === 'string') return value;
|
|
239
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
240
|
+
// What else parsed JSON holds: objects and arrays.
|
|
241
|
+
return JSON.stringify(value);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* `tether-line-attributes`, JSON the site writes: `{ a: 1 }` or `[{ key, value }]`. An array entry that isn't an
|
|
246
|
+
* object is skipped.
|
|
247
|
+
*/
|
|
248
|
+
export function attributesFromJson(json: unknown): { key: string; value: string }[] {
|
|
249
|
+
if (Array.isArray(json)) {
|
|
250
|
+
const entries: unknown[] = json;
|
|
251
|
+
return entries
|
|
252
|
+
.filter(isRecord)
|
|
253
|
+
.map((entry) => ({ key: attributeText(entry['key']), value: attributeText(entry['value']) }));
|
|
254
|
+
}
|
|
255
|
+
if (!isRecord(json)) return [];
|
|
256
|
+
return Object.entries(json).map(([key, value]) => ({ key, value: attributeText(value) }));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** `{ a: 1 }` or `[{ key, value }]` → `[{ key, value }]` as Shopify wants cart and line attributes. */
|
|
260
|
+
export function toAttributes(
|
|
261
|
+
attributes: Record<string, AttributeValue> | { key: string | number; value?: AttributeValue }[] | null | undefined,
|
|
262
|
+
): { key: string; value: string }[] {
|
|
263
|
+
if (!attributes) return [];
|
|
264
|
+
if (Array.isArray(attributes)) {
|
|
265
|
+
return attributes.map((attribute) => ({ key: String(attribute.key), value: String(attribute.value ?? '') }));
|
|
266
|
+
}
|
|
267
|
+
return Object.entries(attributes).map(([key, value]) => ({ key, value: String(value ?? '') }));
|
|
268
|
+
}
|
package/src/extras.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional part with features that load when first needed: bundles (on the first "add set" click), tab sync (on the
|
|
3
|
+
* first change in another tab) and shareable variant links (on the first variant change, with `variantUrl` or
|
|
4
|
+
* `tether-variant-url`).
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, warn, reportError } from './config.ts';
|
|
7
|
+
import { sel, get, has, qsa, emit, parseJSONAttr, attributesFromJson } from './dom.ts';
|
|
8
|
+
import { scopes } from './product.ts';
|
|
9
|
+
import { Cart } from './cart.ts';
|
|
10
|
+
import { isRecord } from '../server/src/shared/json.ts';
|
|
11
|
+
import type { ShopifyCart } from './cart/store.ts';
|
|
12
|
+
|
|
13
|
+
interface StoredMarket {
|
|
14
|
+
country: string;
|
|
15
|
+
language: string | null;
|
|
16
|
+
locale: string | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type SetCountry = (
|
|
20
|
+
country: string,
|
|
21
|
+
options: { language: string | null; locale: string | undefined; remember: boolean },
|
|
22
|
+
) => Promise<unknown>;
|
|
23
|
+
|
|
24
|
+
/** @param name The group's `tether-group` value. */
|
|
25
|
+
function groupAttributes(group: Element, name: string | null) {
|
|
26
|
+
const extra = attributesFromJson(parseJSONAttr(group, 'line-attributes'));
|
|
27
|
+
return name ? [{ key: '_group', value: name }, ...extra] : extra;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `[tether-group]` with `[tether-group-add]`: adds every product scope inside the group in one request, for bundles,
|
|
32
|
+
* build-a-box and "complete the set". The lines get the attribute `_group` and the group's `tether-line-attributes`.
|
|
33
|
+
*
|
|
34
|
+
* @returns The cart, `null` with nothing to add or after a reported failure.
|
|
35
|
+
*/
|
|
36
|
+
export async function addGroup(button: Element): Promise<ShopifyCart | null> {
|
|
37
|
+
const group = button.closest(sel('group'));
|
|
38
|
+
if (!group) return null;
|
|
39
|
+
const name = get(group, 'group');
|
|
40
|
+
const attributes = groupAttributes(group, name);
|
|
41
|
+
const lines = qsa(group, sel('product')).flatMap((el) => {
|
|
42
|
+
const scope = scopes.get(el);
|
|
43
|
+
if (!scope?.variant?.availableForSale || scope.quantity <= 0) return [];
|
|
44
|
+
const line = scope.getLine();
|
|
45
|
+
return line ? [{ ...line, attributes: [...(line.attributes ?? []), ...attributes] }] : [];
|
|
46
|
+
});
|
|
47
|
+
if (!lines.length) {
|
|
48
|
+
warn('group-add: nothing selected');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
const cart = await Cart.add(lines);
|
|
53
|
+
emit('group-added', { group: name, lines }, group);
|
|
54
|
+
return cart;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reportError(error, 'group add');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Another tab writes the market as JSON; a value that isn't (written by something else under the key) is skipped. */
|
|
62
|
+
function parseStoredMarket(text: string): StoredMarket | null {
|
|
63
|
+
let value: unknown;
|
|
64
|
+
try {
|
|
65
|
+
value = JSON.parse(text);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
if (!isRecord(value)) return null;
|
|
70
|
+
const { country, language, locale } = value;
|
|
71
|
+
if (typeof country !== 'string' || !country) return null;
|
|
72
|
+
return {
|
|
73
|
+
country,
|
|
74
|
+
language: typeof language === 'string' ? language : null,
|
|
75
|
+
locale: typeof locale === 'string' ? locale : undefined,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Another tab changed the cart (it is loaded again here) or the visitor's market (this tab follows it). */
|
|
80
|
+
export function onStorage(event: StorageEvent): void {
|
|
81
|
+
if (event.key === cfg.storageKey || event.key === `${cfg.storageKey}:seen`) {
|
|
82
|
+
Cart.init().catch((error: unknown) => {
|
|
83
|
+
reportError(error, 'cart from another tab');
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (event.key !== `${cfg.prefix}:market:${cfg.domain}` || !event.newValue) return;
|
|
88
|
+
const market = parseStoredMarket(event.newValue);
|
|
89
|
+
// The core's `setCountry` takes the `null` language and `undefined` locale stored here, which the public type of
|
|
90
|
+
// `window.Tether` leaves out.
|
|
91
|
+
const tether = Reflect.get(window, 'Tether') as { setCountry: SetCountry } | undefined;
|
|
92
|
+
if (!market || !tether) return;
|
|
93
|
+
const options = { language: market.language, locale: market.locale, remember: false };
|
|
94
|
+
tether.setCountry(market.country, options).catch((error: unknown) => {
|
|
95
|
+
reportError(error, 'market from another tab');
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Scope root → its last variant and plan, so the first render doesn't write. */
|
|
100
|
+
const urlKeys = new WeakMap<Element, string>();
|
|
101
|
+
|
|
102
|
+
const numericId = (gid: string) => gid.split('/').pop() ?? gid;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Safari throws a SecurityError past 100 history changes in 10 seconds; the address then keeps the previous choice
|
|
106
|
+
* until the next one.
|
|
107
|
+
*/
|
|
108
|
+
function replaceUrlIfAllowed(url: URL) {
|
|
109
|
+
try {
|
|
110
|
+
window.history.replaceState(window.history.state, '', url);
|
|
111
|
+
} catch {
|
|
112
|
+
// Too many changes in a row: see the function's comment.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `?variant=` (and `selling_plan=`) follow the visitor's choice.
|
|
118
|
+
*
|
|
119
|
+
* @param root The product scope's root.
|
|
120
|
+
*/
|
|
121
|
+
export function writeVariantUrl(
|
|
122
|
+
root: Element,
|
|
123
|
+
{ variant, sellingPlanId }: { variant: { id: string }; sellingPlanId: string | null | undefined },
|
|
124
|
+
): void {
|
|
125
|
+
const key = `${variant.id}|${sellingPlanId ?? ''}`;
|
|
126
|
+
const before = urlKeys.get(root);
|
|
127
|
+
urlKeys.set(root, key);
|
|
128
|
+
if (!before || before === key) return;
|
|
129
|
+
const isMainProduct = cfg.variantUrl && document.querySelector(sel('product')) === root;
|
|
130
|
+
if (!has(root, 'variant-url') && !isMainProduct) return;
|
|
131
|
+
const url = new URL(window.location.href);
|
|
132
|
+
url.searchParams.set('variant', numericId(variant.id));
|
|
133
|
+
if (sellingPlanId) url.searchParams.set('selling_plan', numericId(sellingPlanId));
|
|
134
|
+
else url.searchParams.delete('selling_plan');
|
|
135
|
+
replaceUrlIfAllowed(url);
|
|
136
|
+
}
|