@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/astro.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Astro integration: Tether on every page, configured from .env.
|
|
3
|
-
*
|
|
4
|
-
* // astro.config.mjs
|
|
5
|
-
* import { defineConfig } from 'astro/config';
|
|
6
|
-
* import tether from '@occluze/tether/astro';
|
|
7
|
-
* export default defineConfig({ integrations: [tether({ country: 'NL', locale: 'nl-NL' })] });
|
|
8
|
-
*
|
|
9
|
-
* // .env
|
|
10
|
-
* PUBLIC_SHOPIFY_DOMAIN=your-store.myshopify.com
|
|
11
|
-
* PUBLIC_SHOPIFY_STOREFRONT_TOKEN=your-public-storefront-token
|
|
12
|
-
*
|
|
13
|
-
* Options are Tether's config (domain and token override .env). Functions (onError, formatMoney,
|
|
14
|
-
* hooks) can't be passed here: set them in window.TETHER_CONFIG in your layout, it is merged in.
|
|
15
|
-
* View Transitions (<ClientRouter />) work without extra code. In dev, changes to
|
|
16
|
-
* src/shop.config.js restart the dev server (other files: tether({ ..., watch: ['my/config.js'] })).
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
const ENV_KEYS = {
|
|
20
|
-
domain: ['PUBLIC_SHOPIFY_DOMAIN', 'PUBLIC_SHOPIFY_STORE_DOMAIN', 'SHOPIFY_DOMAIN'],
|
|
21
|
-
token: ['PUBLIC_SHOPIFY_STOREFRONT_TOKEN', 'PUBLIC_SHOPIFY_STOREFRONT_ACCESS_TOKEN', 'SHOPIFY_STOREFRONT_TOKEN'],
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/** The mode Astro runs in: --mode, else development for dev and production for build. */
|
|
25
|
-
function modeOf(command, argv = process.argv) {
|
|
26
|
-
const i = argv.findIndex((a) => a === '--mode' || a.startsWith('--mode='));
|
|
27
|
-
if (i >= 0) return argv[i].includes('=') ? argv[i].split('=')[1] : argv[i + 1];
|
|
28
|
-
return command === 'dev' ? 'development' : 'production';
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** .env files the way Vite reads them (later files win), plus process.env on top. */
|
|
32
|
-
async function readEnv(command, root) {
|
|
33
|
-
const mode = modeOf(command);
|
|
34
|
-
const out = {};
|
|
35
|
-
try {
|
|
36
|
-
const { readFile } = await import('node:fs/promises');
|
|
37
|
-
const { join } = await import('node:path');
|
|
38
|
-
for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
|
|
39
|
-
let text;
|
|
40
|
-
try { text = await readFile(join(root, name), 'utf8'); } catch { continue; }
|
|
41
|
-
for (const line of text.split(/\r?\n/)) {
|
|
42
|
-
const m = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/.exec(line);
|
|
43
|
-
if (!m || line.trim().startsWith('#')) continue;
|
|
44
|
-
let v = m[2];
|
|
45
|
-
const q = /^(["'])(.*?)\1/.exec(v); // "quoted" (a # comment may follow)
|
|
46
|
-
v = q ? q[2] : v.replace(/\s+#.*$/, '');
|
|
47
|
-
out[m[1]] = v;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
} catch { /* not in Node: nothing to read */ }
|
|
51
|
-
return { ...out, ...process.env };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export default function tether(options = {}) {
|
|
55
|
-
const { inject = true, watch = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'], ...config } = options;
|
|
56
|
-
return {
|
|
57
|
-
name: '@occluze/tether',
|
|
58
|
-
hooks: {
|
|
59
|
-
'astro:config:setup': async ({ injectScript, command, config: astroConfig, logger, addWatchFile, updateConfig }) => {
|
|
60
|
-
const root = astroConfig?.root ? decodeURIComponent(new URL(astroConfig.root).pathname) : process.cwd();
|
|
61
|
-
// The options usually come from a file the Astro config imports (src/shop.config.js):
|
|
62
|
-
// the dev server restarts when it changes, so the browser gets the new config too.
|
|
63
|
-
if (command === 'dev' && addWatchFile) {
|
|
64
|
-
const { existsSync } = await import('node:fs');
|
|
65
|
-
const { resolve } = await import('node:path');
|
|
66
|
-
[].concat(watch || []).map((f) => resolve(root, f)).filter((f) => existsSync(f)).forEach((f) => addWatchFile(f));
|
|
67
|
-
}
|
|
68
|
-
// Astro 5+: declare the store variables, so Astro checks them (astro:env).
|
|
69
|
-
if (updateConfig) {
|
|
70
|
-
try {
|
|
71
|
-
const { envField } = await import('astro/config');
|
|
72
|
-
if (envField) {
|
|
73
|
-
const field = () => envField.string({ context: 'client', access: 'public', optional: true });
|
|
74
|
-
updateConfig({ env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } } });
|
|
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.`);
|
|
85
|
-
if (!config.domain || !config.token) {
|
|
86
|
-
logger.warn('No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).');
|
|
87
|
-
}
|
|
88
|
-
if (!inject) return;
|
|
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'));
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|
package/src/cart.js
DELETED
|
@@ -1,559 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cart: Shopify Cart API, state in localStorage, document-wide rendering.
|
|
3
|
-
*
|
|
4
|
-
* Every mutation is queued, so fast clicking never races. All public methods
|
|
5
|
-
* return the updated cart.
|
|
6
|
-
*/
|
|
7
|
-
import { cfg, log, reportError } from './config.js';
|
|
8
|
-
import { gql, unwrap, TetherError } from './client.js';
|
|
9
|
-
import { cartFragment } from './fragments.js';
|
|
10
|
-
import { getProduct } from './loader.js';
|
|
11
|
-
import { allPlans, GID } from './selling-plans.js';
|
|
12
|
-
import {
|
|
13
|
-
attr, sel, get, qsa, emit, formatMoney, setText, setTextTracked, setImage, show,
|
|
14
|
-
repeat, repeatKeyed, toAttributes, productUrl, notifyLayoutChanged,
|
|
15
|
-
} from './dom.js';
|
|
16
|
-
|
|
17
|
-
/** Accepts many shapes and returns a Shopify CartLineInput. */
|
|
18
|
-
export function toLineInput(input, quantity = 1, opts = {}) {
|
|
19
|
-
const src = typeof input === 'object' && input !== null ? input : { variantId: input, quantity, ...opts };
|
|
20
|
-
const merchandiseId = GID.variant(src.merchandiseId || src.variantId || src.id);
|
|
21
|
-
if (!merchandiseId) throw new TetherError('add(): variantId is required');
|
|
22
|
-
const line = { merchandiseId, quantity: Math.max(1, parseInt(src.quantity ?? 1, 10) || 1) };
|
|
23
|
-
const plan = src.sellingPlanId || src.sellingPlan;
|
|
24
|
-
if (plan) line.sellingPlanId = GID.sellingPlan(plan);
|
|
25
|
-
const attrs = toAttributes(src.attributes);
|
|
26
|
-
if (attrs.length) line.attributes = attrs;
|
|
27
|
-
return line;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function mutation(name, args, inner) {
|
|
31
|
-
return `mutation Tether_${name}(${args}) {
|
|
32
|
-
${name}(${inner}) { cart { ...TetherCart } userErrors { field message } warnings { code message target } }
|
|
33
|
-
}
|
|
34
|
-
${cartFragment()}`;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
|
|
38
|
-
let returnFocus = null;
|
|
39
|
-
|
|
40
|
-
/** Focusable elements inside an open drawer, in order (visible ones only). */
|
|
41
|
-
export const focusables = (d) => qsa(d, FOCUSABLE).filter((el) => el.offsetParent !== null || el === document.activeElement);
|
|
42
|
-
|
|
43
|
-
/** aria-expanded on the open buttons; dialog semantics on drawers (unless the markup has its own). */
|
|
44
|
-
function ariaState(open) {
|
|
45
|
-
qsa(document, sel('cart-open')).forEach((b) => b.setAttribute('aria-expanded', String(open)));
|
|
46
|
-
qsa(document, sel('cart-drawer')).forEach((d) => {
|
|
47
|
-
if (!d.hasAttribute('role')) d.setAttribute('role', 'dialog');
|
|
48
|
-
if (!d.hasAttribute('aria-label') && !d.hasAttribute('aria-labelledby')) d.setAttribute('aria-label', get(d, 'cart-label') || 'Cart');
|
|
49
|
-
d.setAttribute('aria-modal', String(open));
|
|
50
|
-
// Closed: hidden from screen readers and out of the Tab order (inert), like a closed dialog.
|
|
51
|
-
if (!d.classList.contains(cfg.cartDrawerOpenClass)) { d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; }
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || '');
|
|
56
|
-
|
|
57
|
-
export const Cart = {
|
|
58
|
-
state: null,
|
|
59
|
-
_chain: Promise.resolve(),
|
|
60
|
-
|
|
61
|
-
id() { try { return localStorage.getItem(cfg.storageKey); } catch { return null; } },
|
|
62
|
-
_setId(id) {
|
|
63
|
-
try {
|
|
64
|
-
if (id) localStorage.setItem(cfg.storageKey, id);
|
|
65
|
-
else localStorage.removeItem(cfg.storageKey);
|
|
66
|
-
} catch { /* private mode */ }
|
|
67
|
-
},
|
|
68
|
-
|
|
69
|
-
get lines() { return this.state?.lines?.nodes || []; },
|
|
70
|
-
get count() { return this.state?.totalQuantity || 0; },
|
|
71
|
-
|
|
72
|
-
lastError: null,
|
|
73
|
-
/** Set by the account part while a customer is logged in (Customer Account API token). */
|
|
74
|
-
customerAccessToken: null,
|
|
75
|
-
/** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
|
|
76
|
-
pendingQty: new Map(),
|
|
77
|
-
/** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
|
|
78
|
-
warnings: [],
|
|
79
|
-
/** true once the saved cart (or the absence of one) is known. */
|
|
80
|
-
loaded: false,
|
|
81
|
-
|
|
82
|
-
_take(payload, name) {
|
|
83
|
-
const p = unwrap(payload, name);
|
|
84
|
-
// Shopify can repeat a warning per line or code: each message once.
|
|
85
|
-
this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i)
|
|
86
|
-
.map((w) => ({ ...w, message: (typeof cfg.warningText === 'function' && cfg.warningText(w)) || w.message }));
|
|
87
|
-
if (this.warnings.length) emit('cart-warning', { warnings: this.warnings });
|
|
88
|
-
return p.cart;
|
|
89
|
-
},
|
|
90
|
-
|
|
91
|
-
_queue(fn) {
|
|
92
|
-
const run = this._chain.then(fn, fn).then((value) => {
|
|
93
|
-
if (this.lastError) { this.lastError = null; this.render(); }
|
|
94
|
-
return value;
|
|
95
|
-
}, (err) => {
|
|
96
|
-
// tether-show="cart-error" becomes visible until the next successful cart call.
|
|
97
|
-
this.lastError = err;
|
|
98
|
-
this.render();
|
|
99
|
-
emit('cart-error', { error: err });
|
|
100
|
-
throw err;
|
|
101
|
-
});
|
|
102
|
-
this._chain = run.catch(() => {});
|
|
103
|
-
return run;
|
|
104
|
-
},
|
|
105
|
-
|
|
106
|
-
_commit(cart, reason) {
|
|
107
|
-
this.state = cart;
|
|
108
|
-
if (cart?.id) this._setId(cart.id);
|
|
109
|
-
// Other tabs reload the cart (storage event); the count also shows at once on the next page.
|
|
110
|
-
try { localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`); } catch { /* private mode */ }
|
|
111
|
-
this.render();
|
|
112
|
-
emit('cart-updated', { cart, reason });
|
|
113
|
-
return cart;
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
async init() {
|
|
117
|
-
const id = this.id();
|
|
118
|
-
if (!id) { this.state = null; this.loaded = true; this.render(); emit('cart-updated', { cart: null, reason: 'load' }); return null; }
|
|
119
|
-
// Until the saved cart has loaded, show its last known count instead of the page's 0.
|
|
120
|
-
if (!this.loaded) {
|
|
121
|
-
try {
|
|
122
|
-
const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || '').split('|')[1];
|
|
123
|
-
if (n) qsa(document, sel('cart-count')).forEach((el) => { if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n; });
|
|
124
|
-
} catch { /* private mode */ }
|
|
125
|
-
}
|
|
126
|
-
try {
|
|
127
|
-
const data = await gql(`query TetherCart($id: ID!) { cart(id: $id) { ...TetherCart } }\n${cartFragment()}`, { id });
|
|
128
|
-
if (!data.cart) { this._setId(null); this.state = null; } else this.state = data.cart;
|
|
129
|
-
} catch (err) {
|
|
130
|
-
reportError(err, 'cart load failed');
|
|
131
|
-
this.lastError = err;
|
|
132
|
-
// Keep the cart id on network/API errors: the cart is probably fine, Shopify isn't reachable.
|
|
133
|
-
if (isMissingCart(err)) this._setId(null);
|
|
134
|
-
this.state = null;
|
|
135
|
-
}
|
|
136
|
-
this.loaded = true;
|
|
137
|
-
this.render();
|
|
138
|
-
emit('cart-updated', { cart: this.state, reason: 'load' }); // islands (cart.subscribe) hear the loaded cart
|
|
139
|
-
return this.state;
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
async _create(input = {}) {
|
|
143
|
-
const buyerIdentity = cfg.country || this.customerAccessToken ? {
|
|
144
|
-
...(cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {}),
|
|
145
|
-
...(this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}),
|
|
146
|
-
} : undefined;
|
|
147
|
-
const data = await gql(
|
|
148
|
-
mutation('cartCreate', '$input: CartInput!', 'input: $input'),
|
|
149
|
-
{ input: { buyerIdentity, ...input } },
|
|
150
|
-
);
|
|
151
|
-
return this._take(data.cartCreate, 'cartCreate');
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
/** Runs a cart mutation; creates a fresh cart if ours expired. */
|
|
155
|
-
async _mutate(name, args, inner, variables, createInput) {
|
|
156
|
-
const cartId = this.id();
|
|
157
|
-
if (!cartId) return createInput ? this._create(createInput) : null;
|
|
158
|
-
try {
|
|
159
|
-
const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
|
|
160
|
-
return this._take(data[name], name);
|
|
161
|
-
} catch (err) {
|
|
162
|
-
if (createInput && isMissingCart(err)) {
|
|
163
|
-
log('cart expired, creating a new one');
|
|
164
|
-
this._setId(null);
|
|
165
|
-
return this._create(createInput);
|
|
166
|
-
}
|
|
167
|
-
throw err;
|
|
168
|
-
}
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* add('gid://shopify/ProductVariant/1', 2)
|
|
173
|
-
* add({ variantId, quantity, sellingPlanId, attributes })
|
|
174
|
-
* add([line, line]) // bundles / build-a-box in one request
|
|
175
|
-
*/
|
|
176
|
-
add(input, quantity = 1, opts = {}) {
|
|
177
|
-
const lines = (Array.isArray(input) ? input : [input]).map((l) => toLineInput(l, quantity, opts));
|
|
178
|
-
return this._queue(async () => {
|
|
179
|
-
const cart = await this._mutate('cartLinesAdd', '$lines: [CartLineInput!]!', 'lines: $lines', { lines }, { lines });
|
|
180
|
-
this._commit(cart, 'add');
|
|
181
|
-
emit('added', { cart, lines });
|
|
182
|
-
if (cfg.openCartOnAdd) this.open();
|
|
183
|
-
return cart;
|
|
184
|
-
});
|
|
185
|
-
},
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* updateLine(lineId, 3)
|
|
189
|
-
* updateLine(lineId, { quantity, sellingPlanId, attributes })
|
|
190
|
-
* sellingPlanId: null switches a line back to one-time purchase.
|
|
191
|
-
*/
|
|
192
|
-
updateLine(lineId, patch) {
|
|
193
|
-
const p = typeof patch === 'number' ? { quantity: patch } : { ...patch };
|
|
194
|
-
if (p.quantity !== undefined && p.quantity <= 0) return this.removeLine(lineId);
|
|
195
|
-
|
|
196
|
-
const line = { id: lineId };
|
|
197
|
-
if (p.quantity !== undefined) line.quantity = p.quantity;
|
|
198
|
-
if ('sellingPlanId' in p) line.sellingPlanId = p.sellingPlanId ? GID.sellingPlan(p.sellingPlanId) : null;
|
|
199
|
-
if (p.attributes) line.attributes = toAttributes(p.attributes);
|
|
200
|
-
if (p.merchandiseId || p.variantId) line.merchandiseId = GID.variant(p.merchandiseId || p.variantId);
|
|
201
|
-
|
|
202
|
-
return this._queue(async () => this._commit(
|
|
203
|
-
await this._mutate('cartLinesUpdate', '$lines: [CartLineUpdateInput!]!', 'lines: $lines', { lines: [line] }),
|
|
204
|
-
'update',
|
|
205
|
-
));
|
|
206
|
-
},
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
|
|
210
|
-
* which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
|
|
211
|
-
*/
|
|
212
|
-
changeQuantity(lineId, delta) {
|
|
213
|
-
const current = this.lines.find((l) => l.id === lineId);
|
|
214
|
-
if (!current) return Promise.resolve(this.state);
|
|
215
|
-
const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
|
|
216
|
-
const job = { qty: target, done: false };
|
|
217
|
-
this.pendingQty.set(lineId, job);
|
|
218
|
-
this.render();
|
|
219
|
-
emit('cart-updated', { cart: this.state, reason: 'pending' });
|
|
220
|
-
const latest = () => this.pendingQty.get(lineId) === job;
|
|
221
|
-
return this._queue(async () => {
|
|
222
|
-
// Quick clicks: only the latest target goes to Shopify; the ones it replaced are skipped.
|
|
223
|
-
if (!latest()) return this.state;
|
|
224
|
-
const now = this.lines.find((l) => l.id === lineId);
|
|
225
|
-
if (now && now.quantity === target) return this.state; // e.g. + then -: nothing to change
|
|
226
|
-
const cart = target > 0
|
|
227
|
-
? await this._mutate('cartLinesUpdate', '$lines: [CartLineUpdateInput!]!', 'lines: $lines', { lines: [{ id: lineId, quantity: target }] })
|
|
228
|
-
: await this._mutate('cartLinesRemove', '$lineIds: [ID!]!', 'lineIds: $lineIds', { lineIds: [lineId] });
|
|
229
|
-
// A newer click is on its way: keep this answer, but show totals and count only once that
|
|
230
|
-
// one is in (no stepping through every reply).
|
|
231
|
-
if (!latest()) { this.state = cart; if (cart?.id) this._setId(cart.id); return cart; }
|
|
232
|
-
return this._commit(cart, target > 0 ? 'update' : 'remove');
|
|
233
|
-
}).finally(() => {
|
|
234
|
-
if (latest()) { this.pendingQty.delete(lineId); this.render(); emit('cart-updated', { cart: this.state, reason: 'settled' }); }
|
|
235
|
-
});
|
|
236
|
-
},
|
|
237
|
-
|
|
238
|
-
/** Quantity to show for a line: the one asked for while an update is on its way. */
|
|
239
|
-
quantityOf(line) { return this.pendingQty.get(line.id)?.qty ?? line.quantity; },
|
|
240
|
-
|
|
241
|
-
removeLine(lineIds) {
|
|
242
|
-
const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
|
|
243
|
-
return this._queue(async () => this._commit(
|
|
244
|
-
await this._mutate('cartLinesRemove', '$lineIds: [ID!]!', 'lineIds: $lineIds', { lineIds: ids }),
|
|
245
|
-
'remove',
|
|
246
|
-
));
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
clear() {
|
|
250
|
-
const ids = this.lines.map((l) => l.id);
|
|
251
|
-
if (!ids.length) return Promise.resolve(this.state);
|
|
252
|
-
return this.removeLine(ids);
|
|
253
|
-
},
|
|
254
|
-
|
|
255
|
-
setNote(note) {
|
|
256
|
-
return this._queue(async () => this._commit(
|
|
257
|
-
await this._mutate('cartNoteUpdate', '$note: String!', 'note: $note', { note: note ?? '' }, { note: note ?? '' }),
|
|
258
|
-
'note',
|
|
259
|
-
));
|
|
260
|
-
},
|
|
261
|
-
|
|
262
|
-
/** Cart-level attributes, merged with existing ones. Value '' removes a key. */
|
|
263
|
-
setAttributes(obj) {
|
|
264
|
-
const add = toAttributes(obj);
|
|
265
|
-
// Merged in the queue, with the cart as it is by then: quick calls in a row don't drop each other.
|
|
266
|
-
return this._queue(async () => {
|
|
267
|
-
const merged = new Map((this.state?.attributes || []).map((a) => [a.key, a.value]));
|
|
268
|
-
add.forEach((a) => merged.set(a.key, a.value));
|
|
269
|
-
const attributes = [...merged].filter(([, v]) => v !== '').map(([key, value]) => ({ key, value }));
|
|
270
|
-
return this._commit(
|
|
271
|
-
await this._mutate('cartAttributesUpdate', '$attributes: [AttributeInput!]!', 'attributes: $attributes', { attributes }, { attributes }),
|
|
272
|
-
'attributes',
|
|
273
|
-
);
|
|
274
|
-
});
|
|
275
|
-
},
|
|
276
|
-
|
|
277
|
-
/** Replaces the discount codes. Pass [] to remove all. */
|
|
278
|
-
setDiscountCodes(codes) {
|
|
279
|
-
return this._queue(async () => {
|
|
280
|
-
// A function gets the codes as they are by then (applyDiscount/removeDiscount in quick succession).
|
|
281
|
-
const list = typeof codes === 'function' ? codes((this.state?.discountCodes || []).map((d) => d.code)) : codes;
|
|
282
|
-
const discountCodes = (Array.isArray(list) ? list : [list]).map((c) => String(c).trim()).filter(Boolean);
|
|
283
|
-
return this._commit(
|
|
284
|
-
await this._mutate('cartDiscountCodesUpdate', '$discountCodes: [String!]!', 'discountCodes: $discountCodes', { discountCodes }, { discountCodes }),
|
|
285
|
-
'discount',
|
|
286
|
-
);
|
|
287
|
-
});
|
|
288
|
-
},
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
|
|
292
|
-
* it's removed again and the promise rejects with code INVALID_DISCOUNT.
|
|
293
|
-
*/
|
|
294
|
-
async applyDiscount(code) {
|
|
295
|
-
const cart = await this.setDiscountCodes((current) => [...new Set([...current, code])]);
|
|
296
|
-
const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
|
|
297
|
-
if (hit?.applicable) return cart;
|
|
298
|
-
await this.removeDiscount(code).catch(() => {});
|
|
299
|
-
throw new TetherError(`Discount code not valid: ${code}`, { code: 'INVALID_DISCOUNT' });
|
|
300
|
-
},
|
|
301
|
-
|
|
302
|
-
removeDiscount(code) {
|
|
303
|
-
const k = String(code).toLowerCase();
|
|
304
|
-
return this.setDiscountCodes((current) => current.filter((c) => c.toLowerCase() !== k));
|
|
305
|
-
},
|
|
306
|
-
|
|
307
|
-
/** { countryCode, email, phone } — changes currency/prices for that market. */
|
|
308
|
-
setBuyerIdentity(buyerIdentity, { create = true } = {}) {
|
|
309
|
-
return this._queue(async () => {
|
|
310
|
-
const cart = await this._mutate('cartBuyerIdentityUpdate', '$buyerIdentity: CartBuyerIdentityInput!', 'buyerIdentity: $buyerIdentity', { buyerIdentity }, create ? { buyerIdentity } : null)
|
|
311
|
-
.catch((err) => {
|
|
312
|
-
if (create || !isMissingCart(err)) throw err;
|
|
313
|
-
this._setId(null); // expired cart: forget it instead of creating an empty one
|
|
314
|
-
return null;
|
|
315
|
-
});
|
|
316
|
-
return this._commit(cart, 'buyer-identity');
|
|
317
|
-
});
|
|
318
|
-
},
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Goes to Shopify checkout. With checkoutTarget '_blank' the tab is opened
|
|
322
|
-
* inside the click (Safari blocks window.open after an await) and gets its
|
|
323
|
-
* URL once we have it.
|
|
324
|
-
*/
|
|
325
|
-
async checkout(target = cfg.checkoutTarget) {
|
|
326
|
-
const id = this.id();
|
|
327
|
-
if (!id || (this.state && !this.count)) return null; // nothing to check out
|
|
328
|
-
const tab = target === '_blank' ? window.open('', '_blank') : null;
|
|
329
|
-
if (tab) { try { tab.opener = null; } catch { /* ignore */ } }
|
|
330
|
-
|
|
331
|
-
try {
|
|
332
|
-
// Side by side: the cart's current checkout URL, and the account's own (beforeCheckout) if any.
|
|
333
|
-
const fresh = gql('query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }', { id }).then((d) => d.cart?.checkoutUrl);
|
|
334
|
-
fresh.catch(() => {}); // not needed when beforeCheckout answers
|
|
335
|
-
let url = (this.beforeCheckout && await this.beforeCheckout().catch(() => null)) || await fresh;
|
|
336
|
-
if (url && this.customerAccessToken) url += `${url.includes('?') ? '&' : '?'}logged_in=true`;
|
|
337
|
-
if (!url) throw new TetherError('No checkoutUrl (cart empty or expired)');
|
|
338
|
-
emit('checkout', { url, cart: this.state });
|
|
339
|
-
if (tab && !tab.closed) tab.location.href = url;
|
|
340
|
-
else window.location.href = url;
|
|
341
|
-
return url;
|
|
342
|
-
} catch (err) {
|
|
343
|
-
if (tab) tab.close();
|
|
344
|
-
reportError(err, 'checkout failed');
|
|
345
|
-
return null;
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
|
|
349
|
-
// -------------------------------------------------------------- drawer
|
|
350
|
-
|
|
351
|
-
drawers() { return qsa(document, sel('cart-drawer')); },
|
|
352
|
-
|
|
353
|
-
/** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
|
|
354
|
-
_open: false,
|
|
355
|
-
isOpen() {
|
|
356
|
-
const ds = this.drawers();
|
|
357
|
-
return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
|
|
358
|
-
},
|
|
359
|
-
|
|
360
|
-
open() {
|
|
361
|
-
if (this.isOpen()) return;
|
|
362
|
-
this._open = true;
|
|
363
|
-
const ds = this.drawers().filter((d) => get(d, 'cart-drawer') !== 'manual');
|
|
364
|
-
ds.forEach((d) => { d.inert = false; d.classList.add(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'false'); });
|
|
365
|
-
ariaState(true);
|
|
366
|
-
if (cfg.cartFocus !== false && ds[0]) {
|
|
367
|
-
// Move focus into the cart (after the open transition has made it visible).
|
|
368
|
-
returnFocus = document.activeElement;
|
|
369
|
-
setTimeout(() => {
|
|
370
|
-
const target = focusables(ds[0])[0] || ds[0];
|
|
371
|
-
if (target === ds[0] && !ds[0].hasAttribute('tabindex')) ds[0].tabIndex = -1;
|
|
372
|
-
target.focus?.({ preventScroll: true });
|
|
373
|
-
}, 60);
|
|
374
|
-
}
|
|
375
|
-
if (typeof cfg.onCartOpen === 'function') cfg.onCartOpen(ds);
|
|
376
|
-
emit('cart-toggled', { open: true });
|
|
377
|
-
},
|
|
378
|
-
|
|
379
|
-
close() {
|
|
380
|
-
if (!this.isOpen()) return;
|
|
381
|
-
this._open = false;
|
|
382
|
-
const ds = this.drawers();
|
|
383
|
-
const hadFocus = ds.some((d) => d.contains(document.activeElement));
|
|
384
|
-
ds.forEach((d) => { d.classList.remove(cfg.cartDrawerOpenClass); d.setAttribute('aria-hidden', 'true'); if (cfg.cartFocus !== false) d.inert = true; });
|
|
385
|
-
ariaState(false);
|
|
386
|
-
if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
|
|
387
|
-
returnFocus = null;
|
|
388
|
-
if (typeof cfg.onCartClose === 'function') cfg.onCartClose(ds);
|
|
389
|
-
emit('cart-toggled', { open: false });
|
|
390
|
-
},
|
|
391
|
-
|
|
392
|
-
toggle() { if (this.isOpen()) this.close(); else this.open(); },
|
|
393
|
-
|
|
394
|
-
/**
|
|
395
|
-
* For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
|
|
396
|
-
* Returns a function that stops listening.
|
|
397
|
-
*/
|
|
398
|
-
subscribe(fn) {
|
|
399
|
-
const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
|
|
400
|
-
const h = () => call();
|
|
401
|
-
['cart-updated', 'cart-toggled'].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
|
|
402
|
-
call();
|
|
403
|
-
return () => ['cart-updated', 'cart-toggled'].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
|
|
404
|
-
},
|
|
405
|
-
|
|
406
|
-
// -------------------------------------------------------------- render
|
|
407
|
-
|
|
408
|
-
render() {
|
|
409
|
-
const cart = this.state;
|
|
410
|
-
const count = cart ? cart.totalQuantity : 0;
|
|
411
|
-
const money = (k) => (cart ? formatMoney(cart.cost?.[k]) : '');
|
|
412
|
-
|
|
413
|
-
ariaState(this.isOpen());
|
|
414
|
-
qsa(document, sel('cart-count')).forEach((el) => setTextTracked(el, count));
|
|
415
|
-
qsa(document, sel('cart-subtotal')).forEach((el) => setTextTracked(el, money('subtotalAmount')));
|
|
416
|
-
qsa(document, sel('cart-total')).forEach((el) => setTextTracked(el, money('totalAmount')));
|
|
417
|
-
qsa(document, sel('cart-checkout-charge')).forEach((el) => setTextTracked(el, money('checkoutChargeAmount')));
|
|
418
|
-
|
|
419
|
-
const hasSub = this.lines.some((l) => l.sellingPlanAllocation);
|
|
420
|
-
const conds = {
|
|
421
|
-
'cart-empty': count === 0,
|
|
422
|
-
'cart-filled': count > 0,
|
|
423
|
-
'cart-has-subscription': hasSub,
|
|
424
|
-
'cart-no-subscription': !hasSub,
|
|
425
|
-
'cart-has-discount': (cart?.discountCodes || []).some((d) => d.applicable),
|
|
426
|
-
'cart-error': Boolean(this.lastError),
|
|
427
|
-
'cart-warning': this.warnings.length > 0,
|
|
428
|
-
};
|
|
429
|
-
qsa(document, sel('cart-warning')).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(' ')));
|
|
430
|
-
qsa(document, sel('show')).forEach((el) => {
|
|
431
|
-
const k = get(el, 'show');
|
|
432
|
-
if (k in conds && !el.closest(sel('product'))) show(el, conds[k]);
|
|
433
|
-
});
|
|
434
|
-
qsa(document, sel('checkout')).forEach((el) => {
|
|
435
|
-
el.classList.toggle('is-disabled', !count);
|
|
436
|
-
el.setAttribute('aria-disabled', String(!count));
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
qsa(document, `textarea${sel('cart-note')},input${sel('cart-note')}`).forEach((el) => {
|
|
440
|
-
if (document.activeElement !== el) el.value = cart?.note || '';
|
|
441
|
-
});
|
|
442
|
-
|
|
443
|
-
qsa(document, sel('cart-discounts')).forEach((container) => {
|
|
444
|
-
repeat(container, cart?.discountCodes || [], (node, d) => {
|
|
445
|
-
node.setAttribute(attr('discount-code'), d.code);
|
|
446
|
-
const t = node.querySelector(sel('discount', 'code')) || (node.firstElementChild ? null : node);
|
|
447
|
-
if (t) setText(t, d.code);
|
|
448
|
-
node.classList.toggle('is-inapplicable', !d.applicable);
|
|
449
|
-
});
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
qsa(document, sel('cart-lines')).forEach((container) => {
|
|
453
|
-
repeatKeyed(container, this.lines, (l) => l.id, (node, line) => renderLine(node, line), {
|
|
454
|
-
onEnter: cfg.onLineEnter, onLeave: cfg.onLineLeave,
|
|
455
|
-
});
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
if (cart) notifyLayoutChanged('cart');
|
|
459
|
-
},
|
|
460
|
-
};
|
|
461
|
-
|
|
462
|
-
function lineValue(line, key) {
|
|
463
|
-
const m = line.merchandise || {};
|
|
464
|
-
const plan = line.sellingPlanAllocation?.sellingPlan;
|
|
465
|
-
switch (key) {
|
|
466
|
-
case 'title': return m.product?.title;
|
|
467
|
-
case 'product-title': return m.product?.title;
|
|
468
|
-
case 'variant-title': return m.title === 'Default Title' ? '' : m.title;
|
|
469
|
-
case 'options': return (m.selectedOptions || [])
|
|
470
|
-
.filter((o) => o.value !== 'Default Title').map((o) => `${o.name}: ${o.value}`).join(', ');
|
|
471
|
-
case 'vendor': return m.product?.vendor;
|
|
472
|
-
case 'sku': return m.sku;
|
|
473
|
-
case 'price': return formatMoney(line.cost?.amountPerQuantity || m.price);
|
|
474
|
-
case 'compare-at-price': return formatMoney(line.cost?.compareAtAmountPerQuantity || m.compareAtPrice);
|
|
475
|
-
case 'line-total': return formatMoney(line.cost?.totalAmount);
|
|
476
|
-
case 'line-subtotal': return formatMoney(line.cost?.subtotalAmount);
|
|
477
|
-
case 'quantity': return Cart.quantityOf(line);
|
|
478
|
-
case 'plan': return plan?.name || '';
|
|
479
|
-
case 'plan-description': return plan?.description || '';
|
|
480
|
-
case 'components': return (line.lineComponents || [])
|
|
481
|
-
.map((c) => `${c.quantity} × ${c.merchandise?.product?.title || ''}`).join(', ');
|
|
482
|
-
default:
|
|
483
|
-
if (key.startsWith('attribute:')) {
|
|
484
|
-
const k = key.slice('attribute:'.length);
|
|
485
|
-
return (line.attributes || []).find((a) => a.key === k)?.value || '';
|
|
486
|
-
}
|
|
487
|
-
return undefined;
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
function renderLine(node, line) {
|
|
492
|
-
const m = line.merchandise || {};
|
|
493
|
-
const isSub = Boolean(line.sellingPlanAllocation);
|
|
494
|
-
node.setAttribute(attr('line-id'), line.id);
|
|
495
|
-
node.classList.toggle('is-subscription', isSub);
|
|
496
|
-
|
|
497
|
-
qsa(node, sel('line')).forEach((el) => {
|
|
498
|
-
const key = get(el, 'line');
|
|
499
|
-
if (key === 'image') return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
|
|
500
|
-
if (key === 'url') {
|
|
501
|
-
const u = productUrl(m.product?.handle, m.product?.id);
|
|
502
|
-
const q = new URLSearchParams();
|
|
503
|
-
if (m.id) q.set('variant', String(m.id).split('/').pop());
|
|
504
|
-
const plan = line.sellingPlanAllocation?.sellingPlan?.id;
|
|
505
|
-
if (plan) q.set('selling_plan', plan.split('/').pop());
|
|
506
|
-
const qs = q.toString();
|
|
507
|
-
return el.setAttribute('href', qs ? `${u}${u.includes('?') ? '&' : '?'}${qs}` : u);
|
|
508
|
-
}
|
|
509
|
-
const v = lineValue(line, key);
|
|
510
|
-
if (v !== undefined) setTextTracked(el, v);
|
|
511
|
-
return undefined;
|
|
512
|
-
});
|
|
513
|
-
|
|
514
|
-
// Shopify's note about this line (e.g. less stock than asked): class is-warning, tether-line="warning".
|
|
515
|
-
const warning = Cart.warnings.find((w) => w.target === line.id);
|
|
516
|
-
node.classList.toggle('is-warning', Boolean(warning));
|
|
517
|
-
const pending = Cart.pendingQty.has(line.id);
|
|
518
|
-
node.classList.toggle('is-busy', pending);
|
|
519
|
-
if (pending) node.setAttribute('aria-busy', 'true'); else node.removeAttribute('aria-busy');
|
|
520
|
-
qsa(node, sel('line', 'warning')).forEach((el) => setTextTracked(el, warning?.message || ''));
|
|
521
|
-
const lineConds = {
|
|
522
|
-
subscription: isSub,
|
|
523
|
-
'one-time': !isSub,
|
|
524
|
-
'on-sale': parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
|
|
525
|
-
'has-components': (line.lineComponents || []).length > 0,
|
|
526
|
-
warning: Boolean(warning),
|
|
527
|
-
};
|
|
528
|
-
qsa(node, sel('line-show')).forEach((el) => {
|
|
529
|
-
const k = get(el, 'line-show');
|
|
530
|
-
if (k in lineConds) show(el, lineConds[k]);
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
// Change frequency in the cart: <select tether-line-plan> lists the product's plans.
|
|
534
|
-
qsa(node, `select${sel('line-plan')}`).forEach((select) => {
|
|
535
|
-
const handle = m.product?.handle;
|
|
536
|
-
if (!handle) return;
|
|
537
|
-
getProduct(handle).then((product) => {
|
|
538
|
-
const plans = allPlans(product);
|
|
539
|
-
const allowOneTime = !product.requiresSellingPlan && get(select, 'line-plan') !== 'subscription-only';
|
|
540
|
-
const want = [
|
|
541
|
-
...(allowOneTime ? [{ value: '', label: select.getAttribute(attr('one-time-label')) || 'One-time purchase' }] : []),
|
|
542
|
-
...plans.map((p) => ({ value: p.id, label: p.name })),
|
|
543
|
-
];
|
|
544
|
-
const sig = want.map((w) => w.value).join('|');
|
|
545
|
-
if (select.dataset.tetherSig !== sig) {
|
|
546
|
-
select.innerHTML = '';
|
|
547
|
-
want.forEach((w) => {
|
|
548
|
-
const o = document.createElement('option');
|
|
549
|
-
o.value = w.value; o.textContent = w.label;
|
|
550
|
-
select.appendChild(o);
|
|
551
|
-
});
|
|
552
|
-
select.dataset.tetherSig = sig;
|
|
553
|
-
}
|
|
554
|
-
select.value = line.sellingPlanAllocation?.sellingPlan?.id || '';
|
|
555
|
-
show(select, plans.length > 0);
|
|
556
|
-
}).catch((err) => reportError(err, 'line plan options'));
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
|