@occluze/tether 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/esm/astro.js +2 -2
- package/dist/esm/chunks/account-C3NP6HFB.js +1 -0
- package/dist/esm/chunks/account-pages-WLK374FO.js +1 -0
- package/dist/esm/chunks/analytics-G3SAWMNL.js +1 -0
- package/dist/esm/chunks/chunk-27PJJZGC.js +1 -0
- package/dist/esm/chunks/chunk-2FSXS55U.js +1 -0
- package/dist/esm/chunks/chunk-FCSWQDVV.js +1 -0
- package/dist/esm/chunks/chunk-FOMSMIAM.js +1 -0
- package/dist/esm/chunks/chunk-GZJTCLLW.js +1 -0
- package/dist/esm/chunks/chunk-HEQILZVR.js +1 -0
- package/dist/esm/chunks/chunk-JOOEXYF3.js +4 -0
- package/dist/esm/chunks/chunk-MPTQYLSM.js +1 -0
- package/dist/esm/chunks/chunk-NEHZ37TU.js +3 -0
- package/dist/esm/chunks/chunk-V74KGEJF.js +1 -0
- package/dist/esm/chunks/chunk-VT4CDUJJ.js +1 -0
- package/dist/esm/chunks/chunk-YJLWX2HK.js +1 -0
- package/dist/esm/chunks/diagnose-6VVGZNTY.js +14 -0
- package/dist/esm/chunks/extras-GJVRDFM3.js +1 -0
- package/dist/esm/chunks/markets-ui-UNB4PTA7.js +1 -0
- package/dist/esm/chunks/search-OU75RNQL.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -1
- package/dist/presets/barba.esm.js +56 -36
- package/dist/tether.account-pages.js +773 -442
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +648 -318
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +318 -191
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +331 -242
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +78 -33
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +3176 -2015
- package/dist/tether.markets.js +231 -126
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +0 -0
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +221 -126
- package/dist/tether.search.min.js +1 -1
- package/package.json +23 -4
- package/src/account/addresses-view.js +92 -0
- package/src/account/browser-session.js +359 -0
- package/src/account/markup.js +48 -0
- package/src/account/operations.js +180 -0
- package/src/account/order-lines.js +96 -0
- package/src/account/order-list.js +91 -0
- package/src/account/order-page.js +192 -0
- package/src/account/page-events.js +288 -0
- package/src/account/server-client.js +172 -0
- package/src/account/shared.js +111 -0
- package/src/account/shopify-auth.js +150 -0
- package/src/account/subscriptions-view.js +158 -0
- package/src/account-api.js +118 -0
- package/src/account-links.js +35 -0
- package/src/account-pages.js +69 -298
- package/src/account.js +280 -323
- package/src/amounts.js +36 -0
- package/src/analytics-shopify.js +241 -0
- package/src/analytics.js +304 -184
- package/src/api-version.js +75 -25
- package/src/astro.js +233 -68
- package/src/cart/checkout.js +97 -0
- package/src/cart/drawer.js +138 -0
- package/src/cart/mutations.js +234 -0
- package/src/cart/render.js +294 -0
- package/src/cart/store.js +326 -0
- package/src/cart.js +320 -488
- package/src/client.js +214 -78
- package/src/collection.js +126 -57
- package/src/config.js +323 -59
- package/src/controls/cart-controls.js +155 -0
- package/src/controls/discount.js +96 -0
- package/src/controls/extras.js +63 -0
- package/src/controls/navigation.js +83 -0
- package/src/diagnose-markup.js +333 -0
- package/src/diagnose.js +251 -202
- package/src/dom.js +219 -220
- package/src/extras.js +119 -37
- package/src/fragments.js +115 -22
- package/src/images.js +104 -0
- package/src/index.js +245 -287
- package/src/initial.js +78 -66
- package/src/lazy-iife.js +92 -22
- package/src/lazy.js +25 -4
- package/src/loader.js +263 -124
- package/src/market-codes.js +35 -0
- package/src/markets-ui.js +169 -55
- package/src/markets.js +286 -85
- package/src/markup.js +64 -0
- package/src/mounting.js +214 -91
- package/src/page-market.js +116 -0
- package/src/parts/account-pages.js +4 -1
- package/src/parts/account.js +4 -1
- package/src/parts/analytics.js +4 -1
- package/src/parts/diagnose.js +4 -1
- package/src/parts/extras.js +4 -1
- package/src/parts/markets.js +4 -1
- package/src/parts/search.js +4 -1
- package/src/presets/barba.js +120 -30
- package/src/product-events.js +130 -0
- package/src/product-render.js +257 -0
- package/src/product-state.js +318 -0
- package/src/product.js +233 -459
- package/src/repeaters.js +173 -0
- package/src/search.js +277 -107
- package/src/selling-plans.js +107 -47
- package/src/server.js +168 -205
- package/src/shopify-values.js +89 -0
- package/src/status.js +99 -39
- package/src/store-html.js +0 -0
- package/src/storefront/catalog.js +199 -0
- package/src/storefront/json-ld.js +167 -0
- package/src/variant-render.js +165 -0
- package/types/astro.d.ts +13 -2
- package/types/index.d.ts +178 -27
- package/types/server.d.ts +89 -17
- package/dist/esm/chunks/account-Q6E2TFEV.js +0 -1
- package/dist/esm/chunks/account-pages-UZXR4R5F.js +0 -1
- package/dist/esm/chunks/analytics-IJBKATUP.js +0 -1
- package/dist/esm/chunks/chunk-4QJKLPYR.js +0 -1
- package/dist/esm/chunks/chunk-64TUD3QR.js +0 -1
- package/dist/esm/chunks/chunk-AJLPU2JC.js +0 -4
- package/dist/esm/chunks/chunk-FIOZPPC5.js +0 -1
- package/dist/esm/chunks/chunk-K2YEQAIH.js +0 -1
- package/dist/esm/chunks/chunk-KIDH76B7.js +0 -1
- package/dist/esm/chunks/chunk-PAYT5ZD7.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-SSJT43CJ.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/diagnose-3WVAWNTX.js +0 -14
- package/dist/esm/chunks/extras-7I7WJX35.js +0 -1
- package/dist/esm/chunks/markets-ui-545XLFTE.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/config.js
CHANGED
|
@@ -1,85 +1,349 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* The runtime configuration. `cfg` is one shared object that every browser module imports; `configure()` replaces its
|
|
3
|
+
* contents in place, so those imports see the new values, and `setCountry()` changes its market keys the same way.
|
|
4
|
+
* Options that end up in query text or attribute names are validated here, once, where they arrive.
|
|
3
5
|
*/
|
|
6
|
+
/** @import { Money } from './shopify-values.js' */
|
|
4
7
|
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} MarketsConfig Where the visitor's market comes from (`markets.js`).
|
|
10
|
+
* @property {boolean | ('saved' | 'server' | 'webflow')[]} [detect] true: every source; a list: only those.
|
|
11
|
+
* @property {Record<string, { country?: string, language?: string, locale?: string }>} [locales] Webflow locale
|
|
12
|
+
* (subdirectory or html lang) to market.
|
|
13
|
+
* @property {string} [cookie] The cookie a server sets with the visitor's country.
|
|
14
|
+
* @property {boolean} [remember] Remember the visitor's choice (default true).
|
|
15
|
+
* @property {boolean} [strict] With `locales`: ignore an html lang that isn't in the map.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} AnalyticsConfig The optional analytics part.
|
|
20
|
+
* @property {boolean | string} [dataLayer] GA4/GTM events, into `window.dataLayer` or the named array.
|
|
21
|
+
* @property {boolean | { consent?: 'no-banner' | 'default-banner' | 'custom-banner', proxy?: string }} [shopify]
|
|
22
|
+
* Shopify's own analytics.
|
|
23
|
+
* @property {(name: string, data: Record<string, unknown>) => void} [onEvent]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {object} AccountConfig The optional customer accounts part.
|
|
28
|
+
* @property {string} [clientId] Browser mode: the Customer Account API client id.
|
|
29
|
+
* @property {string} [server] Server mode: the Worker's account routes, a URL or a path on this site.
|
|
30
|
+
* @property {string} [orderUrl] The site's own order page, with `{id}` for the numeric order id.
|
|
31
|
+
* @property {string} [subscriptionsUrl] Shopify's subscription management page.
|
|
32
|
+
* @property {string} [paymentUrl] A separate page for payment methods (default `subscriptionsUrl`).
|
|
33
|
+
* @property {string} [callbackUrl] Where Shopify sends the visitor after login (default the current page).
|
|
34
|
+
* @property {string} [logoutUrl] Where Shopify sends the visitor after logout (default the home page).
|
|
35
|
+
* @property {boolean} [remember] Keep the login across tabs and restarts (localStorage, not sessionStorage).
|
|
36
|
+
* @property {string} [tokenUrl] The site's own token endpoint, for when the browser can't call Shopify's.
|
|
37
|
+
* @property {string} [shopDomain] The *.myshopify.com domain, when `domain` is another one.
|
|
38
|
+
* @property {string} [locale] The language of Shopify's login pages.
|
|
39
|
+
* @property {(url: string) => void} [navigate] Replaces `location.assign` for the login redirect (popups, tests).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** @typedef {{ namespace: string, key: string }} MetafieldIdentifier */
|
|
43
|
+
/** @typedef {string | MetafieldIdentifier} MetafieldRef `'namespace.key'` or the identifier itself. */
|
|
44
|
+
/** @typedef {{ code: string, message: string, target?: string }} CartWarning Shopify's note on a cart change. */
|
|
45
|
+
/** @typedef {'_self' | '_blank'} CheckoutTarget */
|
|
46
|
+
/** @typedef {'one-time' | 'subscription'} PurchaseKind */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @typedef {object} TetherConfig Every option, with its default filled in.
|
|
50
|
+
* @property {string} domain The store's domain, without protocol or path.
|
|
51
|
+
* @property {string} token The public Storefront API access token.
|
|
52
|
+
* @property {string} apiVersion A dated Shopify API version.
|
|
53
|
+
* @property {string | undefined} country An ISO country code for `@inContext(country:)`.
|
|
54
|
+
* @property {string | undefined} language A language for `@inContext(language:)`.
|
|
55
|
+
* @property {string | undefined} locale An `Intl` locale for money and dates.
|
|
56
|
+
* @property {MarketsConfig | null} markets
|
|
57
|
+
* @property {string} prefix The attribute prefix: `tether` reads `tether-product`, `tether-bind`.
|
|
58
|
+
* @property {string} productPath A folder (`/product`) or a template with `{handle}` / `{id}`.
|
|
59
|
+
* @property {string} collectionPath
|
|
60
|
+
* @property {MetafieldRef[]} metafields The product metafields to fetch.
|
|
61
|
+
* @property {number} imagesFirst
|
|
62
|
+
* @property {number} variantsFirst
|
|
63
|
+
* @property {number} sellingPlanGroupsFirst
|
|
64
|
+
* @property {number} sellingPlansFirst
|
|
65
|
+
* @property {number} collectionFirst
|
|
66
|
+
* @property {number} retries Retries of a request that failed on the network, a 429/5xx or Shopify's throttling.
|
|
67
|
+
* @property {boolean} revalidate Refresh server-rendered products once in the background.
|
|
68
|
+
* @property {string} storageKey The localStorage key of the cart id (default `<prefix>:cart:<domain>`).
|
|
69
|
+
* @property {string} cartDrawerOpenClass
|
|
70
|
+
* @property {boolean} openCartOnAdd
|
|
71
|
+
* @property {boolean} cartFocus Move focus into the open cart, keep Tab inside it, restore it on close.
|
|
72
|
+
* @property {CheckoutTarget} checkoutTarget
|
|
73
|
+
* @property {PurchaseKind} defaultPurchase
|
|
74
|
+
* @property {boolean} variantUrl `?variant=` follows the visitor's choice on the page's main product.
|
|
75
|
+
* @property {number} maxAge Seconds a Shopify answer may be cached; 0 leaves it to Shopify, which can be hours.
|
|
76
|
+
* @property {number} lowStock `tether-show="low-stock"` at this many or fewer (needs inventory access).
|
|
77
|
+
* @property {number} searchDelay Milliseconds the search part waits after the last keystroke.
|
|
78
|
+
* @property {boolean} autoMount Mount the document on DOMContentLoaded.
|
|
79
|
+
* @property {boolean} observe Mount elements added later (CMS load more, filters).
|
|
80
|
+
* @property {boolean} astro Mount on Astro's `astro:page-load` and close the cart on navigation.
|
|
81
|
+
* @property {string | null} partsUrl The folder of the optional parts, for the `<script>` build.
|
|
82
|
+
* @property {AnalyticsConfig | null} analytics
|
|
83
|
+
* @property {AccountConfig | null} account
|
|
84
|
+
* @property {((warning: CartWarning) => string | null | undefined) | null} warningText The site's text for a cart
|
|
85
|
+
* warning; nothing keeps Shopify's.
|
|
86
|
+
* @property {((money: Money, locale: string | undefined) => string) | null} formatMoney
|
|
87
|
+
* @property {((reason: string) => void) | null} onLayoutChange Default: `ScrollTrigger.refresh()` when present.
|
|
88
|
+
* @property {((drawers: HTMLElement[]) => void) | null} onCartOpen
|
|
89
|
+
* @property {((drawers: HTMLElement[]) => void) | null} onCartClose
|
|
90
|
+
* @property {((node: HTMLElement) => void) | null} onLineEnter
|
|
91
|
+
* @property {((node: HTMLElement, done: () => void) => void) | null} onLineLeave Animates a line out, then calls
|
|
92
|
+
* `done()`.
|
|
93
|
+
* @property {((element: HTMLElement, previousText: string) => void) | null} onValueChange
|
|
94
|
+
* @property {((error: unknown, context: string) => void) | null} onError Every failure that reaches the visitor.
|
|
95
|
+
* @property {boolean} debug
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/** @typedef {{ [Key in keyof TetherConfig]?: TetherConfig[Key] }} TetherOptions */
|
|
99
|
+
|
|
100
|
+
/** @type {Readonly<TetherConfig>} */
|
|
5
101
|
export const DEFAULTS = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
token: '', // public Storefront API access token
|
|
102
|
+
domain: '',
|
|
103
|
+
token: '',
|
|
9
104
|
apiVersion: '2026-07',
|
|
10
|
-
country: undefined,
|
|
11
|
-
language: undefined,
|
|
12
|
-
locale: undefined,
|
|
13
|
-
markets: null,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
prefix: 'tether', // attribute prefix: tether-product, tether-bind, ...
|
|
17
|
-
productPath: '/product', // Webflow CMS template path for products
|
|
105
|
+
country: undefined,
|
|
106
|
+
language: undefined,
|
|
107
|
+
locale: undefined,
|
|
108
|
+
markets: null,
|
|
109
|
+
prefix: 'tether',
|
|
110
|
+
productPath: '/product',
|
|
18
111
|
collectionPath: '/collection',
|
|
19
|
-
|
|
20
|
-
// Fetching
|
|
21
|
-
metafields: [], // ['custom.material', { namespace, key }]
|
|
112
|
+
metafields: [],
|
|
22
113
|
imagesFirst: 25,
|
|
23
114
|
variantsFirst: 100,
|
|
24
115
|
sellingPlanGroupsFirst: 10,
|
|
25
116
|
sellingPlansFirst: 25,
|
|
26
117
|
collectionFirst: 24,
|
|
27
118
|
retries: 2,
|
|
28
|
-
revalidate: true,
|
|
29
|
-
|
|
30
|
-
// Cart
|
|
31
|
-
storageKey: null, // defaults to `tether:cart:<domain>`
|
|
119
|
+
revalidate: true,
|
|
120
|
+
storageKey: '',
|
|
32
121
|
cartDrawerOpenClass: 'is-open',
|
|
33
122
|
openCartOnAdd: true,
|
|
34
|
-
cartFocus: true,
|
|
35
|
-
checkoutTarget: '_self',
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
onLineEnter: null, // (node) => void, animate a new cart line in
|
|
57
|
-
onLineLeave: null, // (node, done) => void, animate out, then call done()
|
|
58
|
-
onValueChange: null, // (el, previousText) => void, e.g. text scramble
|
|
59
|
-
onError: null, // (error, context) => void
|
|
60
|
-
|
|
123
|
+
cartFocus: true,
|
|
124
|
+
checkoutTarget: '_self',
|
|
125
|
+
defaultPurchase: 'one-time',
|
|
126
|
+
variantUrl: false,
|
|
127
|
+
maxAge: 60,
|
|
128
|
+
lowStock: 5,
|
|
129
|
+
searchDelay: 150,
|
|
130
|
+
autoMount: true,
|
|
131
|
+
observe: false,
|
|
132
|
+
astro: true,
|
|
133
|
+
partsUrl: null,
|
|
134
|
+
analytics: null,
|
|
135
|
+
account: null,
|
|
136
|
+
warningText: null,
|
|
137
|
+
formatMoney: null,
|
|
138
|
+
onLayoutChange: null,
|
|
139
|
+
onCartOpen: null,
|
|
140
|
+
onCartClose: null,
|
|
141
|
+
onLineEnter: null,
|
|
142
|
+
onLineLeave: null,
|
|
143
|
+
onValueChange: null,
|
|
144
|
+
onError: null,
|
|
61
145
|
debug: false,
|
|
62
146
|
};
|
|
63
147
|
|
|
148
|
+
/** @type {TetherConfig} */
|
|
64
149
|
export const cfg = { ...DEFAULTS };
|
|
65
150
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
151
|
+
/** The prefix becomes part of attribute names, CSS selectors and event names, so it is kept to those characters. */
|
|
152
|
+
const PREFIX = /^[a-z][a-z0-9-]*$/;
|
|
153
|
+
/**
|
|
154
|
+
* Metafield identifiers are written into the product fragment's text: a GraphQL fragment can't declare variables, so
|
|
155
|
+
* they can't be sent as variables. Only these characters are let through, which covers Shopify's namespaces and keys.
|
|
156
|
+
*/
|
|
157
|
+
const METAFIELD_PART = /^[A-Za-z0-9_.-]+$/;
|
|
158
|
+
/** A namespace is the same, or an app-reserved one: `$app` or `$app:<name>`. */
|
|
159
|
+
const METAFIELD_NAMESPACE = /^(\$app(:[A-Za-z0-9_-]+)?|[A-Za-z0-9_.-]+)$/;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @typedef {'imagesFirst' | 'variantsFirst' | 'sellingPlanGroupsFirst' | 'sellingPlansFirst' | 'collectionFirst'
|
|
163
|
+
* | 'retries' | 'maxAge' | 'lowStock'} CountOption
|
|
164
|
+
*/
|
|
165
|
+
/**
|
|
166
|
+
* @typedef {'warningText' | 'formatMoney' | 'onLayoutChange' | 'onCartOpen' | 'onCartClose' | 'onLineEnter'
|
|
167
|
+
* | 'onLineLeave' | 'onValueChange' | 'onError'} HookOption
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/** @type {readonly CountOption[]} Options that are written into queries as list sizes or used as counts. */
|
|
171
|
+
const COUNT_OPTIONS = [
|
|
172
|
+
'imagesFirst',
|
|
173
|
+
'variantsFirst',
|
|
174
|
+
'sellingPlanGroupsFirst',
|
|
175
|
+
'sellingPlansFirst',
|
|
176
|
+
'collectionFirst',
|
|
177
|
+
'retries',
|
|
178
|
+
'maxAge',
|
|
179
|
+
'lowStock',
|
|
180
|
+
];
|
|
181
|
+
/** @type {readonly HookOption[]} */
|
|
182
|
+
const HOOK_OPTIONS = [
|
|
183
|
+
'warningText',
|
|
184
|
+
'formatMoney',
|
|
185
|
+
'onLayoutChange',
|
|
186
|
+
'onCartOpen',
|
|
187
|
+
'onCartClose',
|
|
188
|
+
'onLineEnter',
|
|
189
|
+
'onLineLeave',
|
|
190
|
+
'onValueChange',
|
|
191
|
+
'onError',
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* @template T
|
|
196
|
+
* @param {string} name
|
|
197
|
+
* @param {unknown} value
|
|
198
|
+
* @param {T} fallback
|
|
199
|
+
* @returns {T} `fallback`, with a warning unless `value` was left out.
|
|
200
|
+
*/
|
|
201
|
+
function invalidOption(name, value, fallback) {
|
|
202
|
+
if (value !== undefined) warn(`invalid ${name} ${JSON.stringify(value)}: using ${JSON.stringify(fallback)}`);
|
|
203
|
+
return fallback;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {CountOption} name
|
|
208
|
+
* @param {unknown} value
|
|
209
|
+
* @returns {number} `value` when it is a non-negative integer, else the default.
|
|
210
|
+
*/
|
|
211
|
+
function countOption(name, value) {
|
|
212
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value;
|
|
213
|
+
return invalidOption(name, value, DEFAULTS[name]);
|
|
73
214
|
}
|
|
74
215
|
|
|
216
|
+
/**
|
|
217
|
+
* @template {string} Choice
|
|
218
|
+
* @param {string} name
|
|
219
|
+
* @param {unknown} value
|
|
220
|
+
* @param {readonly [Choice, ...Choice[]]} choices The first one is the default.
|
|
221
|
+
* @returns {Choice}
|
|
222
|
+
*/
|
|
223
|
+
function choiceOption(name, value, choices) {
|
|
224
|
+
return choices.find((choice) => choice === value) ?? invalidOption(name, value, choices[0]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* @param {unknown} value
|
|
229
|
+
* @returns {string} The store's domain without protocol or path: `https://shop.example/x` → `shop.example`.
|
|
230
|
+
*/
|
|
231
|
+
function domainOption(value) {
|
|
232
|
+
if (typeof value !== 'string') return '';
|
|
233
|
+
return value.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @param {unknown} ref
|
|
238
|
+
* @returns {MetafieldIdentifier | null} The identifier of `'namespace.key'` or `{ namespace, key }`, when both parts are
|
|
239
|
+
* valid identifiers.
|
|
240
|
+
*/
|
|
241
|
+
export function toMetafieldIdentifier(ref) {
|
|
242
|
+
/** @type {unknown} */
|
|
243
|
+
let namespace;
|
|
244
|
+
/** @type {unknown} */
|
|
245
|
+
let key;
|
|
246
|
+
if (typeof ref === 'string') {
|
|
247
|
+
const dot = ref.indexOf('.');
|
|
248
|
+
namespace = dot > 0 ? ref.slice(0, dot) : '';
|
|
249
|
+
key = ref.slice(dot + 1);
|
|
250
|
+
} else if (typeof ref === 'object' && ref !== null) {
|
|
251
|
+
namespace = Reflect.get(ref, 'namespace');
|
|
252
|
+
key = Reflect.get(ref, 'key');
|
|
253
|
+
}
|
|
254
|
+
if (typeof namespace !== 'string' || typeof key !== 'string') return null;
|
|
255
|
+
return METAFIELD_NAMESPACE.test(namespace) && METAFIELD_PART.test(key) ? { namespace, key } : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* @param {unknown} value
|
|
260
|
+
* @returns {MetafieldRef[]} The refs that name a valid identifier, as written; each other one is skipped with a warning.
|
|
261
|
+
*/
|
|
262
|
+
function metafieldsOption(value) {
|
|
263
|
+
if (!Array.isArray(value)) return invalidOption('metafields', value, []);
|
|
264
|
+
/** @type {MetafieldRef[]} */
|
|
265
|
+
const refs = [];
|
|
266
|
+
for (const ref of /** @type {unknown[]} */ (value)) {
|
|
267
|
+
const identifier = toMetafieldIdentifier(ref);
|
|
268
|
+
if (identifier) refs.push(typeof ref === 'string' ? ref : identifier);
|
|
269
|
+
else invalidOption('metafield', ref, null);
|
|
270
|
+
}
|
|
271
|
+
return refs;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The configuration `options` describe: defaults filled in, counts, prefix, choices and metafields validated (an
|
|
276
|
+
* invalid value warns and falls back to its default), hooks that aren't functions dropped so callers only check for
|
|
277
|
+
* null.
|
|
278
|
+
*
|
|
279
|
+
* @param {TetherOptions} options
|
|
280
|
+
* @returns {TetherConfig}
|
|
281
|
+
*/
|
|
282
|
+
export function resolveConfig(options) {
|
|
283
|
+
const domain = domainOption(options.domain);
|
|
284
|
+
const prefix =
|
|
285
|
+
typeof options.prefix === 'string' && PREFIX.test(options.prefix)
|
|
286
|
+
? options.prefix
|
|
287
|
+
: invalidOption('prefix', options.prefix, DEFAULTS.prefix);
|
|
288
|
+
/** @type {TetherConfig} */
|
|
289
|
+
const resolved = {
|
|
290
|
+
...DEFAULTS,
|
|
291
|
+
...options,
|
|
292
|
+
domain,
|
|
293
|
+
prefix,
|
|
294
|
+
storageKey: options.storageKey || `${prefix}:cart:${domain}`,
|
|
295
|
+
metafields: metafieldsOption(options.metafields ?? []),
|
|
296
|
+
checkoutTarget: choiceOption('checkoutTarget', options.checkoutTarget, ['_self', '_blank']),
|
|
297
|
+
defaultPurchase: choiceOption('defaultPurchase', options.defaultPurchase, ['one-time', 'subscription']),
|
|
298
|
+
};
|
|
299
|
+
for (const name of COUNT_OPTIONS) resolved[name] = countOption(name, options[name]);
|
|
300
|
+
for (const name of HOOK_OPTIONS) if (typeof resolved[name] !== 'function') resolved[name] = null;
|
|
301
|
+
if (resolved.apiVersion === 'unstable') {
|
|
302
|
+
warn('apiVersion "unstable" changes without notice. Pin a dated version (e.g. 2026-07).');
|
|
303
|
+
}
|
|
304
|
+
return resolved;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Replaces the shared configuration with `options` over the defaults (see `resolveConfig`).
|
|
309
|
+
*
|
|
310
|
+
* @param {TetherOptions} [options]
|
|
311
|
+
* @returns {TetherConfig} The shared `cfg`.
|
|
312
|
+
*/
|
|
313
|
+
export function configure(options = {}) {
|
|
314
|
+
const resolved = resolveConfig(options);
|
|
315
|
+
for (const key of Object.keys(cfg)) {
|
|
316
|
+
if (!(key in resolved)) Reflect.deleteProperty(cfg, key);
|
|
317
|
+
}
|
|
318
|
+
return Object.assign(cfg, resolved);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** @returns {boolean} True once a domain and a token are set. */
|
|
75
322
|
export const isConfigured = () => Boolean(cfg.domain && cfg.token);
|
|
76
323
|
|
|
77
|
-
|
|
78
|
-
export function
|
|
324
|
+
/** @param {...unknown} args */
|
|
325
|
+
export function log(...args) {
|
|
326
|
+
if (cfg.debug) console.log(`[${cfg.prefix}]`, ...args);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @param {...unknown} args */
|
|
330
|
+
export function warn(...args) {
|
|
331
|
+
console.warn(`[${cfg.prefix}]`, ...args);
|
|
332
|
+
}
|
|
79
333
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Logs a failure that reaches the visitor and hands it to `onError`. A throwing `onError` is logged, never rethrown:
|
|
336
|
+
* the site's hook must not break the render that reported the failure.
|
|
337
|
+
*
|
|
338
|
+
* @param {unknown} error
|
|
339
|
+
* @param {string} context What failed, e.g. `product coffee-box`.
|
|
340
|
+
*/
|
|
341
|
+
export function reportError(error, context) {
|
|
342
|
+
warn(context, error instanceof Error && error.message ? error.message : error);
|
|
343
|
+
if (!cfg.onError) return;
|
|
344
|
+
try {
|
|
345
|
+
cfg.onError(error, context);
|
|
346
|
+
} catch (hookError) {
|
|
347
|
+
warn('onError threw', hookError);
|
|
84
348
|
}
|
|
85
349
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cart's markup controls, delegated from the document so they keep working across page swaps: open, close,
|
|
3
|
+
* clear and checkout buttons, a line's +, - and remove buttons, its quantity input and plan select, the cart note, and
|
|
4
|
+
* the keyboard of the open drawer (Escape closes it, Tab stays inside it).
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, reportError } from '../config.js';
|
|
7
|
+
import { Cart, trapFocus } from '../cart.js';
|
|
8
|
+
import { sel, busy } from '../dom.js';
|
|
9
|
+
|
|
10
|
+
/** @returns {string} The attribute a rendered cart line carries its line id in. */
|
|
11
|
+
const lineKeyAttribute = () => `data-${cfg.prefix}-key`;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {EventTarget | null} target
|
|
15
|
+
* @returns {target is Element}
|
|
16
|
+
*/
|
|
17
|
+
const isElement = (target) => target instanceof window.Element;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {Element} target
|
|
21
|
+
* @returns {{ line: Element, id: string } | null} The rendered cart line `target` is in, with its line id.
|
|
22
|
+
*/
|
|
23
|
+
function lineOf(target) {
|
|
24
|
+
const line = target.closest(`[${lineKeyAttribute()}]`);
|
|
25
|
+
const id = line?.getAttribute(lineKeyAttribute());
|
|
26
|
+
return line && id ? { line, id } : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Promise<unknown>} change
|
|
31
|
+
* @param {string} context
|
|
32
|
+
*/
|
|
33
|
+
function reportFailure(change, context) {
|
|
34
|
+
change.catch((/** @type {unknown} */ error) => {
|
|
35
|
+
reportError(error, context);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A line's remove, + and - buttons.
|
|
41
|
+
*
|
|
42
|
+
* @param {MouseEvent} event
|
|
43
|
+
* @param {Element} target
|
|
44
|
+
*/
|
|
45
|
+
function onLineClick(event, target) {
|
|
46
|
+
const found = lineOf(target);
|
|
47
|
+
if (!found?.line.closest(sel('cart-lines'))) return;
|
|
48
|
+
const { line, id } = found;
|
|
49
|
+
if (target.closest(sel('line-remove'))) {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
line.classList.add('is-busy');
|
|
52
|
+
reportFailure(Cart.removeLine(id), 'cart line');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const delta = lineDelta(target);
|
|
56
|
+
if (!delta || !Cart.lines.some((candidate) => candidate.id === id)) return;
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
reportFailure(Cart.changeQuantity(id, delta), 'cart line');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {Element} target
|
|
63
|
+
* @returns {1 | -1 | 0} The quantity change of a + or - button, 0 for anything else.
|
|
64
|
+
*/
|
|
65
|
+
function lineDelta(target) {
|
|
66
|
+
if (target.closest(sel('line-plus'))) return 1;
|
|
67
|
+
if (target.closest(sel('line-minus'))) return -1;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {MouseEvent} event */
|
|
72
|
+
function onClick(event) {
|
|
73
|
+
const { target } = event;
|
|
74
|
+
if (!isElement(target)) return;
|
|
75
|
+
if (target.closest(sel('cart-open'))) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
Cart.toggle();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (target.closest(sel('cart-close'))) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
Cart.close();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const clear = target.closest(sel('cart-clear'));
|
|
86
|
+
if (clear) {
|
|
87
|
+
event.preventDefault();
|
|
88
|
+
void busy(clear, () =>
|
|
89
|
+
Cart.clear().catch((/** @type {unknown} */ error) => {
|
|
90
|
+
reportError(error, 'cart clear');
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const checkout = target.closest(sel('checkout'));
|
|
96
|
+
if (checkout) {
|
|
97
|
+
event.preventDefault();
|
|
98
|
+
void busy(checkout, () => Cart.checkout());
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
onLineClick(event, target);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The line shows `is-busy` and `aria-busy` until Shopify has answered; the next render removes them.
|
|
106
|
+
*
|
|
107
|
+
* @param {Element} line
|
|
108
|
+
* @param {Promise<unknown>} change
|
|
109
|
+
* @param {string} context
|
|
110
|
+
*/
|
|
111
|
+
function showLineBusy(line, change, context) {
|
|
112
|
+
line.classList.add('is-busy');
|
|
113
|
+
line.setAttribute('aria-busy', 'true');
|
|
114
|
+
reportFailure(change, context);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {Event} event */
|
|
118
|
+
function onChange(event) {
|
|
119
|
+
const { target } = event;
|
|
120
|
+
if (!isElement(target)) return;
|
|
121
|
+
const found = lineOf(target);
|
|
122
|
+
if (found && target instanceof window.HTMLInputElement && target.matches(sel('line', 'quantity'))) {
|
|
123
|
+
showLineBusy(found.line, Cart.updateLine(found.id, parseInt(target.value, 10) || 0), 'cart line');
|
|
124
|
+
} else if (found && target instanceof window.HTMLSelectElement && target.matches(sel('line-plan'))) {
|
|
125
|
+
showLineBusy(found.line, Cart.updateLine(found.id, { sellingPlanId: target.value || null }), 'cart line plan');
|
|
126
|
+
} else if (hasValue(target) && target.matches(sel('cart-note'))) {
|
|
127
|
+
reportFailure(Cart.setNote(target.value), 'cart note');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @param {Element} target
|
|
133
|
+
* @returns {target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement}
|
|
134
|
+
*/
|
|
135
|
+
const hasValue = (target) =>
|
|
136
|
+
target instanceof window.HTMLInputElement ||
|
|
137
|
+
target instanceof window.HTMLTextAreaElement ||
|
|
138
|
+
target instanceof window.HTMLSelectElement;
|
|
139
|
+
|
|
140
|
+
/** @param {KeyboardEvent} event */
|
|
141
|
+
function onKeydown(event) {
|
|
142
|
+
if (!Cart.isOpen()) return;
|
|
143
|
+
if (event.key === 'Escape') {
|
|
144
|
+
Cart.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
trapFocus(event);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Binds the document-wide listeners, once per page load of the script. */
|
|
151
|
+
export function bindCartControls() {
|
|
152
|
+
document.addEventListener('click', onClick);
|
|
153
|
+
document.addEventListener('change', onChange);
|
|
154
|
+
document.addEventListener('keydown', onKeydown);
|
|
155
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discount code form (`tether-discount-form` with `tether-discount-input`) and the remove buttons of applied codes
|
|
3
|
+
* (`tether-discount-remove` inside `tether-discount-code`), delegated from the document. A code Shopify refuses marks
|
|
4
|
+
* the form `is-invalid` (and the input `aria-invalid`) until the visitor types again.
|
|
5
|
+
*/
|
|
6
|
+
import { reportError } from '../config.js';
|
|
7
|
+
import { TetherError } from '../client.js';
|
|
8
|
+
import { Cart } from '../cart.js';
|
|
9
|
+
import { attr, sel, busy, emit } from '../dom.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {EventTarget | null} target
|
|
13
|
+
* @returns {target is Element}
|
|
14
|
+
*/
|
|
15
|
+
const isElement = (target) => target instanceof window.Element;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Element} form
|
|
19
|
+
* @returns {HTMLInputElement | null}
|
|
20
|
+
*/
|
|
21
|
+
function codeInputOf(form) {
|
|
22
|
+
const input = form.querySelector(sel('discount-input')) ?? form.querySelector('input');
|
|
23
|
+
return input instanceof window.HTMLInputElement ? input : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {Element} form
|
|
28
|
+
* @param {HTMLInputElement | null} input
|
|
29
|
+
* @param {boolean} invalid
|
|
30
|
+
*/
|
|
31
|
+
function markInvalid(form, input, invalid) {
|
|
32
|
+
form.classList.toggle('is-invalid', invalid);
|
|
33
|
+
if (invalid) input?.setAttribute('aria-invalid', 'true');
|
|
34
|
+
else input?.removeAttribute('aria-invalid');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {Element} form
|
|
39
|
+
* @param {HTMLInputElement | null} input
|
|
40
|
+
* @param {string} code
|
|
41
|
+
*/
|
|
42
|
+
async function applyCode(form, input, code) {
|
|
43
|
+
try {
|
|
44
|
+
await Cart.applyDiscount(code);
|
|
45
|
+
markInvalid(form, input, false);
|
|
46
|
+
emit('discount-applied', { code }, form);
|
|
47
|
+
if (input) input.value = '';
|
|
48
|
+
} catch (error) {
|
|
49
|
+
markInvalid(form, input, true);
|
|
50
|
+
if (error instanceof TetherError && error.code === 'INVALID_DISCOUNT') emit('discount-invalid', { code }, form);
|
|
51
|
+
else reportError(error, 'discount');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @param {SubmitEvent} event */
|
|
56
|
+
function onSubmit(event) {
|
|
57
|
+
const { target } = event;
|
|
58
|
+
const form = isElement(target) ? target.closest(sel('discount-form')) : null;
|
|
59
|
+
if (!form) return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
const input = codeInputOf(form);
|
|
62
|
+
const code = input?.value.trim();
|
|
63
|
+
if (!code) return;
|
|
64
|
+
void busy(form, () => applyCode(form, input, code));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {Event} event */
|
|
68
|
+
function onInput(event) {
|
|
69
|
+
const { target } = event;
|
|
70
|
+
if (!isElement(target)) return;
|
|
71
|
+
const form = target.closest(sel('discount-form'));
|
|
72
|
+
if (!form) return;
|
|
73
|
+
form.classList.remove('is-invalid');
|
|
74
|
+
target.removeAttribute('aria-invalid');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {MouseEvent} event */
|
|
78
|
+
function onClick(event) {
|
|
79
|
+
const { target } = event;
|
|
80
|
+
const remove = isElement(target) ? target.closest(sel('discount-remove')) : null;
|
|
81
|
+
if (!remove) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
const code = remove.closest(sel('discount-code'))?.getAttribute(attr('discount-code'));
|
|
84
|
+
if (!code) return;
|
|
85
|
+
Cart.removeDiscount(code).catch((/** @type {unknown} */ error) => {
|
|
86
|
+
reportError(error, 'discount');
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Binds the document-wide listeners, once per page load of the script. */
|
|
91
|
+
export function bindDiscountControls() {
|
|
92
|
+
// Capture: the submit is handled before a router (Astro's ClientRouter) turns it into a navigation.
|
|
93
|
+
document.addEventListener('submit', onSubmit, true);
|
|
94
|
+
document.addEventListener('input', onInput);
|
|
95
|
+
document.addEventListener('click', onClick);
|
|
96
|
+
}
|