@occluze/tether 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +776 -432
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +655 -321
- 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 +334 -243
- 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 +3171 -1998
- 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 +68 -284
- package/src/account.js +281 -317
- 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 +322 -481
- 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 -285
- 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-QUBHQG3C.js +0 -1
- package/dist/esm/chunks/account-pages-VWDHF6WA.js +0 -1
- package/dist/esm/chunks/analytics-SLATWPLC.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-L2TRRKYV.js +0 -1
- package/dist/esm/chunks/chunk-RREHGH4A.js +0 -1
- package/dist/esm/chunks/chunk-VC46IEJQ.js +0 -1
- package/dist/esm/chunks/chunk-VCPI7MJ2.js +0 -1
- package/dist/esm/chunks/chunk-VPVJPNB4.js +0 -1
- package/dist/esm/chunks/chunk-ZIUIWEON.js +0 -1
- package/dist/esm/chunks/diagnose-VXQBSD3A.js +0 -14
- package/dist/esm/chunks/extras-R4X34VQL.js +0 -1
- package/dist/esm/chunks/markets-ui-F3R5T5EL.js +0 -1
- package/dist/esm/chunks/search-NILMGYXG.js +0 -1
package/src/astro.js
CHANGED
|
@@ -10,91 +10,256 @@
|
|
|
10
10
|
* PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
|
|
11
11
|
* PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
|
|
12
12
|
*
|
|
13
|
-
* Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* Options are Tether's config (domain and token override .env). Functions (onError, formatMoney, hooks) can't be
|
|
14
|
+
* passed here: set them in window.TETHER_CONFIG in your layout, it is merged in. View Transitions (<ClientRouter />)
|
|
15
|
+
* work without extra code. In dev, changes to src/shop.config.js restart the dev server (other files:
|
|
16
|
+
* tether({ ..., watch: ['my/config.js'] })). Runs in Node, inside Astro's config step.
|
|
17
17
|
*/
|
|
18
|
+
import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.js';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
20
|
+
/**
|
|
21
|
+
* Astro isn't a dependency, so the parts of its integration API this file uses are described here.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {{ warn: (message: string) => void }} AstroLogger
|
|
24
|
+
* @typedef {(options: { context: 'client', access: 'public', optional: boolean }) => unknown} EnvStringField
|
|
25
|
+
* @typedef {object} AstroConfigSetup The argument of Astro's `astro:config:setup` hook.
|
|
26
|
+
* @property {(stage: 'head-inline' | 'page', content: string) => void} injectScript
|
|
27
|
+
* @property {string} command `dev`, `build`, `preview` or `sync`.
|
|
28
|
+
* @property {{ root?: URL }} config
|
|
29
|
+
* @property {AstroLogger} logger
|
|
30
|
+
* @property {(path: string) => void} [addWatchFile]
|
|
31
|
+
* @property {(config: { env: { schema: Record<string, unknown> } }) => void} [updateConfig]
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {object} TetherAstroOptions Tether's serializable config, plus the integration's own options.
|
|
36
|
+
* @property {boolean} [inject] Add the Tether script to every page (default true).
|
|
37
|
+
* @property {string[] | null} [watch] Files that restart the dev server when they change.
|
|
38
|
+
*/
|
|
39
|
+
/** @typedef {{ domain?: unknown, token?: unknown, [option: string]: unknown }} PageConfig */
|
|
40
|
+
/** @typedef {Record<string, string | undefined>} Env */
|
|
23
41
|
|
|
24
|
-
|
|
42
|
+
const DEFAULT_WATCH = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'];
|
|
43
|
+
/**
|
|
44
|
+
* Astro is an optional peer dependency. A variable specifier keeps both tsc and the bundler from resolving it.
|
|
45
|
+
*/
|
|
46
|
+
const ASTRO_CONFIG_MODULE = 'astro/config';
|
|
47
|
+
const ENV_LINE = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/;
|
|
48
|
+
/** A quoted value; a `#` comment may follow the closing quote. */
|
|
49
|
+
const QUOTED_VALUE = /^(["'])(.*?)\1/;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string} command
|
|
53
|
+
* @param {readonly string[]} [argv]
|
|
54
|
+
* @returns {string} The mode Astro runs in: `--mode`, else development for dev and production for everything else.
|
|
55
|
+
*/
|
|
25
56
|
function modeOf(command, argv = process.argv) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
57
|
+
const defaultMode = command === 'dev' ? 'development' : 'production';
|
|
58
|
+
const index = argv.findIndex((arg) => arg === '--mode' || arg.startsWith('--mode='));
|
|
59
|
+
if (index < 0) return defaultMode;
|
|
60
|
+
const flag = argv[index] ?? '';
|
|
61
|
+
const mode = flag.includes('=') ? flag.split('=')[1] : argv[index + 1];
|
|
62
|
+
return mode ?? defaultMode;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {unknown} error
|
|
67
|
+
* @returns {boolean} True for Node's "no such file" error.
|
|
68
|
+
*/
|
|
69
|
+
const isMissingFile = (error) => error instanceof Error && Reflect.get(error, 'code') === 'ENOENT';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {string} path
|
|
73
|
+
* @returns {Promise<string | null>} The file's text, `null` when it doesn't exist: every .env file is optional.
|
|
74
|
+
*/
|
|
75
|
+
async function readOptionalFile(path) {
|
|
76
|
+
const { readFile } = await import('node:fs/promises');
|
|
77
|
+
try {
|
|
78
|
+
return await readFile(path, 'utf8');
|
|
79
|
+
} catch (error) {
|
|
80
|
+
if (isMissingFile(error)) return null;
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} text A .env file.
|
|
87
|
+
* @param {Env} into Receives each variable; a later line wins.
|
|
88
|
+
*/
|
|
89
|
+
function parseEnvFile(text, into) {
|
|
90
|
+
for (const line of text.split(/\r?\n/)) {
|
|
91
|
+
const match = ENV_LINE.exec(line);
|
|
92
|
+
if (!match || line.trim().startsWith('#')) continue;
|
|
93
|
+
const [, name = '', raw = ''] = match;
|
|
94
|
+
const quoted = QUOTED_VALUE.exec(raw);
|
|
95
|
+
into[name] = quoted ? (quoted[2] ?? '') : raw.replace(/\s+#.*$/, '');
|
|
96
|
+
}
|
|
29
97
|
}
|
|
30
98
|
|
|
31
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} command
|
|
101
|
+
* @param {string} root The project folder.
|
|
102
|
+
* @returns {Promise<Env>} The .env files the way Vite reads them (later files win), with `process.env` on top.
|
|
103
|
+
*/
|
|
32
104
|
async function readEnv(command, root) {
|
|
105
|
+
const path = await import('node:path');
|
|
33
106
|
const mode = modeOf(command);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
107
|
+
/** @type {Env} */
|
|
108
|
+
const env = {};
|
|
109
|
+
for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
|
|
110
|
+
const text = await readOptionalFile(path.join(root, name));
|
|
111
|
+
if (text !== null) parseEnvFile(text, env);
|
|
112
|
+
}
|
|
113
|
+
return { ...env, ...process.env };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Fills the store's domain and token from .env when the options don't set them, and refuses a secret token: whatever
|
|
118
|
+
* is read here is written into every page.
|
|
119
|
+
*
|
|
120
|
+
* @param {PageConfig} config Changed in place.
|
|
121
|
+
* @param {Env} env
|
|
122
|
+
* @param {AstroLogger} logger
|
|
123
|
+
* @throws {Error} When the token is a secret Shopify token.
|
|
124
|
+
*/
|
|
125
|
+
function applyStoreEnv(config, env, logger) {
|
|
126
|
+
for (const key of /** @type {const} */ (['domain', 'token'])) {
|
|
127
|
+
if (config[key]) continue;
|
|
128
|
+
const name = STORE_ENV_NAMES[key].find((candidate) => env[candidate]);
|
|
129
|
+
if (!name) continue;
|
|
130
|
+
config[key] = env[name];
|
|
131
|
+
// Whatever is read here is written into every page, so a name without PUBLIC_ is a mistake waiting to happen.
|
|
132
|
+
if (!name.startsWith('PUBLIC_')) {
|
|
133
|
+
logger.warn(`${name} is written into every page as Tether's ${key}. Name it PUBLIC_${name} to say so.`);
|
|
49
134
|
}
|
|
50
|
-
}
|
|
51
|
-
|
|
135
|
+
}
|
|
136
|
+
if (typeof config.token === 'string' && isSecretShopifyToken(config.token)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
'The Shopify token for Tether is a secret token (shpat_/shpss_/…), which would be published in every page. Use the public Storefront API token from the Headless channel.',
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The options usually come from a file the Astro config imports (src/shop.config.js): the dev server restarts when it
|
|
145
|
+
* changes, so the browser gets the new config too.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} root
|
|
148
|
+
* @param {readonly string[]} files
|
|
149
|
+
* @param {(path: string) => void} addWatchFile
|
|
150
|
+
*/
|
|
151
|
+
async function watchConfigFiles(root, files, addWatchFile) {
|
|
152
|
+
const { existsSync } = await import('node:fs');
|
|
153
|
+
const path = await import('node:path');
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
const absolute = path.resolve(root, file);
|
|
156
|
+
if (existsSync(absolute)) addWatchFile(absolute);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @param {string} specifier
|
|
162
|
+
* @returns {Promise<unknown>} The module, `null` when the package isn't installed.
|
|
163
|
+
*/
|
|
164
|
+
async function importIfInstalled(specifier) {
|
|
165
|
+
try {
|
|
166
|
+
/** @type {unknown} */
|
|
167
|
+
const module = await import(specifier);
|
|
168
|
+
return module;
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (error instanceof Error && Reflect.get(error, 'code') === 'ERR_MODULE_NOT_FOUND') return null;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Declares the store variables in `astro:env` (Astro 5+), so Astro checks them. Astro 4 has no `envField`.
|
|
177
|
+
*
|
|
178
|
+
* @param {NonNullable<AstroConfigSetup['updateConfig']>} updateConfig
|
|
179
|
+
* @param {AstroLogger} logger
|
|
180
|
+
*/
|
|
181
|
+
async function declareEnvSchema(updateConfig, logger) {
|
|
182
|
+
const astroConfig = await importIfInstalled(ASTRO_CONFIG_MODULE);
|
|
183
|
+
/** @type {unknown} */
|
|
184
|
+
const envField =
|
|
185
|
+
typeof astroConfig === 'object' && astroConfig !== null ? Reflect.get(astroConfig, 'envField') : null;
|
|
186
|
+
if (typeof envField !== 'object' || envField === null || typeof Reflect.get(envField, 'string') !== 'function')
|
|
187
|
+
return;
|
|
188
|
+
// Checked above: Astro's `envField.string`.
|
|
189
|
+
const fields = /** @type {{ string: EnvStringField }} */ (envField);
|
|
190
|
+
const field = () => fields.string({ context: 'client', access: 'public', optional: true });
|
|
191
|
+
try {
|
|
192
|
+
updateConfig({ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } } });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
// Astro versions with `envField` but without a stable `env` option refuse it; the variables still work unchecked.
|
|
195
|
+
logger.warn(`.env variables not declared in astro:env: ${error instanceof Error ? error.message : String(error)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @param {PageConfig} config Changed in place: functions can't be written into a page.
|
|
201
|
+
* @param {AstroLogger} logger
|
|
202
|
+
*/
|
|
203
|
+
function dropFunctions(config, logger) {
|
|
204
|
+
const functions = Object.keys(config).filter((key) => typeof config[key] === 'function');
|
|
205
|
+
for (const key of functions) Reflect.deleteProperty(config, key);
|
|
206
|
+
if (functions.length) {
|
|
207
|
+
logger.warn(
|
|
208
|
+
`${functions.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
52
211
|
}
|
|
53
212
|
|
|
213
|
+
/**
|
|
214
|
+
* @param {AstroConfigSetup['injectScript']} injectScript
|
|
215
|
+
* @param {PageConfig} config
|
|
216
|
+
*/
|
|
217
|
+
function injectTether(injectScript, config) {
|
|
218
|
+
// Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
|
|
219
|
+
injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
|
|
220
|
+
injectScript(
|
|
221
|
+
'page',
|
|
222
|
+
[
|
|
223
|
+
"import Tether from '@occluze/tether';",
|
|
224
|
+
// As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market (country, money
|
|
225
|
+
// format) then wins over the defaults, as on a plain script-tag site.
|
|
226
|
+
`window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
|
|
227
|
+
'Tether.init().then(() => Tether.mount(document));',
|
|
228
|
+
].join('\n'),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @param {TetherAstroOptions & PageConfig} [options]
|
|
234
|
+
* @returns {{ name: string, hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> } }}
|
|
235
|
+
*/
|
|
54
236
|
export default function tether(options = {}) {
|
|
55
|
-
const { inject = true, watch =
|
|
237
|
+
const { inject = true, watch = DEFAULT_WATCH, ...pageOptions } = options;
|
|
56
238
|
return {
|
|
57
239
|
name: '@occluze/tether',
|
|
58
240
|
hooks: {
|
|
59
|
-
'astro:config:setup': async ({
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
} catch { /* Astro 4: no astro:env */ }
|
|
77
|
-
}
|
|
78
|
-
const env = await readEnv(command, root);
|
|
79
|
-
for (const [key, names] of Object.entries(ENV_KEYS)) {
|
|
80
|
-
if (!config[key]) config[key] = names.map((n) => env[n]).find(Boolean);
|
|
81
|
-
}
|
|
82
|
-
const bad = Object.entries(config).filter(([, v]) => typeof v === 'function').map(([k]) => k);
|
|
83
|
-
bad.forEach((k) => delete config[k]);
|
|
84
|
-
if (bad.length) logger.warn(`${bad.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`);
|
|
241
|
+
'astro:config:setup': async ({
|
|
242
|
+
injectScript,
|
|
243
|
+
command,
|
|
244
|
+
config: astroConfig,
|
|
245
|
+
logger,
|
|
246
|
+
addWatchFile,
|
|
247
|
+
updateConfig,
|
|
248
|
+
}) => {
|
|
249
|
+
const { fileURLToPath } = await import('node:url');
|
|
250
|
+
const root = astroConfig.root ? fileURLToPath(astroConfig.root) : process.cwd();
|
|
251
|
+
if (command === 'dev' && addWatchFile) await watchConfigFiles(root, watch ?? [], addWatchFile);
|
|
252
|
+
if (updateConfig) await declareEnvSchema(updateConfig, logger);
|
|
253
|
+
/** @type {PageConfig} */
|
|
254
|
+
const config = { ...pageOptions };
|
|
255
|
+
applyStoreEnv(config, await readEnv(command, root), logger);
|
|
256
|
+
dropFunctions(config, logger);
|
|
85
257
|
if (!config.domain || !config.token) {
|
|
86
|
-
logger.warn(
|
|
258
|
+
logger.warn(
|
|
259
|
+
'No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).',
|
|
260
|
+
);
|
|
87
261
|
}
|
|
88
|
-
if (
|
|
89
|
-
// Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
|
|
90
|
-
injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
|
|
91
|
-
injectScript('page', [
|
|
92
|
-
"import Tether from '@occluze/tether';",
|
|
93
|
-
// As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market
|
|
94
|
-
// (country, money format) then wins over the defaults, as on a plain script-tag site.
|
|
95
|
-
`window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
|
|
96
|
-
'Tether.init().then(() => Tether.mount(document));',
|
|
97
|
-
].join('\n'));
|
|
262
|
+
if (inject) injectTether(injectScript, config);
|
|
98
263
|
},
|
|
99
264
|
},
|
|
100
265
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending the visitor to Shopify's checkout. The URL comes from Shopify at click time, because the cart's checkout URL
|
|
3
|
+
* changes with its market and customer; a logged-in customer's account part may supply its own (`beforeCheckout`).
|
|
4
|
+
*/
|
|
5
|
+
import { cfg, reportError } from '../config.js';
|
|
6
|
+
import { TetherError } from '../client.js';
|
|
7
|
+
import { isHttpsUrl } from '../shopify-values.js';
|
|
8
|
+
import { emit } from '../dom.js';
|
|
9
|
+
import { fetchCheckoutUrl } from './mutations.js';
|
|
10
|
+
import { customer, currentCart, savedCartId } from './store.js';
|
|
11
|
+
/** @import { CheckoutTarget } from '../config.js' */
|
|
12
|
+
|
|
13
|
+
/** @typedef {{ kind: 'url', url: string | null } | { kind: 'failed', error: unknown }} CheckoutUrlAnswer */
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} cartId
|
|
17
|
+
* @returns {Promise<CheckoutUrlAnswer>} Shopify's answer as a value, so the request can run alongside the account's
|
|
18
|
+
* and its failure is read only when its URL is the one used.
|
|
19
|
+
*/
|
|
20
|
+
const askCheckoutUrl = (cartId) =>
|
|
21
|
+
fetchCheckoutUrl(cartId).then(
|
|
22
|
+
(url) => ({ kind: /** @type {const} */ ('url'), url }),
|
|
23
|
+
(/** @type {unknown} */ error) => ({ kind: /** @type {const} */ ('failed'), error }),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns {Promise<string | null>} The logged-in customer's checkout URL. When the account can't give one, the
|
|
28
|
+
* failure is reported and the cart's own checkout URL is used, which still checks out (without the login).
|
|
29
|
+
*/
|
|
30
|
+
async function accountCheckoutUrl() {
|
|
31
|
+
if (!customer.beforeCheckout) return null;
|
|
32
|
+
try {
|
|
33
|
+
return await customer.beforeCheckout();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
reportError(error, 'account checkout');
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cartId
|
|
42
|
+
* @returns {Promise<string>}
|
|
43
|
+
* @throws {TetherError} `ERROR` without a checkout URL (the cart is empty or Shopify no longer has it).
|
|
44
|
+
*/
|
|
45
|
+
async function checkoutUrl(cartId) {
|
|
46
|
+
const cartAnswer = askCheckoutUrl(cartId);
|
|
47
|
+
let url = await accountCheckoutUrl();
|
|
48
|
+
if (!url) {
|
|
49
|
+
const answer = await cartAnswer;
|
|
50
|
+
if (answer.kind === 'failed') throw answer.error;
|
|
51
|
+
url = answer.url;
|
|
52
|
+
}
|
|
53
|
+
if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
54
|
+
if (!customer.accessToken) return url;
|
|
55
|
+
return `${url}${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A new tab's `opener` gives the checkout page a handle on this one; a browser that doesn't let a script clear it
|
|
60
|
+
* leaves the tab as it opened.
|
|
61
|
+
*
|
|
62
|
+
* @param {Window} tab
|
|
63
|
+
*/
|
|
64
|
+
function detachOpenerIfAllowed(tab) {
|
|
65
|
+
try {
|
|
66
|
+
tab.opener = null;
|
|
67
|
+
} catch {
|
|
68
|
+
// The browser keeps `opener` read-only: see the function's comment.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Goes to Shopify's checkout. With `_blank` the tab opens inside the click (Safari blocks `window.open` after an
|
|
74
|
+
* await) and gets its URL once there is one.
|
|
75
|
+
*
|
|
76
|
+
* @param {CheckoutTarget} target
|
|
77
|
+
* @returns {Promise<string | null>} The checkout URL, `null` with nothing to check out or after a reported failure.
|
|
78
|
+
*/
|
|
79
|
+
export async function checkout(target = cfg.checkoutTarget) {
|
|
80
|
+
const cartId = savedCartId();
|
|
81
|
+
const cart = currentCart();
|
|
82
|
+
if (!cartId || (cart && !cart.totalQuantity)) return null;
|
|
83
|
+
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
84
|
+
if (tab) detachOpenerIfAllowed(tab);
|
|
85
|
+
try {
|
|
86
|
+
const url = await checkoutUrl(cartId);
|
|
87
|
+
if (!isHttpsUrl(url)) throw new TetherError('The checkout URL is not https: not opened');
|
|
88
|
+
emit('checkout', { url, cart: currentCart() });
|
|
89
|
+
if (tab && !tab.closed) tab.location.href = url;
|
|
90
|
+
else window.location.href = url;
|
|
91
|
+
return url;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
tab?.close();
|
|
94
|
+
reportError(error, 'checkout failed');
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cart drawer: every `[tether-cart-drawer]` on the page opens and closes together, as a modal dialog. Focus moves
|
|
3
|
+
* into the open cart, Tab stays inside it and focus returns on close (`cartFocus`). A page without a drawer (a cart
|
|
4
|
+
* built in React, Svelte or Vue) still has an open state, for `Tether.cart.subscribe`.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg } from '../config.js';
|
|
7
|
+
import { sel, get, qsa, emit } from '../dom.js';
|
|
8
|
+
|
|
9
|
+
const FOCUSABLE =
|
|
10
|
+
'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
11
|
+
|
|
12
|
+
/** Long enough for the drawer's open transition to make its content visible: hidden elements can't take focus. */
|
|
13
|
+
const FOCUS_AFTER_OPEN_MS = 60;
|
|
14
|
+
|
|
15
|
+
/** The open state of a page without a drawer element. */
|
|
16
|
+
let openWithoutDrawer = false;
|
|
17
|
+
|
|
18
|
+
/** @type {HTMLElement | null} Where focus was before the cart opened. */
|
|
19
|
+
let returnFocus = null;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {Element | null} el
|
|
23
|
+
* @returns {el is HTMLElement}
|
|
24
|
+
*/
|
|
25
|
+
const isFocusable = (el) => el !== null && 'focus' in el;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {ParentNode} drawer
|
|
29
|
+
* @returns {HTMLElement[]} The visible focusable elements inside `drawer`, in document order.
|
|
30
|
+
*/
|
|
31
|
+
const focusables = (drawer) =>
|
|
32
|
+
qsa(drawer, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
33
|
+
|
|
34
|
+
/** @returns {HTMLElement[]} Every cart drawer on the page. */
|
|
35
|
+
export const drawers = () => qsa(document, sel('cart-drawer'));
|
|
36
|
+
|
|
37
|
+
/** @param {HTMLElement} drawer */
|
|
38
|
+
const isOpenDrawer = (drawer) => drawer.classList.contains(cfg.cartDrawerOpenClass);
|
|
39
|
+
|
|
40
|
+
/** @returns {boolean} True while a drawer, or the drawer-less cart, is open. */
|
|
41
|
+
export function isOpen() {
|
|
42
|
+
const all = drawers();
|
|
43
|
+
return all.length ? all.some(isOpenDrawer) : openWithoutDrawer;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* `aria-expanded` on the open buttons, and dialog semantics on the drawers unless their markup has its own. A closed
|
|
48
|
+
* drawer is hidden from screen readers and, with `cartFocus`, out of the Tab order (inert), like a closed dialog.
|
|
49
|
+
*
|
|
50
|
+
* @param {boolean} open
|
|
51
|
+
*/
|
|
52
|
+
export function syncDrawerAria(open) {
|
|
53
|
+
qsa(document, sel('cart-open')).forEach((button) => {
|
|
54
|
+
button.setAttribute('aria-expanded', String(open));
|
|
55
|
+
});
|
|
56
|
+
drawers().forEach((drawer) => {
|
|
57
|
+
if (!drawer.hasAttribute('role')) drawer.setAttribute('role', 'dialog');
|
|
58
|
+
if (!drawer.hasAttribute('aria-label') && !drawer.hasAttribute('aria-labelledby')) {
|
|
59
|
+
drawer.setAttribute('aria-label', get(drawer, 'cart-label') || 'Cart');
|
|
60
|
+
}
|
|
61
|
+
drawer.setAttribute('aria-modal', String(open));
|
|
62
|
+
if (isOpenDrawer(drawer)) return;
|
|
63
|
+
drawer.setAttribute('aria-hidden', 'true');
|
|
64
|
+
if (cfg.cartFocus) drawer.inert = true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @param {HTMLElement} drawer */
|
|
69
|
+
function focusInto(drawer) {
|
|
70
|
+
const target = focusables(drawer)[0] ?? drawer;
|
|
71
|
+
if (target === drawer && !drawer.hasAttribute('tabindex')) drawer.tabIndex = -1;
|
|
72
|
+
target.focus({ preventScroll: true });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Opens every drawer except `tether-cart-drawer="manual"` ones, which the site opens itself. */
|
|
76
|
+
export function openCart() {
|
|
77
|
+
if (isOpen()) return;
|
|
78
|
+
openWithoutDrawer = true;
|
|
79
|
+
const opened = drawers().filter((drawer) => get(drawer, 'cart-drawer') !== 'manual');
|
|
80
|
+
opened.forEach((drawer) => {
|
|
81
|
+
drawer.inert = false;
|
|
82
|
+
drawer.classList.add(cfg.cartDrawerOpenClass);
|
|
83
|
+
drawer.setAttribute('aria-hidden', 'false');
|
|
84
|
+
});
|
|
85
|
+
syncDrawerAria(true);
|
|
86
|
+
const [first] = opened;
|
|
87
|
+
if (cfg.cartFocus && first) {
|
|
88
|
+
returnFocus = isFocusable(document.activeElement) ? document.activeElement : null;
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
focusInto(first);
|
|
91
|
+
}, FOCUS_AFTER_OPEN_MS);
|
|
92
|
+
}
|
|
93
|
+
cfg.onCartOpen?.(opened);
|
|
94
|
+
emit('cart-toggled', { open: true });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function closeCart() {
|
|
98
|
+
if (!isOpen()) return;
|
|
99
|
+
openWithoutDrawer = false;
|
|
100
|
+
const all = drawers();
|
|
101
|
+
const hadFocus = all.some((drawer) => drawer.contains(document.activeElement));
|
|
102
|
+
all.forEach((drawer) => {
|
|
103
|
+
drawer.classList.remove(cfg.cartDrawerOpenClass);
|
|
104
|
+
drawer.setAttribute('aria-hidden', 'true');
|
|
105
|
+
if (cfg.cartFocus) drawer.inert = true;
|
|
106
|
+
});
|
|
107
|
+
syncDrawerAria(false);
|
|
108
|
+
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
109
|
+
returnFocus = null;
|
|
110
|
+
cfg.onCartClose?.(all);
|
|
111
|
+
emit('cart-toggled', { open: false });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function toggleCart() {
|
|
115
|
+
if (isOpen()) closeCart();
|
|
116
|
+
else openCart();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Keeps Tab inside the open drawer (`cartFocus`): from the last focusable element it wraps to the first, with Shift
|
|
121
|
+
* from the first to the last, and from outside the drawer it goes in. For a document `keydown` listener.
|
|
122
|
+
*
|
|
123
|
+
* @param {KeyboardEvent} event
|
|
124
|
+
*/
|
|
125
|
+
export function trapFocus(event) {
|
|
126
|
+
if (event.key !== 'Tab' || !cfg.cartFocus || !isOpen()) return;
|
|
127
|
+
const drawer = drawers().find(isOpenDrawer);
|
|
128
|
+
if (!drawer) return;
|
|
129
|
+
const inside = focusables(drawer);
|
|
130
|
+
const first = inside[0];
|
|
131
|
+
const last = inside.at(-1);
|
|
132
|
+
if (!first || !last) return;
|
|
133
|
+
const active = document.activeElement;
|
|
134
|
+
const leaving = event.shiftKey ? active === first : active === last;
|
|
135
|
+
if (drawer.contains(active) && !leaving) return;
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
(event.shiftKey ? last : first).focus();
|
|
138
|
+
}
|