@occluze/tether 0.4.0 → 0.5.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.
Files changed (42) hide show
  1. package/README.md +7 -5
  2. package/dist/esm/astro.js +2 -0
  3. package/dist/esm/chunks/chunk-HHMOPDZG.js +1 -0
  4. package/dist/esm/chunks/chunk-HXKV6QGB.js +4 -0
  5. package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-RBVRRQIU.js} +1 -1
  6. package/dist/esm/chunks/chunk-RNZMTQTX.js +1 -0
  7. package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
  8. package/dist/esm/chunks/diagnose-J667GHGS.js +1 -0
  9. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +1 -0
  10. package/dist/esm/index.js +1 -1
  11. package/dist/esm/server.js +1 -0
  12. package/dist/tether.diagnose.js +4 -3
  13. package/dist/tether.diagnose.min.js +1 -1
  14. package/dist/tether.js +229 -61
  15. package/dist/tether.markets.js +16 -10
  16. package/dist/tether.markets.min.js +1 -1
  17. package/dist/tether.min.js +3 -3
  18. package/dist/tether.min.js.map +3 -3
  19. package/package.json +27 -4
  20. package/src/astro.js +73 -0
  21. package/src/cart.js +47 -7
  22. package/src/client.js +25 -15
  23. package/src/config.js +2 -0
  24. package/src/diagnose.js +1 -1
  25. package/src/dom.js +32 -9
  26. package/src/fragments.js +12 -11
  27. package/src/index.js +18 -6
  28. package/src/initial.js +66 -0
  29. package/src/loader.js +49 -6
  30. package/src/markets-ui.js +13 -5
  31. package/src/markets.js +1 -1
  32. package/src/mounting.js +42 -2
  33. package/src/product.js +33 -9
  34. package/src/server.js +179 -0
  35. package/src/status.js +1 -6
  36. package/types/astro.d.ts +15 -0
  37. package/types/index.d.ts +17 -2
  38. package/types/server.d.ts +94 -0
  39. package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
  40. package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
  41. package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
  42. package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
package/dist/tether.js CHANGED
@@ -46,6 +46,8 @@
46
46
  sellingPlansFirst: 25,
47
47
  collectionFirst: 24,
48
48
  retries: 2,
49
+ revalidate: true,
50
+ // products from the server (tether/server hydrate()): refresh once in the background
49
51
  // Cart
50
52
  storageKey: null,
51
53
  // defaults to `tether:cart:<domain>`
@@ -63,6 +65,8 @@
63
65
  // mount document on DOMContentLoaded
64
66
  observe: false,
65
67
  // auto-mount nodes added later (CMS load-more, filters)
68
+ astro: true,
69
+ // mount on Astro View Transitions (astro:page-load); false if you do it yourself
66
70
  partsUrl: null,
67
71
  // folder of tether.markets/diagnose(.min).js for the <script> build (default: next to it)
68
72
  // Formatting
@@ -117,6 +121,7 @@
117
121
  TetherError: () => TetherError,
118
122
  endpoint: () => endpoint,
119
123
  gql: () => gql,
124
+ request: () => request,
120
125
  unwrap: () => unwrap,
121
126
  withContext: () => withContext
122
127
  });
@@ -130,39 +135,38 @@
130
135
  this.userErrors = userErrors;
131
136
  }
132
137
  };
133
- function endpoint() {
134
- return `https://${cfg.domain}/api/${cfg.apiVersion}/graphql.json`;
138
+ function endpoint(conf = cfg) {
139
+ return `https://${conf.domain}/api/${conf.apiVersion}/graphql.json`;
135
140
  }
136
141
  function withContext(query, ctx = cfg) {
137
142
  const args = [];
138
143
  if (ctx.country) args.push(`country: ${String(ctx.country).toUpperCase()}`);
139
144
  if (ctx.language) args.push(`language: ${String(ctx.language).toUpperCase().replace("-", "_")}`);
140
145
  if (!args.length || /@inContext/.test(query)) return query;
141
- return query.replace(
142
- /^(\s*(?:query|mutation)\s+\w+\s*(?:\([^)]*\))?)/,
143
- `$1 @inContext(${args.join(", ")})`
144
- );
146
+ const tag = `@inContext(${args.join(", ")})`;
147
+ if (/^\s*\{/.test(query)) return `query ${tag} ${query.trimStart()}`;
148
+ return query.replace(/^(\s*(?:query|mutation)(?:\s+\w+)?\s*(?:\([^)]*\))?)/, `$1 ${tag}`);
145
149
  }
146
150
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
147
- async function gql(query, variables = {}, { context = true } = {}) {
148
- if (!cfg.domain || !cfg.token) {
151
+ async function request(conf, query, variables = {}, { context = true } = {}) {
152
+ if (!conf.domain || !conf.token) {
149
153
  throw new TetherError("Tether is not configured: domain and token are required", { code: "NOT_CONFIGURED" });
150
154
  }
151
- const body = JSON.stringify({ query: context ? withContext(query) : query, variables });
155
+ const body = JSON.stringify({ query: context ? withContext(query, conf) : query, variables });
152
156
  let attempt = 0;
153
157
  for (; ; ) {
154
158
  let res;
155
159
  try {
156
- res = await fetch(endpoint(), {
160
+ res = await (conf.fetch || fetch)(endpoint(conf), {
157
161
  method: "POST",
158
162
  headers: {
159
163
  "Content-Type": "application/json",
160
- "X-Shopify-Storefront-Access-Token": cfg.token
164
+ "X-Shopify-Storefront-Access-Token": conf.token
161
165
  },
162
166
  body
163
167
  });
164
168
  } catch (err) {
165
- if (attempt++ < cfg.retries) {
169
+ if (attempt++ < conf.retries) {
166
170
  await sleep(300 * 2 ** attempt);
167
171
  continue;
168
172
  }
@@ -170,7 +174,7 @@
170
174
  }
171
175
  if (res.status === 430) throw new TetherError("Shopify security rejection (430)", { status: 430 });
172
176
  const retryable = res.status === 429 || res.status >= 500;
173
- if (retryable && attempt++ < cfg.retries) {
177
+ if (retryable && attempt++ < conf.retries) {
174
178
  log("retry", res.status, attempt);
175
179
  await sleep(300 * 2 ** attempt);
176
180
  continue;
@@ -185,10 +189,13 @@
185
189
  const inventoryDenied = json.errors.some((e) => /quantityAvailable/.test(e.message || ""));
186
190
  if (inventoryDenied && /\bquantityAvailable\b/.test(body)) {
187
191
  log("no inventory permission, retrying without quantityAvailable");
188
- return gql(query.replace(/\bquantityAvailable\b/g, ""), variables, { context });
192
+ return request(conf, query.replace(/\bquantityAvailable\b/g, ""), variables, { context });
193
+ }
194
+ if (json.errors.some((e) => /'warnings'/.test(e.message || "")) && /warnings \{ code message \}/.test(query)) {
195
+ return request(conf, query.replace(/ warnings \{ code message \}/g, ""), variables, { context });
189
196
  }
190
197
  const throttled = json.errors.some((e) => e.extensions?.code === "THROTTLED");
191
- if (throttled && attempt++ < cfg.retries) {
198
+ if (throttled && attempt++ < conf.retries) {
192
199
  await sleep(500 * 2 ** attempt);
193
200
  continue;
194
201
  }
@@ -201,6 +208,7 @@
201
208
  return json.data;
202
209
  }
203
210
  }
211
+ var gql = (query, variables, opts) => request(cfg, query, variables, opts);
204
212
  function unwrap(payload, name) {
205
213
  if (!payload) throw new TetherError(`${name}: empty response`);
206
214
  const errs = payload.userErrors || [];
@@ -230,8 +238,8 @@
230
238
  });
231
239
  var MONEY = "amount currencyCode";
232
240
  var IMAGE = "url altText width height";
233
- function metafieldIdentifiers() {
234
- return (cfg.metafields || []).map((m) => {
241
+ function metafieldIdentifiers(o = cfg) {
242
+ return (o.metafields || []).map((m) => {
235
243
  if (typeof m === "string") {
236
244
  const i = m.indexOf(".");
237
245
  return { namespace: m.slice(0, i), key: m.slice(i + 1) };
@@ -239,16 +247,16 @@
239
247
  return m;
240
248
  }).filter((m) => m.namespace && m.key);
241
249
  }
242
- function metafieldsField() {
243
- const ids = metafieldIdentifiers();
250
+ function metafieldsField(o = cfg) {
251
+ const ids = metafieldIdentifiers(o);
244
252
  if (!ids.length) return "";
245
253
  const list = ids.map((m) => `{namespace: ${JSON.stringify(m.namespace)}, key: ${JSON.stringify(m.key)}}`).join(", ");
246
254
  return `metafields(identifiers:[${list}]){namespace key type value reference{... on MediaImage{image{${IMAGE}}}}}`;
247
255
  }
248
256
  var SELLING_PLAN_FIELDS = ` id name description recurringDeliveries options{name value}priceAdjustments{orderCount adjustmentValue{__typename ... on SellingPlanPercentagePriceAdjustment{adjustmentPercentage}... on SellingPlanFixedAmountPriceAdjustment{adjustmentAmount{${MONEY}}}... on SellingPlanFixedPriceAdjustment{price{${MONEY}}}}}`;
249
257
  var ALLOCATION_FIELDS = ` sellingPlan{id}checkoutChargeAmount{${MONEY}}remainingBalanceChargeAmount{${MONEY}}priceAdjustments{price{${MONEY}}compareAtPrice{${MONEY}}perDeliveryPrice{${MONEY}}unitPrice{${MONEY}}}`;
250
- function productFragment() {
251
- return ` fragment TetherProduct on Product{id handle title description descriptionHtml productType vendor tags availableForSale publishedAt onlineStoreUrl requiresSellingPlan seo{title description}featuredImage{${IMAGE}}images(first:${cfg.imagesFirst}){nodes{${IMAGE}}}options{name optionValues{name swatch{color image{previewImage{${IMAGE}}}}}}priceRange{minVariantPrice{${MONEY}}maxVariantPrice{${MONEY}}}compareAtPriceRange{minVariantPrice{${MONEY}}maxVariantPrice{${MONEY}}}${metafieldsField()} sellingPlanGroups(first:${cfg.sellingPlanGroupsFirst}){nodes{name appName options{name values}sellingPlans(first:${cfg.sellingPlansFirst}){nodes{${SELLING_PLAN_FIELDS}}}}}variants(first:${cfg.variantsFirst}){nodes{id title availableForSale currentlyNotInStock quantityAvailable sku barcode selectedOptions{name value}image{${IMAGE}}price{${MONEY}}compareAtPrice{${MONEY}}unitPrice{${MONEY}}sellingPlanAllocations(first:${cfg.sellingPlansFirst}){nodes{${ALLOCATION_FIELDS}}}}}}`;
258
+ function productFragment(o = cfg) {
259
+ return ` fragment TetherProduct on Product{id handle title description descriptionHtml productType vendor tags availableForSale publishedAt onlineStoreUrl requiresSellingPlan seo{title description}featuredImage{${IMAGE}}images(first:${o.imagesFirst}){nodes{${IMAGE}}}options{name optionValues{name swatch{color image{previewImage{${IMAGE}}}}}}priceRange{minVariantPrice{${MONEY}}maxVariantPrice{${MONEY}}}compareAtPriceRange{minVariantPrice{${MONEY}}maxVariantPrice{${MONEY}}}${metafieldsField(o)} sellingPlanGroups(first:${o.sellingPlanGroupsFirst}){nodes{name appName options{name values}sellingPlans(first:${o.sellingPlansFirst}){nodes{${SELLING_PLAN_FIELDS}}}}}variants(first:${o.variantsFirst}){nodes{id title availableForSale currentlyNotInStock quantityAvailable sku barcode selectedOptions{name value}image{${IMAGE}}price{${MONEY}}compareAtPrice{${MONEY}}unitPrice{${MONEY}}sellingPlanAllocations(first:${o.sellingPlansFirst}){nodes{${ALLOCATION_FIELDS}}}}}}`;
252
260
  }
253
261
  function cartFragment() {
254
262
  return ` fragment TetherCart on Cart{id checkoutUrl totalQuantity note createdAt updatedAt attributes{key value}buyerIdentity{countryCode email}discountCodes{code applicable}cost{subtotalAmount{${MONEY}}totalAmount{${MONEY}}checkoutChargeAmount{${MONEY}}}lines(first:100){nodes{id quantity attributes{key value}cost{totalAmount{${MONEY}}subtotalAmount{${MONEY}}amountPerQuantity{${MONEY}}compareAtAmountPerQuantity{${MONEY}}}discountAllocations{discountedAmount{${MONEY}}}sellingPlanAllocation{sellingPlan{id name description recurringDeliveries options{name value}}checkoutChargeAmount{${MONEY}}priceAdjustments{price{${MONEY}}compareAtPrice{${MONEY}}perDeliveryPrice{${MONEY}}}}merchandise{... on ProductVariant{id title sku availableForSale requiresShipping image{${IMAGE}}price{${MONEY}}compareAtPrice{${MONEY}}selectedOptions{name value}product{id title handle vendor featuredImage{${IMAGE}}}}}... on ComponentizableCartLine{lineComponents{id quantity merchandise{... on ProductVariant{id title product{title handle}}}}}}}}`;
@@ -267,8 +275,11 @@
267
275
  getProduct: () => getProduct,
268
276
  getProducts: () => getProducts,
269
277
  getRecommendations: () => getRecommendations,
278
+ hydrateProducts: () => hydrateProducts,
270
279
  normalizeProductKey: () => normalizeProductKey,
271
- primeProduct: () => primeProduct
280
+ primeProduct: () => primeProduct,
281
+ refreshProducts: () => refreshProducts,
282
+ takeStale: () => takeStale
272
283
  });
273
284
  var productCache = /* @__PURE__ */ new Map();
274
285
  var pending = [];
@@ -343,8 +354,13 @@
343
354
  });
344
355
  keys.forEach((k) => settle(k, (r) => {
345
356
  if (got.has(k)) {
346
- if (gen === generation) primeProduct(got.get(k));
347
- r.resolve(got.get(k));
357
+ const p = got.get(k);
358
+ if (gen === generation) {
359
+ primeProduct(p);
360
+ freshAt.set(p.handle, Date.now());
361
+ freshAt.set(p.id, Date.now());
362
+ }
363
+ r.resolve(p);
348
364
  } else {
349
365
  if (gen === generation) productCache.delete(k);
350
366
  r.reject(new TetherError(`Product not found: ${k}`, { code: "NOT_FOUND" }));
@@ -360,6 +376,35 @@
360
376
  function clearProductCache() {
361
377
  productCache.clear();
362
378
  generation++;
379
+ stale.clear();
380
+ freshAt.clear();
381
+ }
382
+ var stale = /* @__PURE__ */ new Set();
383
+ var freshAt = /* @__PURE__ */ new Map();
384
+ var FRESH_MS = 6e4;
385
+ function hydrateProducts(products) {
386
+ (products || []).forEach((p) => {
387
+ if (!p?.id || !p.handle) return;
388
+ if (Date.now() - (freshAt.get(p.handle) || 0) < FRESH_MS && productCache.has(p.handle)) return;
389
+ primeProduct(p);
390
+ stale.add(p.handle);
391
+ stale.add(p.id);
392
+ });
393
+ }
394
+ function takeStale(key) {
395
+ const k = normalizeProductKey(key);
396
+ if (!stale.has(k)) return false;
397
+ const p = productCache.get(k);
398
+ stale.delete(k);
399
+ p?.then?.((x) => {
400
+ stale.delete(x.handle);
401
+ stale.delete(x.id);
402
+ });
403
+ return true;
404
+ }
405
+ function refreshProducts(keys) {
406
+ keys.forEach((k) => productCache.delete(normalizeProductKey(k)));
407
+ return keys.map((k) => getProduct(k));
363
408
  }
364
409
  async function getProducts({
365
410
  collection,
@@ -389,8 +434,10 @@
389
434
  data.products.nodes.forEach(primeProduct);
390
435
  return { collection: null, products: data.products.nodes, pageInfo: data.products.pageInfo };
391
436
  }
392
- async function getRecommendations(productId, intent = "RELATED") {
393
- const data = await gql(`query TetherRecs($id:ID!,$intent:ProductRecommendationIntent){productRecommendations(productId:$id,intent:$intent){...TetherProduct}}${productFragment()}`, { id: productId, intent });
437
+ async function getRecommendations(product, intent = "RELATED") {
438
+ const key = normalizeProductKey(typeof product === "object" ? product?.id : product);
439
+ const byId = key?.startsWith("gid://");
440
+ const data = await gql(`query TetherRecs(${byId ? "$id: ID!" : "$handle: String!"},$intent:ProductRecommendationIntent){productRecommendations(${byId ? "productId: $id" : "productHandle: $handle"},intent:$intent){...TetherProduct}}${productFragment()}`, byId ? { id: key, intent } : { handle: key, intent });
394
441
  const list = data.productRecommendations || [];
395
442
  list.forEach(primeProduct);
396
443
  return list;
@@ -492,6 +539,7 @@
492
539
  get: () => get,
493
540
  has: () => has,
494
541
  hasTemplate: () => hasTemplate,
542
+ imageUrl: () => imageUrl,
495
543
  notifyLayoutChanged: () => notifyLayoutChanged,
496
544
  parseJSONAttr: () => parseJSONAttr,
497
545
  productUrl: () => productUrl,
@@ -499,6 +547,7 @@
499
547
  readText: () => readText,
500
548
  repeat: () => repeat,
501
549
  repeatKeyed: () => repeatKeyed,
550
+ sameImage: () => sameImage,
502
551
  scoped: () => scoped,
503
552
  sel: () => sel,
504
553
  setImage: () => setImage,
@@ -547,25 +596,31 @@
547
596
  return "";
548
597
  }
549
598
  }
550
- function cdnUrl(url, el) {
551
- if (!url || !el) return url;
552
- const w = el.getAttribute("max-width");
553
- const h = el.getAttribute("max-height");
554
- const crop = el.getAttribute("crop");
555
- if (!w && !h) return url;
599
+ function imageUrl(url, { width, height, crop } = {}) {
600
+ if (!url) return url;
556
601
  try {
557
602
  const u = new URL(url);
558
- if (w) u.searchParams.set("width", w);
559
- if (h) u.searchParams.set("height", h);
603
+ if (width) u.searchParams.set("width", String(width));
604
+ if (height) u.searchParams.set("height", String(height));
560
605
  if (crop) u.searchParams.set("crop", crop);
561
606
  return u.toString();
562
607
  } catch {
563
608
  return url;
564
609
  }
565
610
  }
611
+ function cdnUrl(url, el) {
612
+ if (!url || !el) return url;
613
+ const g = (k) => el.getAttribute(k);
614
+ return imageUrl(url, { width: g("max-width"), height: g("max-height"), crop: g("max-width") || g("max-height") ? g("crop") : null });
615
+ }
616
+ var sameImage = (a, b) => Boolean(a && b) && a.split("?")[0] === b.split("?")[0];
566
617
  function setImage(el, url, alt) {
567
618
  if (!url) return;
568
619
  const src = cdnUrl(url, el);
620
+ if (el.tagName === "IMG" && sameImage(el.getAttribute("src"), src)) {
621
+ if (alt != null && !el.getAttribute("alt")) el.setAttribute("alt", alt);
622
+ return;
623
+ }
569
624
  if (el.tagName === "IMG") {
570
625
  el.removeAttribute("srcset");
571
626
  el.removeAttribute("sizes");
@@ -597,14 +652,21 @@
597
652
  function show(el, visible) {
598
653
  const k = "tetherPrevDisplay";
599
654
  if (visible) {
655
+ if (el.dataset.tetherShown !== void 0) return;
656
+ el.dataset.tetherShown = "";
657
+ el.removeAttribute("hidden");
600
658
  if (el.dataset[k] !== void 0) {
601
659
  el.style.display = el.dataset[k];
602
660
  delete el.dataset[k];
603
661
  } else if (el.style.display === "none") {
604
662
  el.style.display = "";
605
663
  }
664
+ if (typeof getComputedStyle === "function" && getComputedStyle(el).display === "none") {
665
+ el.style.display = el.getAttribute(`${cfg.prefix}-display`) || "block";
666
+ }
606
667
  } else {
607
- if (el.dataset[k] === void 0) el.dataset[k] = el.style.display || "";
668
+ delete el.dataset.tetherShown;
669
+ if (el.dataset[k] === void 0 && el.style.display !== "none") el.dataset[k] = el.style.display || "";
608
670
  el.style.display = "none";
609
671
  }
610
672
  }
@@ -752,7 +814,7 @@
752
814
  return line;
753
815
  }
754
816
  function mutation(name, args, inner) {
755
- return `mutation Tether_${name}(${args}){${name}(${inner}){cart{...TetherCart}userErrors{field message}}}${cartFragment()}`;
817
+ return `mutation Tether_${name}(${args}){${name}(${inner}){cart{...TetherCart}userErrors{field message}warnings{code message}}}${cartFragment()}`;
756
818
  }
757
819
  var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
758
820
  var returnFocus = null;
@@ -791,6 +853,16 @@
791
853
  return this.state?.totalQuantity || 0;
792
854
  },
793
855
  lastError: null,
856
+ /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
857
+ warnings: [],
858
+ /** true once the saved cart (or the absence of one) is known. */
859
+ loaded: false,
860
+ _take(payload, name) {
861
+ const p = unwrap(payload, name);
862
+ this.warnings = p.warnings || [];
863
+ if (this.warnings.length) emit("cart-warning", { warnings: this.warnings });
864
+ return p.cart;
865
+ },
794
866
  _queue(fn) {
795
867
  const run = this._chain.then(fn, fn).then((value) => {
796
868
  if (this.lastError) {
@@ -819,7 +891,9 @@
819
891
  const id = this.id();
820
892
  if (!id) {
821
893
  this.state = null;
894
+ this.loaded = true;
822
895
  this.render();
896
+ emit("cart-updated", { cart: null, reason: "load" });
823
897
  return null;
824
898
  }
825
899
  try {
@@ -834,7 +908,9 @@
834
908
  if (isMissingCart(err)) this._setId(null);
835
909
  this.state = null;
836
910
  }
911
+ this.loaded = true;
837
912
  this.render();
913
+ emit("cart-updated", { cart: this.state, reason: "load" });
838
914
  return this.state;
839
915
  },
840
916
  async _create(input = {}) {
@@ -843,7 +919,7 @@
843
919
  mutation("cartCreate", "$input: CartInput!", "input: $input"),
844
920
  { input: { buyerIdentity, ...input } }
845
921
  );
846
- return unwrap(data.cartCreate, "cartCreate").cart;
922
+ return this._take(data.cartCreate, "cartCreate");
847
923
  },
848
924
  /** Runs a cart mutation; creates a fresh cart if ours expired. */
849
925
  async _mutate(name, args, inner, variables, createInput) {
@@ -851,7 +927,7 @@
851
927
  if (!cartId) return createInput ? this._create(createInput) : null;
852
928
  try {
853
929
  const data = await gql(mutation(name, `$cartId: ID!, ${args}`, `cartId: $cartId, ${inner}`), { ...variables, cartId });
854
- return unwrap(data[name], name).cart;
930
+ return this._take(data[name], name);
855
931
  } catch (err) {
856
932
  if (createInput && isMissingCart(err)) {
857
933
  log("cart expired, creating a new one");
@@ -935,7 +1011,8 @@
935
1011
  return this.setDiscountCodes([.../* @__PURE__ */ new Set([...current, code])]);
936
1012
  },
937
1013
  removeDiscount(code) {
938
- return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c !== code));
1014
+ const k = String(code).toLowerCase();
1015
+ return this.setDiscountCodes((this.state?.discountCodes || []).map((d) => d.code).filter((c) => c.toLowerCase() !== k));
939
1016
  },
940
1017
  /** { countryCode, email, phone } — changes currency/prices for that market. */
941
1018
  setBuyerIdentity(buyerIdentity, { create = true } = {}) {
@@ -955,7 +1032,7 @@
955
1032
  */
956
1033
  async checkout(target = cfg.checkoutTarget) {
957
1034
  const id = this.id();
958
- if (!id) return null;
1035
+ if (!id || this.state && !this.count) return null;
959
1036
  const tab = target === "_blank" ? window.open("", "_blank") : null;
960
1037
  if (tab) {
961
1038
  try {
@@ -1021,6 +1098,17 @@
1021
1098
  if (this.isOpen()) this.close();
1022
1099
  else this.open();
1023
1100
  },
1101
+ /**
1102
+ * For framework islands (React, Svelte, Vue): fn(cart, { open }) now and on every change.
1103
+ * Returns a function that stops listening.
1104
+ */
1105
+ subscribe(fn) {
1106
+ const call = () => fn(this.state, { open: this.isOpen(), count: this.count, loaded: this.loaded, warnings: this.warnings });
1107
+ const h = () => call();
1108
+ ["cart-updated", "cart-toggled"].forEach((e) => document.addEventListener(`${cfg.prefix}:${e}`, h));
1109
+ call();
1110
+ return () => ["cart-updated", "cart-toggled"].forEach((e) => document.removeEventListener(`${cfg.prefix}:${e}`, h));
1111
+ },
1024
1112
  // -------------------------------------------------------------- render
1025
1113
  render() {
1026
1114
  const cart = this.state;
@@ -1038,12 +1126,18 @@
1038
1126
  "cart-has-subscription": hasSub,
1039
1127
  "cart-no-subscription": !hasSub,
1040
1128
  "cart-has-discount": (cart?.discountCodes || []).some((d) => d.applicable),
1041
- "cart-error": Boolean(this.lastError)
1129
+ "cart-error": Boolean(this.lastError),
1130
+ "cart-warning": this.warnings.length > 0
1042
1131
  };
1132
+ qsa(document, sel("cart-warning")).forEach((el) => setTextTracked(el, [...new Set(this.warnings.map((w) => w.message))].join(" ")));
1043
1133
  qsa(document, sel("show")).forEach((el) => {
1044
1134
  const k = get(el, "show");
1045
1135
  if (k in conds && !el.closest(sel("product"))) show(el, conds[k]);
1046
1136
  });
1137
+ qsa(document, sel("checkout")).forEach((el) => {
1138
+ el.classList.toggle("is-disabled", !count);
1139
+ el.setAttribute("aria-disabled", String(!count));
1140
+ });
1047
1141
  qsa(document, `textarea${sel("cart-note")},input${sel("cart-note")}`).forEach((el) => {
1048
1142
  if (document.activeElement !== el) el.value = cart?.note || "";
1049
1143
  });
@@ -1112,7 +1206,15 @@
1112
1206
  qsa(node, sel("line")).forEach((el) => {
1113
1207
  const key = get(el, "line");
1114
1208
  if (key === "image") return setImage(el, m.image?.url || m.product?.featuredImage?.url, m.product?.title);
1115
- if (key === "url") return el.setAttribute("href", productUrl(m.product?.handle, m.product?.id));
1209
+ if (key === "url") {
1210
+ const u = productUrl(m.product?.handle, m.product?.id);
1211
+ const q = new URLSearchParams();
1212
+ if (m.id) q.set("variant", String(m.id).split("/").pop());
1213
+ const plan = line.sellingPlanAllocation?.sellingPlan?.id;
1214
+ if (plan) q.set("selling_plan", plan.split("/").pop());
1215
+ const qs = q.toString();
1216
+ return el.setAttribute("href", qs ? `${u}${u.includes("?") ? "&" : "?"}${qs}` : u);
1217
+ }
1116
1218
  const v = lineValue(line, key);
1117
1219
  if (v !== void 0) setTextTracked(el, v);
1118
1220
  return void 0;
@@ -1158,6 +1260,7 @@
1158
1260
  var product_exports = {};
1159
1261
  __export(product_exports, {
1160
1262
  ProductScope: () => ProductScope,
1263
+ rememberOptionNames: () => rememberOptionNames,
1161
1264
  scopes: () => scopes
1162
1265
  });
1163
1266
 
@@ -1204,11 +1307,7 @@
1204
1307
  scoped(root, sel("show"), boundary()).forEach((el) => {
1205
1308
  const k = get(el, "show");
1206
1309
  if (!STATUS_KEYS.includes(k)) return;
1207
- const visible = k === status;
1208
- show(el, visible);
1209
- if (visible && typeof getComputedStyle === "function" && getComputedStyle(el).display === "none") {
1210
- el.style.display = el.getAttribute(attr("display")) || "block";
1211
- }
1310
+ show(el, k === status);
1212
1311
  });
1213
1312
  if (status === "error" || notFound) {
1214
1313
  scoped(root, sel("add-to-cart"), boundary()).forEach((btn) => {
@@ -1229,6 +1328,12 @@
1229
1328
  const mf = (product.metafields || []).find((m) => m && m.namespace === ns && m.key === key);
1230
1329
  return mf || null;
1231
1330
  }
1331
+ var knownOptionNames = /* @__PURE__ */ new Map();
1332
+ function rememberOptionNames(products) {
1333
+ (products || []).forEach((p) => {
1334
+ if (p?.handle && !knownOptionNames.has(p.handle)) knownOptionNames.set(p.handle, (p.options || []).map((o) => o.name));
1335
+ });
1336
+ }
1232
1337
  var ProductScope = class {
1233
1338
  constructor(root, product) {
1234
1339
  this.root = root;
@@ -1278,6 +1383,13 @@
1278
1383
  return this.root.matches(s) ? [this.root, ...inner] : inner;
1279
1384
  }
1280
1385
  // ------------------------------------------------------------ state
1386
+ /** The product's option name for a name used in the markup (same name, or same position). */
1387
+ optName(authored) {
1388
+ const opts = this.product?.options || [];
1389
+ if (!authored || opts.some((o) => o.name === authored)) return authored;
1390
+ const i = (knownOptionNames.get(this.product.handle) || []).indexOf(authored);
1391
+ return i >= 0 && opts[i] ? opts[i].name : authored;
1392
+ }
1281
1393
  initSelection() {
1282
1394
  const vs = this.product.variants.nodes;
1283
1395
  const first = vs.find((v) => v.availableForSale) || vs[0];
@@ -1394,7 +1506,7 @@
1394
1506
  const opt = t.closest(sel("option-value"));
1395
1507
  if (own(opt) && opt.closest(sel("option-list"))) {
1396
1508
  e.preventDefault();
1397
- this.select(get(opt.closest(sel("option-list")), "option-list"), get(opt, "option-value"));
1509
+ this.select(this.optName(get(opt.closest(sel("option-list")), "option-list")), get(opt, "option-value"));
1398
1510
  return;
1399
1511
  }
1400
1512
  const plan = t.closest(sel("plan-value"));
@@ -1430,7 +1542,7 @@
1430
1542
  this.root.addEventListener("change", (e) => {
1431
1543
  const t = e.target;
1432
1544
  if (!own(t)) return;
1433
- if (t.matches(`select${sel("option")}`)) this.select(get(t, "option"), t.value);
1545
+ if (t.matches(`select${sel("option")}`)) this.select(this.optName(get(t, "option")), t.value);
1434
1546
  else if (t.matches(`select${sel("variant-select")}`)) this.selectVariant(t.value);
1435
1547
  else if (t.matches(`select${sel("plan")}`)) {
1436
1548
  if (t.value) this.selectPlan(t.value);
@@ -1460,6 +1572,11 @@
1460
1572
  }
1461
1573
  // ------------------------------------------------------------ render
1462
1574
  renderProduct() {
1575
+ const p0 = this.product;
1576
+ if (!knownOptionNames.has(p0.handle)) {
1577
+ const authored = this.$(`${sel("option-list")},select${sel("option")}`).map((e) => get(e, "option-list") || get(e, "option"));
1578
+ if (authored.some((n) => p0.options.some((o) => o.name === n))) rememberOptionNames([p0]);
1579
+ }
1463
1580
  const p = this.product;
1464
1581
  this.$(sel("bind")).forEach((el) => {
1465
1582
  const key = get(el, "bind") || "";
@@ -1489,6 +1606,10 @@
1489
1606
  if (key === "url" && !el.firstElementChild && !el.textContent.trim()) setText(el, p.title);
1490
1607
  return void 0;
1491
1608
  case "images":
1609
+ {
1610
+ const imgs = [...el.children].map((n) => n.tagName === "IMG" ? n : n.querySelector("img"));
1611
+ if (!hasTemplate(el) && imgs.length === p.images.nodes.length && imgs.every((im, i) => sameImage(im?.getAttribute("src"), p.images.nodes[i].url))) return void 0;
1612
+ }
1492
1613
  return repeat(el, p.images.nodes, (node, img) => {
1493
1614
  const target = node.matches(`img,${sel("bind", "image")}`) ? node : node.querySelector("img") || node;
1494
1615
  setImage(target, img.url, img.altText || p.title);
@@ -1513,7 +1634,7 @@
1513
1634
  }
1514
1635
  });
1515
1636
  this.$(sel("option-list")).forEach((container) => {
1516
- const name = get(container, "option-list");
1637
+ const name = this.optName(get(container, "option-list"));
1517
1638
  const opt = p.options.find((o) => o.name === name);
1518
1639
  if (!opt || opt.optionValues.length <= 1 && has(container, "hide-single")) return show(container, false);
1519
1640
  return repeat(container, opt.optionValues, (node, v) => {
@@ -1527,7 +1648,7 @@
1527
1648
  });
1528
1649
  });
1529
1650
  this.$(`select${sel("option")}`).forEach((s) => {
1530
- const opt = p.options.find((o) => o.name === get(s, "option"));
1651
+ const opt = p.options.find((o) => o.name === this.optName(get(s, "option")));
1531
1652
  if (!opt) return show(s, false);
1532
1653
  s.innerHTML = "";
1533
1654
  opt.optionValues.forEach((v) => s.appendChild(new Option(v.name, v.name)));
@@ -1611,9 +1732,9 @@
1611
1732
  if (k === "variant-image") setImage(el, v.image?.url || p.featuredImage?.url, v.image?.altText || p.title);
1612
1733
  else if (text[k]) setText(el, text[k]());
1613
1734
  });
1614
- this.$(sel("option-selected")).forEach((el) => setText(el, this.selected[get(el, "option-selected")] || ""));
1735
+ this.$(sel("option-selected")).forEach((el) => setText(el, this.selected[this.optName(get(el, "option-selected"))] || ""));
1615
1736
  this.$(sel("option-list")).forEach((container) => {
1616
- const name = get(container, "option-list");
1737
+ const name = this.optName(get(container, "option-list"));
1617
1738
  qsa(container, sel("option-value")).forEach((node) => {
1618
1739
  const value = get(node, "option-value");
1619
1740
  setSelected(node, this.selected[name] === value);
@@ -1623,7 +1744,7 @@
1623
1744
  });
1624
1745
  });
1625
1746
  this.$(`select${sel("option")}`).forEach((s) => {
1626
- s.value = this.selected[get(s, "option")] || "";
1747
+ s.value = this.selected[this.optName(get(s, "option"))] || "";
1627
1748
  });
1628
1749
  this.$(`select${sel("variant-select")}`).forEach((s) => {
1629
1750
  s.value = v.id;
@@ -1756,18 +1877,18 @@
1756
1877
  }
1757
1878
  async load(more = false) {
1758
1879
  const token = this.loadToken = (this.loadToken || 0) + 1;
1759
- const stale = () => token !== this.loadToken;
1880
+ const stale2 = () => token !== this.loadToken;
1760
1881
  try {
1761
1882
  if (has(this.root, "recommendations")) {
1762
1883
  const key = get(this.root, "recommendations");
1763
1884
  const base = await getProduct(key);
1764
1885
  const recs = await getRecommendations(base.id, get(this.root, "intent") || "RELATED");
1765
- if (stale()) return;
1886
+ if (stale2()) return;
1766
1887
  const limit = parseInt(get(this.root, "limit") || "", 10);
1767
1888
  this.products = limit ? recs.slice(0, limit) : recs;
1768
1889
  } else {
1769
1890
  const res = await getProducts({ ...this.options(), after: more ? this.pageInfo?.endCursor : null });
1770
- if (stale()) return;
1891
+ if (stale2()) return;
1771
1892
  this.collection = res.collection;
1772
1893
  this.pageInfo = res.pageInfo;
1773
1894
  this.products = more ? [...this.products, ...res.products] : res.products;
@@ -1776,7 +1897,7 @@
1776
1897
  setScopeStatus(this.root, "ready");
1777
1898
  emit("list-ready", { products: this.products, collection: this.collection }, this.root);
1778
1899
  } catch (err) {
1779
- if (stale()) return;
1900
+ if (stale2()) return;
1780
1901
  if (!more) setScopeStatus(this.root, isNotFound(err) ? "not-found" : "error", err);
1781
1902
  reportError(err, "list load failed");
1782
1903
  emit("error", { error: err, context: "list" }, this.root);
@@ -1822,6 +1943,7 @@
1822
1943
  // src/mounting.js
1823
1944
  var mounting_exports = {};
1824
1945
  __export(mounting_exports, {
1946
+ hydrateFromPage: () => hydrateFromPage,
1825
1947
  liveLists: () => liveLists,
1826
1948
  liveProducts: () => liveProducts,
1827
1949
  mountProduct: () => mountProduct,
@@ -1873,6 +1995,7 @@
1873
1995
  if (existing) existing.update(p);
1874
1996
  else new ProductScope(el, p);
1875
1997
  applyPageTitle(el, p);
1998
+ if (cfg.revalidate !== false && takeStale(key)) queueRefresh(key);
1876
1999
  }).catch((err) => {
1877
2000
  if (!current()) return;
1878
2001
  const notFound = isNotFound(err);
@@ -1901,6 +2024,40 @@
1901
2024
  mountProduct(el);
1902
2025
  });
1903
2026
  }
2027
+ var refreshQueue = /* @__PURE__ */ new Set();
2028
+ function queueRefresh(key) {
2029
+ if (!refreshQueue.size) setTimeout(flushRefresh, 0);
2030
+ refreshQueue.add(key);
2031
+ }
2032
+ function flushRefresh() {
2033
+ const keys = [...refreshQueue];
2034
+ refreshQueue = /* @__PURE__ */ new Set();
2035
+ refreshProducts(keys).forEach((promise, i) => promise.then((fresh) => {
2036
+ liveProducts.forEach((el) => {
2037
+ const scope = scopes.get(el);
2038
+ if (!scope || !el.isConnected) return;
2039
+ const k = resolveProductKey(el).key;
2040
+ if (k !== keys[i] && k !== fresh.handle && k !== fresh.id) return;
2041
+ if (JSON.stringify(scope.product) !== JSON.stringify(fresh)) scope.update(fresh);
2042
+ });
2043
+ }).catch((err) => reportError(err, `refresh ${keys[i]}`)));
2044
+ }
2045
+ var readScripts = /* @__PURE__ */ new WeakSet();
2046
+ function hydrateFromPage(root = document) {
2047
+ qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
2048
+ if (readScripts.has(s)) return;
2049
+ readScripts.add(s);
2050
+ try {
2051
+ const data = JSON.parse(s.textContent);
2052
+ rememberOptionNames(data.products);
2053
+ const same = (a, b) => String(a || "").toUpperCase() === String(b || "").toUpperCase();
2054
+ if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
2055
+ else log("hydration skipped: other market");
2056
+ } catch (err) {
2057
+ warn("bad data-tether-hydrate", err);
2058
+ }
2059
+ });
2060
+ }
1904
2061
 
1905
2062
  // src/lazy-iife.js
1906
2063
  var { cfg: cfg2 } = config_exports;
@@ -1939,13 +2096,14 @@
1939
2096
  }
1940
2097
 
1941
2098
  // src/index.js
1942
- var VERSION = "0.4.0";
2099
+ var VERSION = "0.5.0";
1943
2100
  var initPromise = null;
1944
2101
  var observer = null;
1945
2102
  function mount(root = document) {
1946
2103
  if (!isConfigured()) return 0;
1947
2104
  let count = 0;
1948
2105
  pruneLive();
2106
+ hydrateFromPage(root);
1949
2107
  within(root, listSelector()).forEach((el) => {
1950
2108
  if (mountedLists.has(el)) return;
1951
2109
  mountedLists.add(el);
@@ -2055,6 +2213,8 @@
2055
2213
  try {
2056
2214
  const cart = await Cart.applyDiscount(code);
2057
2215
  const ok = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === code.toLowerCase())?.applicable;
2216
+ if (!ok) await Cart.removeDiscount(code).catch(() => {
2217
+ });
2058
2218
  form.classList.toggle("is-invalid", !ok);
2059
2219
  emit(ok ? "discount-applied" : "discount-invalid", { code }, form);
2060
2220
  if (ok && input) input.value = "";
@@ -2063,7 +2223,14 @@
2063
2223
  reportError(err, "discount");
2064
2224
  }
2065
2225
  });
2226
+ }, true);
2227
+ document.addEventListener("input", (e) => {
2228
+ e.target.closest?.(sel("discount-form"))?.classList.remove("is-invalid");
2066
2229
  });
2230
+ if (cfg.astro !== false) {
2231
+ document.addEventListener("astro:page-load", () => mount(document));
2232
+ document.addEventListener("astro:before-swap", () => unmount());
2233
+ }
2067
2234
  window.addEventListener("online", () => {
2068
2235
  retryFailed();
2069
2236
  liveLists.forEach((l) => {
@@ -2160,7 +2327,7 @@
2160
2327
  if (!market) return;
2161
2328
  if (market.country) cfg.country = market.country;
2162
2329
  if (market.language) cfg.language = market.language;
2163
- if (market.locale && !overrides.locale && !window.TETHER_CONFIG?.locale) cfg.locale = market.locale;
2330
+ if (market.locale && !overrides.locale) cfg.locale = market.locale;
2164
2331
  log("market", market);
2165
2332
  }
2166
2333
  function setCountry(code, opts) {
@@ -2207,6 +2374,7 @@
2207
2374
  normalizeProductKey,
2208
2375
  clearProductCache,
2209
2376
  formatMoney,
2377
+ imageUrl,
2210
2378
  plans: selling_plans_exports,
2211
2379
  TetherError,
2212
2380
  resolveProductKey,
@@ -2225,7 +2393,7 @@
2225
2393
  var index_default = api;
2226
2394
  if (typeof window !== "undefined" && typeof document !== "undefined") {
2227
2395
  window.Tether = api;
2228
- const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false;
2396
+ const auto = window.TETHER_CONFIG && window.TETHER_CONFIG.autoMount !== false && !window.TETHER_INTEGRATION;
2229
2397
  if (auto) {
2230
2398
  const start = () => init().then(() => mount(document));
2231
2399
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", start, { once: true });