@occluze/tether 0.7.0 → 0.7.2
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 +51 -109
- package/dist/esm/astro.js +1 -1
- package/dist/esm/chunks/{account-UIPWJQ3Y.js → account-W4FBRKXA.js} +1 -1
- package/dist/esm/chunks/{account-pages-6ROA6Y4V.js → account-pages-IM3OQTOP.js} +1 -1
- package/dist/esm/chunks/analytics-QDVOLZY3.js +1 -0
- package/dist/esm/chunks/{chunk-T44MUL4Y.js → chunk-AUVAN3KZ.js} +1 -1
- package/dist/esm/chunks/chunk-BMDYNMRC.js +1 -0
- package/dist/esm/chunks/chunk-CUL66KRA.js +1 -0
- package/dist/esm/chunks/{chunk-6OZJCAGZ.js → chunk-CVNRODH3.js} +1 -1
- package/dist/esm/chunks/{chunk-5Z5O3767.js → chunk-CXHPBT7N.js} +1 -1
- package/dist/esm/chunks/chunk-GF6JTI65.js +1 -0
- package/dist/esm/chunks/chunk-MRSDJPBC.js +1 -0
- package/dist/esm/chunks/chunk-NYDZPTLP.js +1 -0
- package/dist/esm/chunks/chunk-QDHLXXAZ.js +1 -0
- package/dist/esm/chunks/{chunk-4GCXM764.js → chunk-QUECUXHD.js} +1 -1
- package/dist/esm/chunks/chunk-RJY76ESL.js +1 -0
- package/dist/esm/chunks/chunk-RPI5C3EJ.js +1 -0
- package/dist/esm/chunks/{chunk-BFTZR4OB.js → chunk-YC4JXWRG.js} +2 -2
- package/dist/esm/chunks/{diagnose-3FEKRIEK.js → diagnose-3XHPZZNP.js} +1 -1
- package/dist/esm/chunks/extras-C724KKG6.js +1 -0
- package/dist/esm/chunks/{markets-ui-FD6NI2OG.js → markets-ui-WBWWQAGU.js} +1 -1
- package/dist/esm/chunks/{search-UJ73YBQ5.js → search-5NFIZBPJ.js} +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +2 -2
- package/dist/tether.account-pages.js +13 -3
- package/dist/tether.account-pages.min.js +1 -1
- package/dist/tether.account.js +13 -3
- package/dist/tether.account.min.js +1 -1
- package/dist/tether.analytics.js +18 -3
- package/dist/tether.analytics.min.js +1 -1
- package/dist/tether.diagnose.js +20 -5
- package/dist/tether.diagnose.min.js +2 -2
- package/dist/tether.extras.js +52 -28
- package/dist/tether.extras.min.js +1 -1
- package/dist/tether.js +353 -288
- package/dist/tether.markets.js +31 -5
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +2 -2
- package/dist/tether.min.js.map +4 -4
- package/dist/tether.search.js +18 -3
- package/dist/tether.search.min.js +1 -1
- package/dist/types/server/src/shared/cart-id.d.ts +2 -0
- package/dist/types/src/cart/mutations.d.ts +6 -0
- package/dist/types/src/cart/store.d.ts +5 -2
- package/dist/types/src/cart.d.ts +3 -2
- package/dist/types/src/client.d.ts +61 -22
- package/dist/types/src/config.d.ts +6 -1
- package/dist/types/src/index.d.ts +2 -2
- package/dist/types/src/markets.d.ts +9 -2
- package/dist/types/src/product-shape.d.ts +3 -1
- package/dist/types/src/uri.d.ts +6 -0
- package/package.json +54 -6
- package/dist/esm/chunks/analytics-2V632IRV.js +0 -1
- package/dist/esm/chunks/chunk-56FCLJJD.js +0 -1
- package/dist/esm/chunks/chunk-A5SYCVQM.js +0 -1
- package/dist/esm/chunks/chunk-BA4LQJJQ.js +0 -1
- package/dist/esm/chunks/chunk-C5JYQCWH.js +0 -1
- package/dist/esm/chunks/chunk-E5YO6274.js +0 -1
- package/dist/esm/chunks/chunk-MPDWM2NN.js +0 -1
- package/dist/esm/chunks/chunk-YLUULATY.js +0 -1
- package/dist/esm/chunks/extras-6POFBNOP.js +0 -1
- package/src/account/addresses-view.ts +0 -58
- package/src/account/answers.ts +0 -79
- package/src/account/markup.ts +0 -36
- package/src/account/operations.ts +0 -145
- package/src/account/order-lines.ts +0 -75
- package/src/account/order-list.ts +0 -107
- package/src/account/order-page.ts +0 -137
- package/src/account/page-answers.ts +0 -317
- package/src/account/page-events.ts +0 -253
- package/src/account/server-client.ts +0 -183
- package/src/account/subscriptions-view.ts +0 -115
- package/src/account-api.ts +0 -98
- package/src/account-marks.ts +0 -35
- package/src/account-pages.ts +0 -81
- package/src/account.ts +0 -284
- package/src/amounts.ts +0 -64
- package/src/analytics-shopify.ts +0 -246
- package/src/analytics.ts +0 -315
- package/src/api-version.ts +0 -79
- package/src/astro.ts +0 -272
- package/src/cart/cart-shape.ts +0 -25
- package/src/cart/checkout.ts +0 -101
- package/src/cart/drawer.ts +0 -125
- package/src/cart/mutations.ts +0 -237
- package/src/cart/render.ts +0 -266
- package/src/cart/store.ts +0 -279
- package/src/cart.ts +0 -345
- package/src/client.ts +0 -391
- package/src/collection.ts +0 -175
- package/src/config-sections.ts +0 -284
- package/src/config.ts +0 -480
- package/src/controls/cart-controls.ts +0 -123
- package/src/controls/discount.ts +0 -75
- package/src/controls/extras.ts +0 -54
- package/src/controls/navigation.ts +0 -77
- package/src/diagnose-markup.ts +0 -298
- package/src/diagnose.ts +0 -294
- package/src/dom.ts +0 -268
- package/src/extras.ts +0 -136
- package/src/fragments.ts +0 -261
- package/src/globals.d.ts +0 -15
- package/src/images.ts +0 -91
- package/src/index.ts +0 -415
- package/src/initial.ts +0 -101
- package/src/lazy-iife.ts +0 -197
- package/src/lazy.ts +0 -42
- package/src/loader.ts +0 -363
- package/src/market-codes.ts +0 -6
- package/src/markets-ui.ts +0 -227
- package/src/markets.ts +0 -246
- package/src/markup.ts +0 -127
- package/src/mounting.ts +0 -264
- package/src/page-market.ts +0 -110
- package/src/part-integrity.ts +0 -7
- package/src/parts/account-pages.ts +0 -7
- package/src/parts/account.ts +0 -7
- package/src/parts/analytics.ts +0 -7
- package/src/parts/diagnose.ts +0 -7
- package/src/parts/extras.ts +0 -7
- package/src/parts/markets.ts +0 -7
- package/src/parts/search.ts +0 -7
- package/src/presets/barba.ts +0 -194
- package/src/product-events.ts +0 -115
- package/src/product-render.ts +0 -218
- package/src/product-shape.ts +0 -49
- package/src/product-state.ts +0 -318
- package/src/product.ts +0 -308
- package/src/repeaters.ts +0 -161
- package/src/search.ts +0 -305
- package/src/selling-plans.ts +0 -145
- package/src/server.ts +0 -211
- package/src/shopify-values.ts +0 -91
- package/src/status.ts +0 -106
- package/src/storage.ts +0 -34
- package/src/store-html.ts +0 -359
- package/src/storefront/catalog.ts +0 -196
- package/src/storefront/json-ld.ts +0 -144
- package/src/variant-render.ts +0 -139
package/src/analytics.ts
DELETED
|
@@ -1,315 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Analytics, as an optional part (loaded when config.analytics is set).
|
|
3
|
-
*
|
|
4
|
-
* analytics: {
|
|
5
|
-
* dataLayer: true, // GA4 / Google Tag Manager ecommerce events (window.dataLayer)
|
|
6
|
-
* shopify: true, // Shopify's own analytics (experimental, see docs/analytics.md)
|
|
7
|
-
* onEvent(name, data) {}, // any other tool: Meta, TikTok, Plausible
|
|
8
|
-
* }
|
|
9
|
-
*
|
|
10
|
-
* Events: view_item (the page's product), view_item_list (lists), add_to_cart / remove_from_cart (from the cart's
|
|
11
|
-
* changes, so quick +/- and JS calls count too), view_cart (cart opened), begin_checkout, search (search as you type).
|
|
12
|
-
* Purchases happen on Shopify's checkout: use Shopify's Google & YouTube app or a checkout pixel for those.
|
|
13
|
-
* Shopify's analytics are set up in analytics-shopify.ts.
|
|
14
|
-
*/
|
|
15
|
-
import { cfg, reportError } from './config.ts';
|
|
16
|
-
import { ANALYTICS_EVENTS, isOwnEvent, sel } from './dom.ts';
|
|
17
|
-
import { Cart } from './cart.ts';
|
|
18
|
-
import { scopes } from './product.ts';
|
|
19
|
-
import { startShopifyAnalytics } from './analytics-shopify.ts';
|
|
20
|
-
import type { Money } from './shopify-values.ts';
|
|
21
|
-
import type { Product, ProductVariant } from './fragments.ts';
|
|
22
|
-
import type { AnalyticsBus, EventPayload } from './analytics-shopify.ts';
|
|
23
|
-
import type { ProductScope } from './product.ts';
|
|
24
|
-
import type { AnalyticsEventName, TetherEvents } from './markup.ts';
|
|
25
|
-
|
|
26
|
-
/** The fields of the cart's product (`fragment TetherCart`) this module reads. */
|
|
27
|
-
interface LineProduct {
|
|
28
|
-
id: string;
|
|
29
|
-
title: string;
|
|
30
|
-
handle: string;
|
|
31
|
-
vendor: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface LineVariant {
|
|
35
|
-
id: string;
|
|
36
|
-
title: string;
|
|
37
|
-
sku: string | null;
|
|
38
|
-
price: Money;
|
|
39
|
-
product: LineProduct;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
interface CartLine {
|
|
43
|
-
id: string;
|
|
44
|
-
quantity: number;
|
|
45
|
-
merchandise: LineVariant;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The fields of the cart (`fragment TetherCart`) this module reads. */
|
|
49
|
-
interface AnalyticsCart {
|
|
50
|
-
lines: { nodes: CartLine[] };
|
|
51
|
-
cost: { totalAmount: Money };
|
|
52
|
-
discountCodes: { code: string; applicable: boolean }[];
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** What a GA4 item is built from: a product, or a cart line's product. */
|
|
56
|
-
interface ItemProduct {
|
|
57
|
-
id: string;
|
|
58
|
-
title: string;
|
|
59
|
-
vendor?: string;
|
|
60
|
-
productType?: string;
|
|
61
|
-
priceRange?: { minVariantPrice: Money };
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
type ItemVariant = Pick<ProductVariant, 'id' | 'title' | 'sku' | 'price'>;
|
|
65
|
-
|
|
66
|
-
/** A GA4 ecommerce item. */
|
|
67
|
-
interface AnalyticsItem {
|
|
68
|
-
item_id: string;
|
|
69
|
-
item_name: string;
|
|
70
|
-
item_variant: string | undefined;
|
|
71
|
-
item_brand: string | undefined;
|
|
72
|
-
item_category: string | undefined;
|
|
73
|
-
price: number;
|
|
74
|
-
quantity: number;
|
|
75
|
-
index?: number;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Shopify's title for the one variant of a product without options. */
|
|
79
|
-
const DEFAULT_VARIANT_TITLE = 'Default Title';
|
|
80
|
-
const CENTS_PER_UNIT = 100;
|
|
81
|
-
/** GA4 drops an event with more items than this. */
|
|
82
|
-
const MAX_LIST_ITEMS = 200;
|
|
83
|
-
|
|
84
|
-
const amountOf = (money: Money | null | undefined): number => (money ? parseFloat(money.amount) : 0);
|
|
85
|
-
|
|
86
|
-
/** The numeric part of a gid. */
|
|
87
|
-
const numericId = (gid: string | null | undefined): string => (gid ?? '').split('/').pop() ?? '';
|
|
88
|
-
|
|
89
|
-
/** @param index The position in a list. */
|
|
90
|
-
function itemFromVariant(
|
|
91
|
-
product: ItemProduct,
|
|
92
|
-
variant: ItemVariant | null | undefined,
|
|
93
|
-
quantity = 1,
|
|
94
|
-
index?: number,
|
|
95
|
-
): AnalyticsItem {
|
|
96
|
-
const item: AnalyticsItem = {
|
|
97
|
-
item_id: variant?.sku || numericId(variant?.id) || numericId(product.id),
|
|
98
|
-
item_name: product.title,
|
|
99
|
-
item_variant: variant?.title === DEFAULT_VARIANT_TITLE ? undefined : variant?.title,
|
|
100
|
-
item_brand: product.vendor || undefined,
|
|
101
|
-
item_category: product.productType || undefined,
|
|
102
|
-
price: amountOf(variant?.price ?? product.priceRange?.minVariantPrice),
|
|
103
|
-
quantity,
|
|
104
|
-
};
|
|
105
|
-
if (index != null) item.index = index;
|
|
106
|
-
return item;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const itemFromLine = (line: CartLine, quantity = line.quantity) =>
|
|
110
|
-
itemFromVariant(line.merchandise.product, line.merchandise, quantity);
|
|
111
|
-
|
|
112
|
-
/** The items' total, rounded to cents. */
|
|
113
|
-
function totalValue(items: AnalyticsItem[]): number {
|
|
114
|
-
const total = items.reduce((sum, item) => sum + item.price * (item.quantity || 1), 0);
|
|
115
|
-
return Math.round(total * CENTS_PER_UNIT) / CENTS_PER_UNIT;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const productPayload = (product: Product, variant: ItemVariant, quantity = 1) => ({
|
|
119
|
-
id: product.id,
|
|
120
|
-
title: product.title,
|
|
121
|
-
price: variant.price,
|
|
122
|
-
vendor: product.vendor || '',
|
|
123
|
-
variantId: variant.id,
|
|
124
|
-
variantTitle: variant.title,
|
|
125
|
-
quantity,
|
|
126
|
-
sku: variant.sku || undefined,
|
|
127
|
-
productType: product.productType || undefined,
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
const linePayload = (line: CartLine) => ({
|
|
131
|
-
id: line.id,
|
|
132
|
-
quantity: line.quantity,
|
|
133
|
-
merchandise: {
|
|
134
|
-
id: line.merchandise.id,
|
|
135
|
-
title: line.merchandise.title,
|
|
136
|
-
price: line.merchandise.price,
|
|
137
|
-
sku: line.merchandise.sku,
|
|
138
|
-
product: {
|
|
139
|
-
id: line.merchandise.product.id,
|
|
140
|
-
title: line.merchandise.product.title,
|
|
141
|
-
vendor: line.merchandise.product.vendor || '',
|
|
142
|
-
handle: line.merchandise.product.handle,
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
/** The GA4 / GTM data layer, created when the page has none. */
|
|
148
|
-
function dataLayer(): unknown[] {
|
|
149
|
-
const { dataLayer: option } = cfg.analytics ?? {};
|
|
150
|
-
const name = typeof option === 'string' ? option : 'dataLayer';
|
|
151
|
-
const existing: unknown = Reflect.get(window, name);
|
|
152
|
-
if (Array.isArray(existing)) return existing;
|
|
153
|
-
const created: unknown[] = [];
|
|
154
|
-
Reflect.set(window, name, created);
|
|
155
|
-
return created;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function pushToDataLayer(event: string, ecommerce: Record<string, unknown> | undefined): void {
|
|
159
|
-
const layer = dataLayer();
|
|
160
|
-
if (!ecommerce) {
|
|
161
|
-
layer.push({ event });
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
// Google asks for the previous ecommerce object to be cleared first.
|
|
165
|
-
layer.push({ ecommerce: null });
|
|
166
|
-
layer.push({ event, ecommerce });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
let bus: AnalyticsBus | null = null;
|
|
170
|
-
/** Variant id to line, to turn cart changes into add and remove events. */
|
|
171
|
-
let lastLines: Map<string, CartLine> | null = null;
|
|
172
|
-
let lastCart: AnalyticsCart | null = null;
|
|
173
|
-
/** Product roots whose view_item was sent on this page. */
|
|
174
|
-
let viewed = new WeakSet<Element>();
|
|
175
|
-
|
|
176
|
-
/** @param name The GA4 event. */
|
|
177
|
-
function send(name: string, data: Record<string, unknown>, shopifyEvent?: string, shopifyPayload?: EventPayload): void {
|
|
178
|
-
if (cfg.analytics?.dataLayer) pushToDataLayer(name, data);
|
|
179
|
-
if (bus && shopifyEvent) bus.publish(shopifyEvent, shopifyPayload);
|
|
180
|
-
try {
|
|
181
|
-
cfg.analytics?.onEvent?.(name, data);
|
|
182
|
-
} catch (error) {
|
|
183
|
-
reportError(error, 'analytics onEvent');
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** The scope of the page's own product: a product root outside every list. */
|
|
188
|
-
function mainProduct(): ProductScope | null {
|
|
189
|
-
const el = document.querySelector(sel('product'));
|
|
190
|
-
if (!el || el.closest(`${sel('collection')},${sel('products')},${sel('recommendations')}`)) return null;
|
|
191
|
-
return scopes.get(el) ?? null;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function onProductReady({ product, scope }: TetherEvents['product-ready']): void {
|
|
195
|
-
if (viewed.has(scope.root) || mainProduct() !== scope) return;
|
|
196
|
-
viewed.add(scope.root);
|
|
197
|
-
const variant = scope.variant ?? product.variants.nodes[0];
|
|
198
|
-
const items = [itemFromVariant(product, variant)];
|
|
199
|
-
const products = variant ? [productPayload(product, variant)] : [];
|
|
200
|
-
send('view_item', { currency: variant?.price.currencyCode, value: totalValue(items), items }, 'product_viewed', {
|
|
201
|
-
products,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function onListReady({ products, collection }: TetherEvents['list-ready']): void {
|
|
206
|
-
const items = products
|
|
207
|
-
.slice(0, MAX_LIST_ITEMS)
|
|
208
|
-
.map((product, index) => itemFromVariant(product, product.variants.nodes[0], 1, index));
|
|
209
|
-
const list = { item_list_id: collection?.handle || 'products', item_list_name: collection?.title || 'Products' };
|
|
210
|
-
if (!collection) {
|
|
211
|
-
send('view_item_list', { ...list, items });
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
send('view_item_list', { ...list, items }, 'collection_viewed', {
|
|
215
|
-
collection: { id: collection.id, handle: collection.handle },
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
function sendCartChanges(cart: AnalyticsCart | null, now: Map<string, CartLine>, before: Map<string, CartLine>): void {
|
|
220
|
-
const currency = cart?.cost.totalAmount.currencyCode;
|
|
221
|
-
now.forEach((line, variantId) => {
|
|
222
|
-
const previous = before.get(variantId);
|
|
223
|
-
const added = line.quantity - (previous?.quantity ?? 0);
|
|
224
|
-
if (added <= 0) return;
|
|
225
|
-
const items = [itemFromLine(line, added)];
|
|
226
|
-
send('add_to_cart', { currency, value: totalValue(items), items }, 'product_added_to_cart', {
|
|
227
|
-
cart,
|
|
228
|
-
prevCart: lastCart,
|
|
229
|
-
currentLine: linePayload(line),
|
|
230
|
-
prevLine: previous ? linePayload(previous) : undefined,
|
|
231
|
-
});
|
|
232
|
-
});
|
|
233
|
-
before.forEach((line, variantId) => {
|
|
234
|
-
const removed = line.quantity - (now.get(variantId)?.quantity ?? 0);
|
|
235
|
-
if (removed <= 0) return;
|
|
236
|
-
const items = [itemFromLine(line, removed)];
|
|
237
|
-
send('remove_from_cart', { currency, value: totalValue(items), items });
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function onCartUpdated({ cart, reason }: TetherEvents['cart-updated']): void {
|
|
242
|
-
const now = new Map((cart?.lines.nodes ?? []).map((line) => [line.merchandise.id, line]));
|
|
243
|
-
// Loading the saved cart and showing a pending change aren't changes the visitor made.
|
|
244
|
-
if (lastLines && reason !== 'load' && reason !== 'pending') sendCartChanges(cart, now, lastLines);
|
|
245
|
-
lastLines = now;
|
|
246
|
-
lastCart = cart;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function onCartOpened(): void {
|
|
250
|
-
const items = Cart.lines.map((line) => itemFromLine(line));
|
|
251
|
-
send('view_cart', { currency: Cart.state?.cost.totalAmount.currencyCode, value: totalValue(items), items });
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function onCheckout({ cart }: TetherEvents['checkout']): void {
|
|
255
|
-
if (!cart) return;
|
|
256
|
-
const items = cart.lines.nodes.map((line) => itemFromLine(line));
|
|
257
|
-
const coupons = cart.discountCodes.filter((discount) => discount.applicable).map((discount) => discount.code);
|
|
258
|
-
send('begin_checkout', {
|
|
259
|
-
currency: cart.cost.totalAmount.currencyCode,
|
|
260
|
-
value: totalValue(items),
|
|
261
|
-
coupon: coupons.join(',') || undefined,
|
|
262
|
-
items,
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** What each event `ANALYTICS_EVENTS` names sends: one entry per name, so the list and this table can't diverge. */
|
|
267
|
-
const REPORTERS: { [Name in AnalyticsEventName]: (detail: TetherEvents[Name]) => void } = {
|
|
268
|
-
'product-ready': onProductReady,
|
|
269
|
-
'list-ready': onListReady,
|
|
270
|
-
'cart-updated': onCartUpdated,
|
|
271
|
-
'cart-toggled': ({ open }) => {
|
|
272
|
-
if (open) onCartOpened();
|
|
273
|
-
},
|
|
274
|
-
checkout: onCheckout,
|
|
275
|
-
search: ({ term }) => {
|
|
276
|
-
if (term) send('search', { search_term: term }, 'search_viewed', { searchTerm: term });
|
|
277
|
-
},
|
|
278
|
-
};
|
|
279
|
-
|
|
280
|
-
/** @param event The event Tether created as `name`. */
|
|
281
|
-
function report<Name extends AnalyticsEventName>(event: CustomEvent<TetherEvents[Name]>, name: Name): void {
|
|
282
|
-
REPORTERS[name](event.detail);
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/**
|
|
286
|
-
* Turns one buffered event into analytics events: a Tether event from `ANALYTICS_EVENTS`, or Astro's `astro:page-load`
|
|
287
|
-
* for a client-side navigation, which starts a new page view. Only events Tether created count (`isOwnEvent`): a
|
|
288
|
-
* look-alike another script dispatches, whatever its detail, sends nothing.
|
|
289
|
-
*/
|
|
290
|
-
function handle(event: Event): void {
|
|
291
|
-
if (event.type === 'astro:page-load') {
|
|
292
|
-
viewed = new WeakSet();
|
|
293
|
-
bus?.publish('page_viewed', {});
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
ANALYTICS_EVENTS.forEach((name) => {
|
|
297
|
-
if (isOwnEvent(event, name)) report(event, name);
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/** Called by the core with the events it buffered while this part loaded; the queue then handles events as they come. */
|
|
302
|
-
export async function startAnalytics(queue: Event[]): Promise<void> {
|
|
303
|
-
if (cfg.analytics?.shopify) {
|
|
304
|
-
bus = await startShopifyAnalytics(() => mainProduct() != null).catch((error: unknown) => {
|
|
305
|
-
reportError(error, 'shopify analytics');
|
|
306
|
-
return null;
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
bus?.publish('page_viewed', {});
|
|
310
|
-
queue.splice(0).forEach(handle);
|
|
311
|
-
queue.push = (...events) => {
|
|
312
|
-
events.forEach(handle);
|
|
313
|
-
return queue.length;
|
|
314
|
-
};
|
|
315
|
-
}
|
package/src/api-version.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shopify API versions are quarterly (YYYY-01, -04, -07, -10) and supported for 12 months. Tether pins a dated version
|
|
3
|
-
* and never targets `unstable` (it changes without notice). Used by the config, the diagnose part and the release
|
|
4
|
-
* scripts.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { ApiVersion } from '../server/src/shared/api-versions.ts';
|
|
8
|
-
|
|
9
|
-
const QUARTERLY_VERSION = /^(\d{4})-(01|04|07|10)$/;
|
|
10
|
-
const MONTHS_PER_QUARTER = 3;
|
|
11
|
-
const QUARTERS_PER_YEAR = 4;
|
|
12
|
-
const MONTHS_SUPPORTED = 12;
|
|
13
|
-
/** Months left at which the status starts asking for an upgrade. */
|
|
14
|
-
const UPGRADE_WARNING_MONTHS = 3;
|
|
15
|
-
const AVERAGE_MONTH_MS = 30.44 * 24 * 3600 * 1000;
|
|
16
|
-
|
|
17
|
-
export interface ApiVersionStatus {
|
|
18
|
-
version: string;
|
|
19
|
-
/** Released and still supported. */
|
|
20
|
-
ok: boolean;
|
|
21
|
-
/** What to do about it, for people. */
|
|
22
|
-
note: string;
|
|
23
|
-
/** Months of support left, rounded; negative once support ended. */
|
|
24
|
-
monthsLeft?: number;
|
|
25
|
-
/** The version after this one. */
|
|
26
|
-
next?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function parseVersion(version: unknown): { year: number; month: number } | null {
|
|
30
|
-
const match = QUARTERLY_VERSION.exec(typeof version === 'string' ? version : '');
|
|
31
|
-
if (!match) return null;
|
|
32
|
-
return { year: Number(match[1]), month: Number(match[2]) };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** @returns True for a dated version such as `2026-07`. */
|
|
36
|
-
export const isQuarterlyVersion = (version: unknown): version is ApiVersion => parseVersion(version) !== null;
|
|
37
|
-
|
|
38
|
-
/** @param month 1 to 12. */
|
|
39
|
-
const formatVersion = (year: number, month: number) => `${year}-${String(month).padStart(2, '0')}`;
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* `'2026-07'` → `'2026-10'`, `'2026-10'` → `'2027-01'`.
|
|
43
|
-
*
|
|
44
|
-
* @param steps Quarters ahead.
|
|
45
|
-
* @throws {Error} When `version` isn't a quarterly version.
|
|
46
|
-
*/
|
|
47
|
-
export function nextApiVersion(version: string, steps = 1): string {
|
|
48
|
-
const parsed = parseVersion(version);
|
|
49
|
-
if (!parsed) throw new Error(`not a quarterly Shopify API version: ${version}`);
|
|
50
|
-
const quarter = parsed.year * QUARTERS_PER_YEAR + (parsed.month - 1) / MONTHS_PER_QUARTER + steps;
|
|
51
|
-
return formatVersion(Math.floor(quarter / QUARTERS_PER_YEAR), (quarter % QUARTERS_PER_YEAR) * MONTHS_PER_QUARTER + 1);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The support window of `version`, relative to `now`. */
|
|
55
|
-
export function apiVersionStatus(version: string, now = new Date()): ApiVersionStatus {
|
|
56
|
-
if (version === 'unstable') {
|
|
57
|
-
return { version, ok: false, note: '"unstable" changes without notice: pin a dated version such as 2026-07' };
|
|
58
|
-
}
|
|
59
|
-
const parsed = parseVersion(version);
|
|
60
|
-
if (!parsed) return { version, ok: false, note: 'not a quarterly version (YYYY-01, -04, -07 or -10)' };
|
|
61
|
-
const released = new Date(Date.UTC(parsed.year, parsed.month - 1, 1));
|
|
62
|
-
const supportedUntil = new Date(Date.UTC(parsed.year, parsed.month - 1 + MONTHS_SUPPORTED, 1));
|
|
63
|
-
const monthsLeft = (supportedUntil.getTime() - now.getTime()) / AVERAGE_MONTH_MS;
|
|
64
|
-
const isReleased = released <= now;
|
|
65
|
-
let note = `supported until about ${supportedUntil.toISOString().slice(0, 7)}`;
|
|
66
|
-
if (!isReleased) note = 'not released yet (release candidate at best)';
|
|
67
|
-
else if (monthsLeft <= 0) {
|
|
68
|
-
note = 'no longer supported: Shopify falls back to the oldest supported version. Upgrade (see README)';
|
|
69
|
-
} else if (monthsLeft < UPGRADE_WARNING_MONTHS) {
|
|
70
|
-
note = `${note}: plan the upgrade to ${nextApiVersion(version)} or later (see README)`;
|
|
71
|
-
}
|
|
72
|
-
return {
|
|
73
|
-
version,
|
|
74
|
-
ok: isReleased && monthsLeft > 0,
|
|
75
|
-
monthsLeft: Math.round(monthsLeft),
|
|
76
|
-
next: nextApiVersion(version),
|
|
77
|
-
note,
|
|
78
|
-
};
|
|
79
|
-
}
|
package/src/astro.ts
DELETED
|
@@ -1,272 +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), and may be the object createStorefront() is given too:
|
|
14
|
-
* its own options (siteUrl, pageSize) are left out of the page. Functions (onError, formatMoney, hooks) can't be
|
|
15
|
-
* passed here: set them in window.TETHER_CONFIG in your layout, it is merged in. View Transitions (<ClientRouter />)
|
|
16
|
-
* work without extra code. In dev, changes to src/shop.config.js restart the dev server (other files:
|
|
17
|
-
* tether({ ..., watch: ['my/config.js'] })). Runs in Node, inside Astro's config step.
|
|
18
|
-
*/
|
|
19
|
-
import { DEFAULTS } from './config.ts';
|
|
20
|
-
import type { TetherOptions } from './config.ts';
|
|
21
|
-
import type { StorefrontOptions } from './server.ts';
|
|
22
|
-
import { isSecretShopifyToken, STORE_ENV_NAMES } from './shopify-values.ts';
|
|
23
|
-
import { messageOf } from '../server/src/shared/error-message.ts';
|
|
24
|
-
|
|
25
|
-
// Astro isn't a dependency, so the parts of its integration API this file uses are described here.
|
|
26
|
-
interface AstroLogger {
|
|
27
|
-
warn: (message: string) => void;
|
|
28
|
-
}
|
|
29
|
-
type EnvStringField = (options: { context: 'client'; access: 'public'; optional: boolean }) => unknown;
|
|
30
|
-
/** The argument of Astro's `astro:config:setup` hook. */
|
|
31
|
-
interface AstroConfigSetup {
|
|
32
|
-
injectScript: (stage: 'head-inline' | 'page', content: string) => void;
|
|
33
|
-
/** `dev`, `build`, `preview` or `sync`. */
|
|
34
|
-
command: string;
|
|
35
|
-
config: { root?: URL };
|
|
36
|
-
logger: AstroLogger;
|
|
37
|
-
addWatchFile?: (path: string) => void;
|
|
38
|
-
/** Takes a partial Astro config. A method, so that Astro's own `DeepPartial<AstroConfig>` parameter fits. */
|
|
39
|
-
updateConfig?(config: { env?: { schema?: Record<string, unknown> } }): unknown;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** The config options a page can be handed as JSON: all but the functions. */
|
|
43
|
-
type SerializableOptions = {
|
|
44
|
-
[
|
|
45
|
-
Key in keyof TetherOptions as Exclude<TetherOptions[Key], null | undefined> extends (...args: never[]) => unknown
|
|
46
|
-
? never
|
|
47
|
-
: Key
|
|
48
|
-
]?: TetherOptions[Key];
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
/** `createStorefront()`'s own options that a config shared with it may hold, which the page leaves out. */
|
|
52
|
-
type ServerOnlyOptions = Pick<StorefrontOptions, 'siteUrl' | 'pageSize'>;
|
|
53
|
-
|
|
54
|
-
/** Tether's serializable config and the storefront's own options, plus the integration's own options. */
|
|
55
|
-
interface TetherAstroOptions extends SerializableOptions, ServerOnlyOptions {
|
|
56
|
-
/** Add the Tether script to every page (default true). */
|
|
57
|
-
inject?: boolean;
|
|
58
|
-
/** Files that restart the dev server when they change. */
|
|
59
|
-
watch?: string[] | null;
|
|
60
|
-
}
|
|
61
|
-
interface PageConfig {
|
|
62
|
-
domain?: unknown;
|
|
63
|
-
token?: unknown;
|
|
64
|
-
[option: string]: unknown;
|
|
65
|
-
}
|
|
66
|
-
type Env = Record<string, string | undefined>;
|
|
67
|
-
|
|
68
|
-
export type { TetherAstroOptions, AstroConfigSetup };
|
|
69
|
-
|
|
70
|
-
const DEFAULT_WATCH = ['src/shop.config.js', 'src/shop.config.mjs', 'src/shop.config.ts'];
|
|
71
|
-
/** The keys of `ServerOnlyOptions`: the browser has no use for them. */
|
|
72
|
-
const SERVER_OPTIONS = new Set(['siteUrl', 'pageSize']);
|
|
73
|
-
/**
|
|
74
|
-
* Astro is an optional peer dependency. A variable specifier keeps both tsc and the bundler from resolving it.
|
|
75
|
-
*/
|
|
76
|
-
const ASTRO_CONFIG_MODULE = 'astro/config';
|
|
77
|
-
const ENV_LINE = /^\s*(?:export\s+)?([\w.-]+)\s*=\s*(.*?)\s*$/;
|
|
78
|
-
/** A quoted value; a `#` comment may follow the closing quote. */
|
|
79
|
-
const QUOTED_VALUE = /^(["'])(.*?)\1/;
|
|
80
|
-
|
|
81
|
-
/** @returns The mode Astro runs in: `--mode`, else development for dev and production for everything else. */
|
|
82
|
-
function modeOf(command: string, argv: readonly string[] = process.argv): string {
|
|
83
|
-
const defaultMode = command === 'dev' ? 'development' : 'production';
|
|
84
|
-
const index = argv.findIndex((arg) => arg === '--mode' || arg.startsWith('--mode='));
|
|
85
|
-
if (index < 0) return defaultMode;
|
|
86
|
-
const flag = argv[index] ?? '';
|
|
87
|
-
const mode = flag.includes('=') ? flag.split('=')[1] : argv[index + 1];
|
|
88
|
-
return mode ?? defaultMode;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** @returns True for Node's "no such file" error. */
|
|
92
|
-
const isMissingFile = (error: unknown): boolean => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
93
|
-
|
|
94
|
-
/** @returns The file's text, `null` when it doesn't exist: every .env file is optional. */
|
|
95
|
-
async function readOptionalFile(path: string): Promise<string | null> {
|
|
96
|
-
const { readFile } = await import('node:fs/promises');
|
|
97
|
-
try {
|
|
98
|
-
return await readFile(path, 'utf8');
|
|
99
|
-
} catch (error) {
|
|
100
|
-
if (isMissingFile(error)) return null;
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* @param text A .env file.
|
|
107
|
-
* @param into Receives each variable; a later line wins.
|
|
108
|
-
*/
|
|
109
|
-
function parseEnvFile(text: string, into: Env) {
|
|
110
|
-
for (const line of text.split(/\r?\n/)) {
|
|
111
|
-
const match = ENV_LINE.exec(line);
|
|
112
|
-
if (!match || line.trim().startsWith('#')) continue;
|
|
113
|
-
const [, name = '', raw = ''] = match;
|
|
114
|
-
const quoted = QUOTED_VALUE.exec(raw);
|
|
115
|
-
into[name] = quoted ? (quoted[2] ?? '') : raw.replace(/\s+#.*$/, '');
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* @param root The project folder.
|
|
121
|
-
* @returns The .env files the way Vite reads them (later files win), with `process.env` on top.
|
|
122
|
-
*/
|
|
123
|
-
async function readEnv(command: string, root: string): Promise<Env> {
|
|
124
|
-
const path = await import('node:path');
|
|
125
|
-
const mode = modeOf(command);
|
|
126
|
-
const env: Env = {};
|
|
127
|
-
for (const name of ['.env', '.env.local', `.env.${mode}`, `.env.${mode}.local`]) {
|
|
128
|
-
const text = await readOptionalFile(path.join(root, name));
|
|
129
|
-
if (text !== null) parseEnvFile(text, env);
|
|
130
|
-
}
|
|
131
|
-
return { ...env, ...process.env };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Fills the store's domain and token from .env when the options don't set them, and refuses a secret token: whatever
|
|
136
|
-
* is read here is written into every page.
|
|
137
|
-
*
|
|
138
|
-
* @param config Changed in place.
|
|
139
|
-
* @throws {Error} When the token is a secret Shopify token.
|
|
140
|
-
*/
|
|
141
|
-
function applyStoreEnv(config: PageConfig, env: Env, logger: AstroLogger) {
|
|
142
|
-
for (const key of ['domain', 'token'] as const) {
|
|
143
|
-
if (config[key]) continue;
|
|
144
|
-
const name = STORE_ENV_NAMES[key].find((candidate) => env[candidate]);
|
|
145
|
-
if (!name) continue;
|
|
146
|
-
config[key] = env[name];
|
|
147
|
-
// Whatever is read here is written into every page, so a name without PUBLIC_ is a mistake waiting to happen.
|
|
148
|
-
if (!name.startsWith('PUBLIC_')) {
|
|
149
|
-
logger.warn(`${name} is written into every page as Tether's ${key}. Name it PUBLIC_${name} to say so.`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
if (typeof config.token === 'string' && isSecretShopifyToken(config.token)) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
'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.',
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The options usually come from a file the Astro config imports (src/shop.config.js): the dev server restarts when it
|
|
161
|
-
* changes, so the browser gets the new config too.
|
|
162
|
-
*/
|
|
163
|
-
async function watchConfigFiles(root: string, files: readonly string[], addWatchFile: (path: string) => void) {
|
|
164
|
-
const { existsSync } = await import('node:fs');
|
|
165
|
-
const path = await import('node:path');
|
|
166
|
-
for (const file of files) {
|
|
167
|
-
const absolute = path.resolve(root, file);
|
|
168
|
-
if (existsSync(absolute)) addWatchFile(absolute);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/** @returns The module, `null` when the package isn't installed. */
|
|
173
|
-
async function importIfInstalled(specifier: string): Promise<unknown> {
|
|
174
|
-
try {
|
|
175
|
-
const module: unknown = await import(specifier);
|
|
176
|
-
return module;
|
|
177
|
-
} catch (error) {
|
|
178
|
-
if (error instanceof Error && 'code' in error && error.code === 'ERR_MODULE_NOT_FOUND') return null;
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* @param envField `astro/config`'s `envField`, which Astro 4 doesn't have.
|
|
185
|
-
* @returns True when it has the `string` field Astro 5 documents.
|
|
186
|
-
*/
|
|
187
|
-
const hasStringField = (envField: unknown): envField is { string: EnvStringField } =>
|
|
188
|
-
typeof envField === 'object' && envField !== null && 'string' in envField && typeof envField.string === 'function';
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* Declares the store variables in `astro:env` (Astro 5+), so Astro checks them. Astro 4 has no `envField`.
|
|
192
|
-
*/
|
|
193
|
-
async function declareEnvSchema(setup: AstroConfigSetup) {
|
|
194
|
-
if (!setup.updateConfig) return;
|
|
195
|
-
const astroConfig = await importIfInstalled(ASTRO_CONFIG_MODULE);
|
|
196
|
-
const envField =
|
|
197
|
-
typeof astroConfig === 'object' && astroConfig !== null && 'envField' in astroConfig ? astroConfig.envField : null;
|
|
198
|
-
if (!hasStringField(envField)) return;
|
|
199
|
-
const field = () => envField.string({ context: 'client', access: 'public', optional: true });
|
|
200
|
-
try {
|
|
201
|
-
setup.updateConfig({
|
|
202
|
-
env: { schema: { PUBLIC_SHOPIFY_DOMAIN: field(), PUBLIC_SHOPIFY_STOREFRONT_TOKEN: field() } },
|
|
203
|
-
});
|
|
204
|
-
} catch (error) {
|
|
205
|
-
// Astro versions with `envField` but without a stable `env` option refuse it; the variables still work unchecked.
|
|
206
|
-
setup.logger.warn(`.env variables not declared in astro:env: ${messageOf(error)}`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/** @param config Changed in place: functions can't be written into a page. */
|
|
211
|
-
function dropFunctions(config: PageConfig, logger: AstroLogger) {
|
|
212
|
-
const functions = Object.keys(config).filter((key) => typeof config[key] === 'function');
|
|
213
|
-
for (const key of functions) Reflect.deleteProperty(config, key);
|
|
214
|
-
if (functions.length) {
|
|
215
|
-
logger.warn(
|
|
216
|
-
`${functions.join(', ')}: functions can't be passed here. Set them in window.TETHER_CONFIG in your layout.`,
|
|
217
|
-
);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** @param config Changed in place: only browser options are written into the page. */
|
|
222
|
-
function keepBrowserOptions(config: PageConfig, logger: AstroLogger) {
|
|
223
|
-
for (const key of Object.keys(config)) {
|
|
224
|
-
if (key in DEFAULTS) continue;
|
|
225
|
-
Reflect.deleteProperty(config, key);
|
|
226
|
-
if (!SERVER_OPTIONS.has(key)) logger.warn(`${key} is not a Tether option: left out of the page.`);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function injectTether(injectScript: AstroConfigSetup['injectScript'], config: PageConfig) {
|
|
231
|
-
// Before any module runs: window.TETHER_CONFIG in the layout doesn't auto-start a second init.
|
|
232
|
-
injectScript('head-inline', 'window.TETHER_INTEGRATION="astro";');
|
|
233
|
-
injectScript(
|
|
234
|
-
'page',
|
|
235
|
-
[
|
|
236
|
-
"import Tether from '@occluze/tether';",
|
|
237
|
-
// As the base config (like window.TETHER_CONFIG), not as overrides: a visitor's saved market (country, money
|
|
238
|
-
// format) then wins over the defaults, as on a plain script-tag site.
|
|
239
|
-
`window.TETHER_CONFIG = { ...${JSON.stringify({ autoMount: false, ...config })}, ...(window.TETHER_CONFIG || {}) };`,
|
|
240
|
-
'Tether.init().then(() => Tether.mount(document));',
|
|
241
|
-
].join('\n'),
|
|
242
|
-
);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export default function tether(options: TetherAstroOptions = {}): {
|
|
246
|
-
name: string;
|
|
247
|
-
hooks: { 'astro:config:setup': (setup: AstroConfigSetup) => Promise<void> };
|
|
248
|
-
} {
|
|
249
|
-
const { inject = true, watch = DEFAULT_WATCH, ...pageOptions } = options;
|
|
250
|
-
return {
|
|
251
|
-
name: '@occluze/tether',
|
|
252
|
-
hooks: {
|
|
253
|
-
'astro:config:setup': async (setup) => {
|
|
254
|
-
const { injectScript, command, config: astroConfig, logger, addWatchFile } = setup;
|
|
255
|
-
const { fileURLToPath } = await import('node:url');
|
|
256
|
-
const root = astroConfig.root ? fileURLToPath(astroConfig.root) : process.cwd();
|
|
257
|
-
if (command === 'dev' && addWatchFile) await watchConfigFiles(root, watch ?? [], addWatchFile);
|
|
258
|
-
await declareEnvSchema(setup);
|
|
259
|
-
const config: PageConfig = { ...pageOptions };
|
|
260
|
-
applyStoreEnv(config, await readEnv(command, root), logger);
|
|
261
|
-
dropFunctions(config, logger);
|
|
262
|
-
keepBrowserOptions(config, logger);
|
|
263
|
-
if (!config.domain || !config.token) {
|
|
264
|
-
logger.warn(
|
|
265
|
-
'No Shopify store configured: set PUBLIC_SHOPIFY_DOMAIN and PUBLIC_SHOPIFY_STOREFRONT_TOKEN in .env (the public Storefront API token).',
|
|
266
|
-
);
|
|
267
|
-
}
|
|
268
|
-
if (inject) injectTether(injectScript, config);
|
|
269
|
-
},
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
}
|
package/src/cart/cart-shape.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The structure of Tether's cart fragment, checked on a Storefront answer before it is used as a `ShopifyCart`. The
|
|
3
|
-
* cart's render, its totals and the cart API walk its lines and prices without checking each step. Text fields are
|
|
4
|
-
* written as text, so they need no check.
|
|
5
|
-
*/
|
|
6
|
-
import { MONEY } from '../product-shape.ts';
|
|
7
|
-
import type { Shape } from '../client.ts';
|
|
8
|
-
|
|
9
|
-
const LINE = {
|
|
10
|
-
id: 'string',
|
|
11
|
-
quantity: 'number',
|
|
12
|
-
attributes: ['object'],
|
|
13
|
-
cost: { totalAmount: MONEY, subtotalAmount: MONEY, amountPerQuantity: MONEY },
|
|
14
|
-
merchandise: { id: 'string', selectedOptions: ['object'], product: 'object' },
|
|
15
|
-
} as const satisfies Shape;
|
|
16
|
-
|
|
17
|
-
export const CART = {
|
|
18
|
-
id: 'string',
|
|
19
|
-
checkoutUrl: 'string',
|
|
20
|
-
totalQuantity: 'number',
|
|
21
|
-
attributes: ['object'],
|
|
22
|
-
discountCodes: ['object'],
|
|
23
|
-
cost: { totalAmount: MONEY, subtotalAmount: MONEY },
|
|
24
|
-
lines: { nodes: [LINE] },
|
|
25
|
-
} as const satisfies Shape;
|