@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/extras.js
CHANGED
|
@@ -1,63 +1,145 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Optional part with features that load when first needed: bundles (on the first "add set"
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Optional part with features that load when first needed: bundles (on the first "add set" click), tab sync (on the
|
|
3
|
+
* first change in another tab) and shareable variant links (on the first variant change, with `variantUrl` or
|
|
4
|
+
* `tether-variant-url`).
|
|
5
5
|
*/
|
|
6
6
|
import { cfg, warn, reportError } from './config.js';
|
|
7
7
|
import { sel, get, has, qsa, emit, parseJSONAttr, toAttributes } from './dom.js';
|
|
8
8
|
import { scopes } from './product.js';
|
|
9
9
|
import { Cart } from './cart.js';
|
|
10
|
+
/** @import { ShopifyCart } from './cart/store.js' */
|
|
10
11
|
|
|
12
|
+
/** @typedef {{ country: string, language: string | null, locale: string | undefined }} StoredMarket */
|
|
11
13
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
+
* @typedef {(country: string, options: { language: string | null, locale: string | undefined, remember: boolean })
|
|
15
|
+
* => Promise<unknown>} SetCountry
|
|
14
16
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Element} group
|
|
20
|
+
* @param {string | null} name The group's `tether-group` value.
|
|
21
|
+
*/
|
|
22
|
+
function groupAttributes(group, name) {
|
|
23
|
+
const extra = toAttributes(
|
|
24
|
+
/** @type {Parameters<typeof toAttributes>[0]} */ (parseJSONAttr(group, 'line-attributes')),
|
|
25
|
+
);
|
|
26
|
+
return name ? [{ key: '_group', value: name }, ...extra] : extra;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `[tether-group]` with `[tether-group-add]`: adds every product scope inside the group in one request, for bundles,
|
|
31
|
+
* build-a-box and "complete the set". The lines get the attribute `_group` and the group's `tether-line-attributes`.
|
|
32
|
+
*
|
|
33
|
+
* @param {Element} button
|
|
34
|
+
* @returns {Promise<ShopifyCart | null>} The cart, `null` with nothing to add or after a reported failure.
|
|
35
|
+
*/
|
|
36
|
+
export async function addGroup(button) {
|
|
37
|
+
const group = button.closest(sel('group'));
|
|
17
38
|
if (!group) return null;
|
|
18
39
|
const name = get(group, 'group');
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
const attributes = groupAttributes(group, name);
|
|
41
|
+
const lines = qsa(group, sel('product')).flatMap((el) => {
|
|
42
|
+
const scope = scopes.get(el);
|
|
43
|
+
if (!scope?.variant?.availableForSale || scope.quantity <= 0) return [];
|
|
44
|
+
const line = scope.getLine();
|
|
45
|
+
return line ? [{ ...line, attributes: [...(line.attributes ?? []), ...attributes] }] : [];
|
|
46
|
+
});
|
|
47
|
+
if (!lines.length) {
|
|
48
|
+
warn('group-add: nothing selected');
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
30
51
|
try {
|
|
31
52
|
const cart = await Cart.add(lines);
|
|
32
53
|
emit('group-added', { group: name, lines }, group);
|
|
33
54
|
return cart;
|
|
34
|
-
} catch (
|
|
55
|
+
} catch (error) {
|
|
56
|
+
reportError(error, 'group add');
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
35
59
|
}
|
|
36
60
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Another tab writes the market as JSON; a value that isn't (written by something else under the key) is skipped.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} text
|
|
65
|
+
* @returns {StoredMarket | null}
|
|
66
|
+
*/
|
|
67
|
+
function parseStoredMarket(text) {
|
|
68
|
+
/** @type {unknown} */
|
|
69
|
+
let value;
|
|
70
|
+
try {
|
|
71
|
+
value = JSON.parse(text);
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
45
74
|
}
|
|
75
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
76
|
+
const { country, language, locale } = /** @type {Record<string, unknown>} */ (value);
|
|
77
|
+
if (typeof country !== 'string' || !country) return null;
|
|
78
|
+
return {
|
|
79
|
+
country,
|
|
80
|
+
language: typeof language === 'string' ? language : null,
|
|
81
|
+
locale: typeof locale === 'string' ? locale : undefined,
|
|
82
|
+
};
|
|
46
83
|
}
|
|
47
84
|
|
|
48
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Another tab changed the cart (it is loaded again here) or the visitor's market (this tab follows it).
|
|
87
|
+
*
|
|
88
|
+
* @param {StorageEvent} event
|
|
89
|
+
*/
|
|
90
|
+
export function onStorage(event) {
|
|
91
|
+
if (event.key === cfg.storageKey || event.key === `${cfg.storageKey}:seen`) {
|
|
92
|
+
Cart.init().catch((/** @type {unknown} */ error) => {
|
|
93
|
+
reportError(error, 'cart from another tab');
|
|
94
|
+
});
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (event.key !== `${cfg.prefix}:market:${cfg.domain}` || !event.newValue) return;
|
|
98
|
+
const market = parseStoredMarket(event.newValue);
|
|
99
|
+
const { Tether } = /** @type {Window & { Tether?: { setCountry: SetCountry } }} */ (window);
|
|
100
|
+
if (!market || !Tether) return;
|
|
101
|
+
const options = { language: market.language, locale: market.locale, remember: false };
|
|
102
|
+
Tether.setCountry(market.country, options).catch((/** @type {unknown} */ error) => {
|
|
103
|
+
reportError(error, 'market from another tab');
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @type {WeakMap<Element, string>} Scope root → its last variant and plan, so the first render doesn't write. */
|
|
108
|
+
const urlKeys = new WeakMap();
|
|
109
|
+
|
|
110
|
+
/** @param {string} gid */
|
|
111
|
+
const numericId = (gid) => gid.split('/').pop() ?? gid;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Safari throws a SecurityError past 100 history changes in 10 seconds; the address then keeps the previous choice
|
|
115
|
+
* until the next one.
|
|
116
|
+
*
|
|
117
|
+
* @param {URL} url
|
|
118
|
+
*/
|
|
119
|
+
function replaceUrlIfAllowed(url) {
|
|
120
|
+
try {
|
|
121
|
+
window.history.replaceState(window.history.state, '', url);
|
|
122
|
+
} catch {
|
|
123
|
+
// Too many changes in a row: see the function's comment.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
49
126
|
|
|
50
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* `?variant=` (and `selling_plan=`) follow the visitor's choice.
|
|
129
|
+
*
|
|
130
|
+
* @param {Element} root The product scope's root.
|
|
131
|
+
* @param {{ variant: { id: string }, sellingPlanId: string | null | undefined }} choice
|
|
132
|
+
*/
|
|
51
133
|
export function writeVariantUrl(root, { variant, sellingPlanId }) {
|
|
52
|
-
const key = `${variant.id}|${sellingPlanId
|
|
134
|
+
const key = `${variant.id}|${sellingPlanId ?? ''}`;
|
|
53
135
|
const before = urlKeys.get(root);
|
|
54
136
|
urlKeys.set(root, key);
|
|
55
137
|
if (!before || before === key) return;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
138
|
+
const isMainProduct = cfg.variantUrl && document.querySelector(sel('product')) === root;
|
|
139
|
+
if (!has(root, 'variant-url') && !isMainProduct) return;
|
|
140
|
+
const url = new URL(window.location.href);
|
|
141
|
+
url.searchParams.set('variant', numericId(variant.id));
|
|
142
|
+
if (sellingPlanId) url.searchParams.set('selling_plan', numericId(sellingPlanId));
|
|
143
|
+
else url.searchParams.delete('selling_plan');
|
|
144
|
+
replaceUrlIfAllowed(url);
|
|
63
145
|
}
|
package/src/fragments.js
CHANGED
|
@@ -1,27 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* GraphQL fragments.
|
|
3
|
-
* from config
|
|
2
|
+
* The GraphQL fragments of what Tether renders. They are built at call time because the list sizes and metafields come
|
|
3
|
+
* from the config; the browser passes its validated `cfg`, the server helpers their own. Operations put these after
|
|
4
|
+
* the operation, where `withContext` expects them.
|
|
5
|
+
*/
|
|
6
|
+
import { cfg, toMetafieldIdentifier } from './config.js';
|
|
7
|
+
/** @import { TetherConfig, MetafieldIdentifier } from './config.js' */
|
|
8
|
+
/** @import { Money } from './shopify-values.js' */
|
|
9
|
+
|
|
10
|
+
/** @typedef {{ url: string, altText: string | null, width: number | null, height: number | null }} Image */
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{ __typename: 'SellingPlanPercentagePriceAdjustment', adjustmentPercentage: number }
|
|
13
|
+
* | { __typename: 'SellingPlanFixedAmountPriceAdjustment', adjustmentAmount: Money }
|
|
14
|
+
* | { __typename: 'SellingPlanFixedPriceAdjustment', price: Money }} SellingPlanAdjustmentValue
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {object} SellingPlan
|
|
18
|
+
* @property {string} id
|
|
19
|
+
* @property {string} name
|
|
20
|
+
* @property {string | null} description
|
|
21
|
+
* @property {boolean} recurringDeliveries
|
|
22
|
+
* @property {{ name: string, value: string }[]} options
|
|
23
|
+
* @property {{ orderCount: number | null, adjustmentValue: SellingPlanAdjustmentValue }[]} priceAdjustments
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} SellingPlanGroup
|
|
27
|
+
* @property {string} name
|
|
28
|
+
* @property {string | null} appName
|
|
29
|
+
* @property {{ name: string, values: string[] }[]} options
|
|
30
|
+
* @property {{ nodes: SellingPlan[] }} sellingPlans
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {object} SellingPlanAllocation A variant's price on one plan.
|
|
34
|
+
* @property {{ id: string }} sellingPlan
|
|
35
|
+
* @property {Money} checkoutChargeAmount What the customer pays at checkout (more for a prepaid plan).
|
|
36
|
+
* @property {Money} remainingBalanceChargeAmount
|
|
37
|
+
* @property {{ price: Money, compareAtPrice: Money, perDeliveryPrice: Money, unitPrice: Money | null }[]}
|
|
38
|
+
* priceAdjustments
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {object} ProductVariant
|
|
42
|
+
* @property {string} id
|
|
43
|
+
* @property {string} title
|
|
44
|
+
* @property {boolean} availableForSale
|
|
45
|
+
* @property {boolean} currentlyNotInStock
|
|
46
|
+
* @property {number | null} [quantityAvailable] Absent when the store's token can't read inventory.
|
|
47
|
+
* @property {string | null} sku
|
|
48
|
+
* @property {string | null} barcode
|
|
49
|
+
* @property {{ name: string, value: string }[]} selectedOptions
|
|
50
|
+
* @property {Image | null} image
|
|
51
|
+
* @property {Money} price
|
|
52
|
+
* @property {Money | null} compareAtPrice
|
|
53
|
+
* @property {Money | null} unitPrice
|
|
54
|
+
* @property {{ nodes: SellingPlanAllocation[] }} sellingPlanAllocations
|
|
55
|
+
*/
|
|
56
|
+
/**
|
|
57
|
+
* @typedef {object} Metafield
|
|
58
|
+
* @property {string} namespace
|
|
59
|
+
* @property {string} key
|
|
60
|
+
* @property {string} type
|
|
61
|
+
* @property {string} value
|
|
62
|
+
* @property {{ image?: Image } | null} reference A file reference's image.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {object} Product The fields of `fragment TetherProduct`.
|
|
66
|
+
* @property {string} id
|
|
67
|
+
* @property {string} handle
|
|
68
|
+
* @property {string} title
|
|
69
|
+
* @property {string} description
|
|
70
|
+
* @property {string} descriptionHtml
|
|
71
|
+
* @property {string} productType
|
|
72
|
+
* @property {string} vendor
|
|
73
|
+
* @property {string[]} tags
|
|
74
|
+
* @property {boolean} availableForSale
|
|
75
|
+
* @property {string | null} publishedAt
|
|
76
|
+
* @property {string | null} onlineStoreUrl
|
|
77
|
+
* @property {boolean} requiresSellingPlan
|
|
78
|
+
* @property {{ title: string | null, description: string | null }} seo
|
|
79
|
+
* @property {Image | null} featuredImage
|
|
80
|
+
* @property {{ nodes: Image[] }} images
|
|
81
|
+
* @property {{ name: string, optionValues: { name: string, swatch: unknown }[] }[]} options
|
|
82
|
+
* @property {{ minVariantPrice: Money, maxVariantPrice: Money }} priceRange
|
|
83
|
+
* @property {{ minVariantPrice: Money, maxVariantPrice: Money }} compareAtPriceRange
|
|
84
|
+
* @property {(Metafield | null)[]} [metafields] Present when metafields are configured; `null` for one that isn't set.
|
|
85
|
+
* @property {{ nodes: SellingPlanGroup[] }} sellingPlanGroups
|
|
86
|
+
* @property {{ nodes: ProductVariant[] }} variants
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Pick<TetherConfig, 'imagesFirst' | 'variantsFirst' | 'sellingPlanGroupsFirst' | 'sellingPlansFirst'
|
|
91
|
+
* | 'metafields'>} ProductFragmentConfig
|
|
4
92
|
*/
|
|
5
|
-
import { cfg } from './config.js';
|
|
6
93
|
|
|
7
94
|
const MONEY = 'amount currencyCode';
|
|
8
95
|
const IMAGE = 'url altText width height';
|
|
9
96
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The `metafields(identifiers:)` field. A fragment can't declare variables, so the identifiers are written into the
|
|
99
|
+
* text: only valid ones (`toMetafieldIdentifier`), each quoted as a string literal.
|
|
100
|
+
*
|
|
101
|
+
* @param {ProductFragmentConfig['metafields']} metafields
|
|
102
|
+
*/
|
|
103
|
+
function metafieldsField(metafields) {
|
|
104
|
+
/** @type {MetafieldIdentifier[]} */
|
|
105
|
+
const identifiers = [];
|
|
106
|
+
for (const ref of metafields) {
|
|
107
|
+
const identifier = toMetafieldIdentifier(ref);
|
|
108
|
+
if (identifier) identifiers.push(identifier);
|
|
109
|
+
}
|
|
110
|
+
if (!identifiers.length) return '';
|
|
111
|
+
const list = identifiers
|
|
112
|
+
.map(({ namespace, key }) => `{namespace: ${JSON.stringify(namespace)}, key: ${JSON.stringify(key)}}`)
|
|
25
113
|
.join(', ');
|
|
26
114
|
return `metafields(identifiers: [${list}]) {
|
|
27
115
|
namespace key type value
|
|
@@ -29,7 +117,7 @@ function metafieldsField(o = cfg) {
|
|
|
29
117
|
}`;
|
|
30
118
|
}
|
|
31
119
|
|
|
32
|
-
|
|
120
|
+
const SELLING_PLAN_FIELDS = `
|
|
33
121
|
id name description recurringDeliveries
|
|
34
122
|
options { name value }
|
|
35
123
|
priceAdjustments {
|
|
@@ -42,7 +130,7 @@ export const SELLING_PLAN_FIELDS = `
|
|
|
42
130
|
}
|
|
43
131
|
}`;
|
|
44
132
|
|
|
45
|
-
|
|
133
|
+
const ALLOCATION_FIELDS = `
|
|
46
134
|
sellingPlan { id }
|
|
47
135
|
checkoutChargeAmount { ${MONEY} }
|
|
48
136
|
remainingBalanceChargeAmount { ${MONEY} }
|
|
@@ -53,7 +141,10 @@ export const ALLOCATION_FIELDS = `
|
|
|
53
141
|
unitPrice { ${MONEY} }
|
|
54
142
|
}`;
|
|
55
143
|
|
|
56
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* @param {ProductFragmentConfig} [o] The list sizes and metafields.
|
|
146
|
+
* @returns {string} `fragment TetherProduct on Product`.
|
|
147
|
+
*/
|
|
57
148
|
export function productFragment(o = cfg) {
|
|
58
149
|
return `
|
|
59
150
|
fragment TetherProduct on Product {
|
|
@@ -65,7 +156,7 @@ fragment TetherProduct on Product {
|
|
|
65
156
|
options { name optionValues { name swatch { color image { previewImage { ${IMAGE} } } } } }
|
|
66
157
|
priceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
|
|
67
158
|
compareAtPriceRange { minVariantPrice { ${MONEY} } maxVariantPrice { ${MONEY} } }
|
|
68
|
-
${metafieldsField(o)}
|
|
159
|
+
${metafieldsField(o.metafields)}
|
|
69
160
|
sellingPlanGroups(first: ${o.sellingPlanGroupsFirst}) {
|
|
70
161
|
nodes {
|
|
71
162
|
name appName
|
|
@@ -87,6 +178,7 @@ fragment TetherProduct on Product {
|
|
|
87
178
|
}`;
|
|
88
179
|
}
|
|
89
180
|
|
|
181
|
+
/** @returns {string} `fragment TetherCart on Cart`. */
|
|
90
182
|
export function cartFragment() {
|
|
91
183
|
return `
|
|
92
184
|
fragment TetherCart on Cart {
|
|
@@ -136,6 +228,7 @@ fragment TetherCart on Cart {
|
|
|
136
228
|
}`;
|
|
137
229
|
}
|
|
138
230
|
|
|
231
|
+
/** @returns {string} The collection fields Tether renders, for inside a `collection { }` selection. */
|
|
139
232
|
export function collectionFields() {
|
|
140
233
|
return `
|
|
141
234
|
id handle title description descriptionHtml
|
package/src/images.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopify images on the page: sized through the CDN's URL parameters, written to an `<img>` or as a CSS background,
|
|
3
|
+
* and never downloaded twice when the server already rendered the same image.
|
|
4
|
+
*/
|
|
5
|
+
import { attr } from './markup.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} ImageSize Shopify CDN parameters.
|
|
9
|
+
* @property {string | number | null} [width]
|
|
10
|
+
* @property {string | number | null} [height]
|
|
11
|
+
* @property {string | null} [crop] `center`, `top`, `bottom`, `left` or `right`; only applies with a width or height.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Shopify CDN image at a size: `imageUrl(url, { width: 800 })`. Use the same width as the element's max-width
|
|
16
|
+
* attribute, so the browser script keeps the image instead of loading it again.
|
|
17
|
+
*
|
|
18
|
+
* @template {string | null | undefined} Url
|
|
19
|
+
* @param {Url} url
|
|
20
|
+
* @param {ImageSize} [size]
|
|
21
|
+
* @returns {Url} The sized URL; `url` itself when it is empty or not absolute.
|
|
22
|
+
*/
|
|
23
|
+
export function imageUrl(url, { width, height, crop } = {}) {
|
|
24
|
+
if (!url) return url;
|
|
25
|
+
let parsed;
|
|
26
|
+
try {
|
|
27
|
+
parsed = new URL(url);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
// A relative or malformed URL has no CDN parameters to set: it is used as written.
|
|
30
|
+
if (error instanceof TypeError) return url;
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
if (width) parsed.searchParams.set('width', String(width));
|
|
34
|
+
if (height) parsed.searchParams.set('height', String(height));
|
|
35
|
+
if (crop) parsed.searchParams.set('crop', crop);
|
|
36
|
+
return /** @type {Url} */ (parsed.toString());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {Element} el
|
|
41
|
+
* @param {string} name
|
|
42
|
+
* @returns {string | null} `name` as Webflow's custom attribute, or in valid HTML as `<prefix>-name` / `data-name`.
|
|
43
|
+
*/
|
|
44
|
+
function sizeAttribute(el, name) {
|
|
45
|
+
return el.getAttribute(name) ?? el.getAttribute(attr(name)) ?? el.getAttribute(`data-${name}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @template {string | null | undefined} Url
|
|
50
|
+
* @param {Url} url
|
|
51
|
+
* @param {Element | null | undefined} el The element whose max-width / max-height / crop attributes size the image.
|
|
52
|
+
* @returns {Url}
|
|
53
|
+
*/
|
|
54
|
+
export function cdnUrl(url, el) {
|
|
55
|
+
if (!url || !el) return url;
|
|
56
|
+
const width = sizeAttribute(el, 'max-width');
|
|
57
|
+
const height = sizeAttribute(el, 'max-height');
|
|
58
|
+
return imageUrl(url, { width, height, crop: width || height ? sizeAttribute(el, 'crop') : null });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string | null | undefined} a
|
|
63
|
+
* @param {string | null | undefined} b
|
|
64
|
+
* @returns {boolean} True for the same image at any size parameters: the server-rendered one is kept.
|
|
65
|
+
*/
|
|
66
|
+
export const sameImage = (a, b) => Boolean(a && b) && a?.split('?')[0] === b?.split('?')[0];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A CSS `url()` for an image URL from Shopify. Characters that would end the string are escaped, so the URL can't
|
|
70
|
+
* add declarations; only http(s), relative and data:image URLs are used.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} url
|
|
73
|
+
*/
|
|
74
|
+
function cssUrl(url) {
|
|
75
|
+
const hasScheme = /^\s*[a-z][a-z0-9+.-]*:/i.test(url);
|
|
76
|
+
if (hasScheme && !/^\s*(https?:|data:image\/)/i.test(url)) return 'none';
|
|
77
|
+
const escaped = url.replace(/["\\\n\r\f]/g, (char) => `\\${char.charCodeAt(0).toString(16)} `);
|
|
78
|
+
return `url("${escaped}")`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Shows `url` in `el`: as the `src` of an `<img>` (lazy unless it says otherwise), else as its background image.
|
|
83
|
+
*
|
|
84
|
+
* @param {HTMLElement} el
|
|
85
|
+
* @param {string | null | undefined} url
|
|
86
|
+
* @param {string | null} [alt]
|
|
87
|
+
*/
|
|
88
|
+
export function setImage(el, url, alt) {
|
|
89
|
+
if (!url) return;
|
|
90
|
+
const src = cdnUrl(url, el);
|
|
91
|
+
if (el.tagName !== 'IMG') {
|
|
92
|
+
el.style.backgroundImage = cssUrl(src);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (sameImage(el.getAttribute('src'), src)) {
|
|
96
|
+
if (alt != null && !el.getAttribute('alt')) el.setAttribute('alt', alt);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
el.removeAttribute('srcset');
|
|
100
|
+
el.removeAttribute('sizes');
|
|
101
|
+
el.setAttribute('src', src);
|
|
102
|
+
if (alt != null) el.setAttribute('alt', alt);
|
|
103
|
+
if (!el.getAttribute('loading')) el.setAttribute('loading', 'lazy');
|
|
104
|
+
}
|