@occluze/tether 0.5.0 → 0.6.1

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 (66) hide show
  1. package/README.md +9 -7
  2. package/dist/esm/astro.js +1 -1
  3. package/dist/esm/chunks/account-JBZJN7X7.js +1 -0
  4. package/dist/esm/chunks/analytics-22NMIVBV.js +1 -0
  5. package/dist/esm/chunks/chunk-4UR45XTC.js +1 -0
  6. package/dist/esm/chunks/chunk-6R752OYZ.js +1 -0
  7. package/dist/esm/chunks/chunk-BGIKQY6H.js +1 -0
  8. package/dist/esm/chunks/chunk-CISGFYOF.js +1 -0
  9. package/dist/esm/chunks/chunk-KARKXC6G.js +1 -0
  10. package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
  11. package/dist/esm/chunks/{chunk-RBVRRQIU.js → chunk-YZMBXS6Y.js} +1 -1
  12. package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
  13. package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
  14. package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
  15. package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
  16. package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
  17. package/dist/esm/index.js +1 -1
  18. package/dist/esm/server.js +1 -1
  19. package/dist/tether.account.js +380 -0
  20. package/dist/tether.account.min.js +1 -0
  21. package/dist/tether.analytics.js +259 -0
  22. package/dist/tether.analytics.min.js +1 -0
  23. package/dist/tether.diagnose.js +11 -2
  24. package/dist/tether.diagnose.min.js +1 -1
  25. package/dist/tether.extras.js +87 -0
  26. package/dist/tether.extras.min.js +1 -0
  27. package/dist/tether.js +268 -75
  28. package/dist/tether.markets.js +3 -3
  29. package/dist/tether.markets.min.js +1 -1
  30. package/dist/tether.min.js +3 -3
  31. package/dist/tether.min.js.map +3 -3
  32. package/dist/tether.search.js +176 -0
  33. package/dist/tether.search.min.js +1 -0
  34. package/package.json +5 -2
  35. package/src/account.js +313 -0
  36. package/src/analytics.js +232 -0
  37. package/src/astro.js +4 -1
  38. package/src/cart.js +74 -11
  39. package/src/client.js +8 -3
  40. package/src/collection.js +15 -10
  41. package/src/config.js +3 -0
  42. package/src/diagnose.js +11 -2
  43. package/src/dom.js +7 -1
  44. package/src/extras.js +63 -0
  45. package/src/fragments.js +1 -1
  46. package/src/index.js +103 -41
  47. package/src/initial.js +13 -4
  48. package/src/lazy.js +9 -1
  49. package/src/loader.js +4 -1
  50. package/src/markets-ui.js +2 -1
  51. package/src/mounting.js +27 -2
  52. package/src/parts/account.js +3 -0
  53. package/src/parts/analytics.js +3 -0
  54. package/src/parts/extras.js +3 -0
  55. package/src/parts/search.js +3 -0
  56. package/src/product.js +19 -5
  57. package/src/search.js +153 -0
  58. package/src/server.js +78 -6
  59. package/src/status.js +2 -2
  60. package/types/index.d.ts +56 -3
  61. package/types/server.d.ts +10 -2
  62. package/dist/esm/chunks/chunk-HHMOPDZG.js +0 -1
  63. package/dist/esm/chunks/chunk-HXKV6QGB.js +0 -4
  64. package/dist/esm/chunks/chunk-RNZMTQTX.js +0 -1
  65. package/dist/esm/chunks/diagnose-J667GHGS.js +0 -1
  66. package/dist/esm/chunks/markets-ui-EGWIEUOZ.js +0 -1
package/dist/tether.js CHANGED
@@ -60,6 +60,12 @@
60
60
  // Subscriptions
61
61
  defaultPurchase: "one-time",
62
62
  // 'one-time' | 'subscription'
63
+ variantUrl: false,
64
+ // true: ?variant= follows the visitor's choice on the main product (shareable links)
65
+ maxAge: 60,
66
+ // seconds a Shopify answer may be cached (0 = Shopify's own caching, can be hours)
67
+ lowStock: 5,
68
+ // tether-show="low-stock" at this many or fewer (needs inventory access)
63
69
  // Mounting
64
70
  autoMount: true,
65
71
  // mount document on DOMContentLoaded
@@ -152,7 +158,10 @@
152
158
  if (!conf.domain || !conf.token) {
153
159
  throw new TetherError("Tether is not configured: domain and token are required", { code: "NOT_CONFIGURED" });
154
160
  }
155
- const body = JSON.stringify({ query: context ? withContext(query, conf) : query, variables });
161
+ let text = context ? withContext(query, conf) : query;
162
+ const age = conf.maxAge ?? 60;
163
+ if (age > 0 && !/^\s*mutation\b/.test(text)) text += ` #${Math.floor(Date.now() / (age * 1e3))}`;
164
+ const body = JSON.stringify({ query: text, variables });
156
165
  let attempt = 0;
157
166
  for (; ; ) {
158
167
  let res;
@@ -191,8 +200,8 @@
191
200
  log("no inventory permission, retrying without quantityAvailable");
192
201
  return request(conf, query.replace(/\bquantityAvailable\b/g, ""), variables, { context });
193
202
  }
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 });
203
+ if (json.errors.some((e) => /'warnings'/.test(e.message || "")) && /warnings \{ code message target \}/.test(query)) {
204
+ return request(conf, query.replace(/ warnings \{ code message target \}/g, ""), variables, { context });
196
205
  }
197
206
  const throttled = json.errors.some((e) => e.extensions?.code === "THROTTLED");
198
207
  if (throttled && attempt++ < conf.retries) {
@@ -259,7 +268,7 @@
259
268
  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}}}}}}`;
260
269
  }
261
270
  function cartFragment() {
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}}}}}}}}`;
271
+ 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 quantityAvailable 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}}}}}}}}`;
263
272
  }
264
273
  function collectionFields() {
265
274
  return `
@@ -362,7 +371,10 @@
362
371
  }
363
372
  r.resolve(p);
364
373
  } else {
365
- if (gen === generation) productCache.delete(k);
374
+ const miss = productCache.get(k);
375
+ if (gen === generation) setTimeout(() => {
376
+ if (productCache.get(k) === miss) productCache.delete(k);
377
+ }, 1e4);
366
378
  r.reject(new TetherError(`Product not found: ${k}`, { code: "NOT_FOUND" }));
367
379
  }
368
380
  }));
@@ -536,6 +548,7 @@
536
548
  emit: () => emit,
537
549
  formatDate: () => formatDate,
538
550
  formatMoney: () => formatMoney,
551
+ formatPercent: () => formatPercent,
539
552
  get: () => get,
540
553
  has: () => has,
541
554
  hasTemplate: () => hasTemplate,
@@ -574,6 +587,14 @@
574
587
  function emit(name, detail, target = document) {
575
588
  target.dispatchEvent(new CustomEvent(`${cfg.prefix}:${name}`, { detail, bubbles: true }));
576
589
  }
590
+ function formatPercent(n, locale = cfg.locale) {
591
+ if (!n) return "";
592
+ try {
593
+ return new Intl.NumberFormat(locale || void 0, { style: "percent", maximumFractionDigits: 0 }).format(n / 100);
594
+ } catch {
595
+ return `${n}%`;
596
+ }
597
+ }
577
598
  function formatMoney(money) {
578
599
  if (!money) return "";
579
600
  if (typeof cfg.formatMoney === "function") return cfg.formatMoney(money, cfg.locale);
@@ -690,7 +711,7 @@
690
711
  function repeat(container, items, render) {
691
712
  const tpl = template(container);
692
713
  if (!tpl) {
693
- warn("repeater without a template child", container);
714
+ if (items.length) warn("repeater without a template child", container);
694
715
  return;
695
716
  }
696
717
  container.innerHTML = "";
@@ -814,7 +835,7 @@
814
835
  return line;
815
836
  }
816
837
  function mutation(name, args, inner) {
817
- return `mutation Tether_${name}(${args}){${name}(${inner}){cart{...TetherCart}userErrors{field message}warnings{code message}}}${cartFragment()}`;
838
+ return `mutation Tether_${name}(${args}){${name}(${inner}){cart{...TetherCart}userErrors{field message}warnings{code message target}}}${cartFragment()}`;
818
839
  }
819
840
  var FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
820
841
  var returnFocus = null;
@@ -825,7 +846,10 @@
825
846
  if (!d.hasAttribute("role")) d.setAttribute("role", "dialog");
826
847
  if (!d.hasAttribute("aria-label") && !d.hasAttribute("aria-labelledby")) d.setAttribute("aria-label", get(d, "cart-label") || "Cart");
827
848
  d.setAttribute("aria-modal", String(open));
828
- if (!d.classList.contains(cfg.cartDrawerOpenClass)) d.setAttribute("aria-hidden", "true");
849
+ if (!d.classList.contains(cfg.cartDrawerOpenClass)) {
850
+ d.setAttribute("aria-hidden", "true");
851
+ if (cfg.cartFocus !== false) d.inert = true;
852
+ }
829
853
  });
830
854
  }
831
855
  var isMissingCart = (err) => /does not exist|not found|invalid id/i.test(err?.message || "");
@@ -853,13 +877,17 @@
853
877
  return this.state?.totalQuantity || 0;
854
878
  },
855
879
  lastError: null,
880
+ /** Set by the account part while a customer is logged in (Customer Account API token). */
881
+ customerAccessToken: null,
882
+ /** line id -> { qty, done }: what quick +/- clicks asked for, shown until Shopify has answered. */
883
+ pendingQty: /* @__PURE__ */ new Map(),
856
884
  /** Shopify's notes on the last change, e.g. less stock than asked: [{ code, message }]. */
857
885
  warnings: [],
858
886
  /** true once the saved cart (or the absence of one) is known. */
859
887
  loaded: false,
860
888
  _take(payload, name) {
861
889
  const p = unwrap(payload, name);
862
- this.warnings = p.warnings || [];
890
+ this.warnings = (p.warnings || []).filter((w, i, all) => all.findIndex((x) => x.message === w.message) === i).map((w) => ({ ...w, message: typeof cfg.warningText === "function" && cfg.warningText(w) || w.message }));
863
891
  if (this.warnings.length) emit("cart-warning", { warnings: this.warnings });
864
892
  return p.cart;
865
893
  },
@@ -883,6 +911,10 @@
883
911
  _commit(cart, reason) {
884
912
  this.state = cart;
885
913
  if (cart?.id) this._setId(cart.id);
914
+ try {
915
+ localStorage.setItem(`${cfg.storageKey}:seen`, `${Date.now()}|${cart?.totalQuantity || 0}`);
916
+ } catch {
917
+ }
886
918
  this.render();
887
919
  emit("cart-updated", { cart, reason });
888
920
  return cart;
@@ -896,6 +928,15 @@
896
928
  emit("cart-updated", { cart: null, reason: "load" });
897
929
  return null;
898
930
  }
931
+ if (!this.loaded) {
932
+ try {
933
+ const n = (localStorage.getItem(`${cfg.storageKey}:seen`) || "").split("|")[1];
934
+ if (n) qsa(document, sel("cart-count")).forEach((el) => {
935
+ if (/^\s*0?\s*$/.test(el.textContent)) el.textContent = n;
936
+ });
937
+ } catch {
938
+ }
939
+ }
899
940
  try {
900
941
  const data = await gql(`query TetherCart($id:ID!){cart(id:$id){...TetherCart}}${cartFragment()}`, { id });
901
942
  if (!data.cart) {
@@ -914,7 +955,10 @@
914
955
  return this.state;
915
956
  },
916
957
  async _create(input = {}) {
917
- const buyerIdentity = cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : void 0;
958
+ const buyerIdentity = cfg.country || this.customerAccessToken ? {
959
+ ...cfg.country ? { countryCode: String(cfg.country).toUpperCase() } : {},
960
+ ...this.customerAccessToken ? { customerAccessToken: this.customerAccessToken } : {}
961
+ } : void 0;
918
962
  const data = await gql(
919
963
  mutation("cartCreate", "$input: CartInput!", "input: $input"),
920
964
  { input: { buyerIdentity, ...input } }
@@ -970,6 +1014,30 @@
970
1014
  "update"
971
1015
  ));
972
1016
  },
1017
+ /**
1018
+ * +1 / -1 for a line. Quick clicks add up: each builds on the quantity the last click asked for,
1019
+ * which shows at once (tether-line="quantity", Cart.lines) until Shopify has answered.
1020
+ */
1021
+ changeQuantity(lineId, delta) {
1022
+ const current = this.lines.find((l) => l.id === lineId);
1023
+ if (!current) return Promise.resolve(this.state);
1024
+ const target = Math.max(0, (this.pendingQty.get(lineId)?.qty ?? current.quantity) + delta);
1025
+ const job = { qty: target, done: false };
1026
+ this.pendingQty.set(lineId, job);
1027
+ this.render();
1028
+ emit("cart-updated", { cart: this.state, reason: "pending" });
1029
+ return this.updateLine(lineId, target).finally(() => {
1030
+ if (this.pendingQty.get(lineId) === job) {
1031
+ this.pendingQty.delete(lineId);
1032
+ this.render();
1033
+ emit("cart-updated", { cart: this.state, reason: "settled" });
1034
+ }
1035
+ });
1036
+ },
1037
+ /** Quantity to show for a line: the one asked for while an update is on its way. */
1038
+ quantityOf(line) {
1039
+ return this.pendingQty.get(line.id)?.qty ?? line.quantity;
1040
+ },
973
1041
  removeLine(lineIds) {
974
1042
  const ids = Array.isArray(lineIds) ? lineIds : [lineIds];
975
1043
  return this._queue(async () => this._commit(
@@ -1006,9 +1074,18 @@
1006
1074
  "discount"
1007
1075
  ));
1008
1076
  },
1009
- applyDiscount(code) {
1077
+ /**
1078
+ * Adds a code. A code Shopify doesn't accept isn't kept in the cart (it would warn on every change):
1079
+ * it's removed again and the promise rejects with code INVALID_DISCOUNT.
1080
+ */
1081
+ async applyDiscount(code) {
1010
1082
  const current = (this.state?.discountCodes || []).map((d) => d.code);
1011
- return this.setDiscountCodes([.../* @__PURE__ */ new Set([...current, code])]);
1083
+ const cart = await this.setDiscountCodes([.../* @__PURE__ */ new Set([...current, code])]);
1084
+ const hit = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === String(code).toLowerCase());
1085
+ if (hit?.applicable) return cart;
1086
+ await this.removeDiscount(code).catch(() => {
1087
+ });
1088
+ throw new TetherError(`Discount code not valid: ${code}`, { code: "INVALID_DISCOUNT" });
1012
1089
  },
1013
1090
  removeDiscount(code) {
1014
1091
  const k = String(code).toLowerCase();
@@ -1042,7 +1119,8 @@
1042
1119
  }
1043
1120
  try {
1044
1121
  const data = await gql("query TetherCheckout($id: ID!) { cart(id: $id) { checkoutUrl } }", { id });
1045
- const url = data.cart?.checkoutUrl;
1122
+ let url = this.beforeCheckout && await this.beforeCheckout().catch(() => null) || data.cart?.checkoutUrl;
1123
+ if (url && this.customerAccessToken) url += `${url.includes("?") ? "&" : "?"}logged_in=true`;
1046
1124
  if (!url) throw new TetherError("No checkoutUrl (cart empty or expired)");
1047
1125
  emit("checkout", { url, cart: this.state });
1048
1126
  if (tab && !tab.closed) tab.location.href = url;
@@ -1058,13 +1136,18 @@
1058
1136
  drawers() {
1059
1137
  return qsa(document, sel("cart-drawer"));
1060
1138
  },
1139
+ /** Without a tether-cart-drawer (a React/Svelte/Vue cart), open/close still work: as state for subscribe. */
1140
+ _open: false,
1061
1141
  isOpen() {
1062
- return this.drawers().some((d) => d.classList.contains(cfg.cartDrawerOpenClass));
1142
+ const ds = this.drawers();
1143
+ return ds.length ? ds.some((d) => d.classList.contains(cfg.cartDrawerOpenClass)) : this._open;
1063
1144
  },
1064
1145
  open() {
1065
1146
  if (this.isOpen()) return;
1147
+ this._open = true;
1066
1148
  const ds = this.drawers().filter((d) => get(d, "cart-drawer") !== "manual");
1067
1149
  ds.forEach((d) => {
1150
+ d.inert = false;
1068
1151
  d.classList.add(cfg.cartDrawerOpenClass);
1069
1152
  d.setAttribute("aria-hidden", "false");
1070
1153
  });
@@ -1082,11 +1165,13 @@
1082
1165
  },
1083
1166
  close() {
1084
1167
  if (!this.isOpen()) return;
1168
+ this._open = false;
1085
1169
  const ds = this.drawers();
1086
1170
  const hadFocus = ds.some((d) => d.contains(document.activeElement));
1087
1171
  ds.forEach((d) => {
1088
1172
  d.classList.remove(cfg.cartDrawerOpenClass);
1089
1173
  d.setAttribute("aria-hidden", "true");
1174
+ if (cfg.cartFocus !== false) d.inert = true;
1090
1175
  });
1091
1176
  ariaState(false);
1092
1177
  if (hadFocus && returnFocus?.isConnected) returnFocus.focus({ preventScroll: true });
@@ -1183,7 +1268,7 @@
1183
1268
  case "line-subtotal":
1184
1269
  return formatMoney(line.cost?.subtotalAmount);
1185
1270
  case "quantity":
1186
- return line.quantity;
1271
+ return Cart.quantityOf(line);
1187
1272
  case "plan":
1188
1273
  return plan?.name || "";
1189
1274
  case "plan-description":
@@ -1219,11 +1304,19 @@
1219
1304
  if (v !== void 0) setTextTracked(el, v);
1220
1305
  return void 0;
1221
1306
  });
1307
+ const warning = Cart.warnings.find((w) => w.target === line.id);
1308
+ node.classList.toggle("is-warning", Boolean(warning));
1309
+ const pending2 = Cart.pendingQty.has(line.id);
1310
+ node.classList.toggle("is-busy", pending2);
1311
+ if (pending2) node.setAttribute("aria-busy", "true");
1312
+ else node.removeAttribute("aria-busy");
1313
+ qsa(node, sel("line", "warning")).forEach((el) => setTextTracked(el, warning?.message || ""));
1222
1314
  const lineConds = {
1223
1315
  subscription: isSub,
1224
1316
  "one-time": !isSub,
1225
1317
  "on-sale": parseFloat(line.cost?.compareAtAmountPerQuantity?.amount) > parseFloat(line.cost?.amountPerQuantity?.amount),
1226
- "has-components": (line.lineComponents || []).length > 0
1318
+ "has-components": (line.lineComponents || []).length > 0,
1319
+ warning: Boolean(warning)
1227
1320
  };
1228
1321
  qsa(node, sel("line-show")).forEach((el) => {
1229
1322
  const k = get(el, "line-show");
@@ -1274,8 +1367,8 @@
1274
1367
  resolveProductKey: () => resolveProductKey,
1275
1368
  setScopeStatus: () => setScopeStatus
1276
1369
  });
1277
- function resolveProductKey(el, location = window.location) {
1278
- const raw = (get(el, "product") || "").trim();
1370
+ function resolveProductKey(el, location = window.location, name = "product") {
1371
+ const raw = (get(el, name) || "").trim();
1279
1372
  if (!raw.toLowerCase().startsWith("url:")) return { key: raw || null, fromUrl: false, raw };
1280
1373
  const what = raw.slice(4).trim();
1281
1374
  let key = null;
@@ -1457,7 +1550,17 @@
1457
1550
  // ------------------------------------------------------------ actions
1458
1551
  select(name, value) {
1459
1552
  if (this.selected[name] === value) return;
1460
- this.selected[name] = value;
1553
+ const vs = this.product.variants.nodes.filter((v) => v.selectedOptions.some((o) => o.name === name && o.value === value));
1554
+ if (!vs.length) return;
1555
+ const next = { ...this.selected, [name]: value };
1556
+ if (!vs.some((v) => v.selectedOptions.every((o) => next[o.name] === o.value))) {
1557
+ const score = (v) => v.selectedOptions.filter((o) => o.name !== name && this.selected[o.name] === o.value).length * 2 + (v.availableForSale ? 1 : 0);
1558
+ const best = vs.reduce((a, b) => score(b) > score(a) ? b : a);
1559
+ best.selectedOptions.forEach((o) => {
1560
+ next[o.name] = o.value;
1561
+ });
1562
+ }
1563
+ this.selected = next;
1461
1564
  this.renderVariant();
1462
1565
  }
1463
1566
  selectVariant(id) {
@@ -1704,7 +1807,7 @@
1704
1807
  const plan = this.plan;
1705
1808
  const total = { amount: (parseFloat(price.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: price.currencyCode };
1706
1809
  const fm = formatMoney;
1707
- const pct = (n) => n ? `${n}%` : "";
1810
+ const pct = (n) => formatPercent(n);
1708
1811
  const text = {
1709
1812
  price: () => fm(price),
1710
1813
  "total-price": () => fm(total),
@@ -1712,7 +1815,11 @@
1712
1815
  "subscription-price": () => fm(subPrice),
1713
1816
  "subscription-savings": () => pct(savings.percentage),
1714
1817
  "subscription-savings-amount": () => fm(savings.amount),
1715
- "checkout-charge": () => fm(sub ? planCheckoutCharge(v, this.planId) : price),
1818
+ // What the visitor pays today for the chosen quantity (prepaid plans charge more up front).
1819
+ "checkout-charge": () => {
1820
+ const c = sub ? planCheckoutCharge(v, this.planId) : price;
1821
+ return c && fm({ amount: (parseFloat(c.amount) * Math.max(1, this.quantity)).toFixed(2), currencyCode: c.currencyCode });
1822
+ },
1716
1823
  "plan-name": () => plan?.name,
1717
1824
  "plan-description": () => plan?.description,
1718
1825
  "plan-group": () => plan?.group?.name,
@@ -1771,6 +1878,8 @@
1771
1878
  const k = get(el, "plan-bind");
1772
1879
  const val = {
1773
1880
  name: () => planObj?.name,
1881
+ option: () => (planObj?.options || []).map((o) => o.value).join(", "),
1882
+ // e.g. "1 month", without the store's price text
1774
1883
  description: () => planObj?.description,
1775
1884
  price: () => fm(planPrice(v, id)),
1776
1885
  savings: () => pct(s.percentage),
@@ -1783,6 +1892,7 @@
1783
1892
  this.$(`select${sel("plan")}`).forEach((s) => {
1784
1893
  s.value = sub ? this.planId : "";
1785
1894
  });
1895
+ const low = v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= (cfg.lowStock ?? 5);
1786
1896
  const conds = {
1787
1897
  "in-stock": v.availableForSale,
1788
1898
  "out-of-stock": !v.availableForSale,
@@ -1791,7 +1901,8 @@
1791
1901
  "not-on-sale": !onSale,
1792
1902
  "has-variants": p.variants.nodes.length > 1,
1793
1903
  "single-variant": p.variants.nodes.length === 1,
1794
- "low-stock": v.quantityAvailable != null && v.quantityAvailable > 0 && v.quantityAvailable <= 5,
1904
+ "low-stock": low,
1905
+ "plenty-in-stock": v.availableForSale && !low,
1795
1906
  "subscription-available": this.subscriptionAvailable,
1796
1907
  "subscription-unavailable": !this.subscriptionAvailable,
1797
1908
  "subscription-only": Boolean(p.requiresSellingPlan),
@@ -1880,9 +1991,11 @@
1880
1991
  const stale2 = () => token !== this.loadToken;
1881
1992
  try {
1882
1993
  if (has(this.root, "recommendations")) {
1883
- const key = get(this.root, "recommendations");
1884
- const base = await getProduct(key);
1885
- const recs = await getRecommendations(base.id, get(this.root, "intent") || "RELATED");
1994
+ let { key } = resolveProductKey(this.root, window.location, "recommendations");
1995
+ const scope = this.root.closest(sel("product"));
1996
+ if (!key && scope) ({ key } = resolveProductKey(scope));
1997
+ if (!key) throw new TetherError("No product for recommendations", { code: "NOT_FOUND" });
1998
+ const recs = await getRecommendations(key, get(this.root, "intent") || "RELATED");
1886
1999
  if (stale2()) return;
1887
2000
  const limit = parseInt(get(this.root, "limit") || "", 10);
1888
2001
  this.products = limit ? recs.slice(0, limit) : recs;
@@ -1899,7 +2012,7 @@
1899
2012
  } catch (err) {
1900
2013
  if (stale2()) return;
1901
2014
  if (!more) setScopeStatus(this.root, isNotFound(err) ? "not-found" : "error", err);
1902
- reportError(err, "list load failed");
2015
+ if (!(isNotFound(err) && has(this.root, "recommendations"))) reportError(err, "list load failed");
1903
2016
  emit("error", { error: err, context: "list" }, this.root);
1904
2017
  }
1905
2018
  }
@@ -1930,12 +2043,10 @@
1930
2043
  const empty = this.products.length === 0;
1931
2044
  this.$(sel("show")).forEach((el) => {
1932
2045
  const k = get(el, "show");
1933
- if (k === "list-empty") el.style.display = empty ? "" : "none";
1934
- if (k === "list-filled") el.style.display = empty ? "none" : "";
1935
- });
1936
- this.$(sel("load-more")).forEach((el) => {
1937
- el.style.display = this.pageInfo?.hasNextPage ? "" : "none";
2046
+ if (k === "list-empty") show(el, empty);
2047
+ if (k === "list-filled") show(el, !empty);
1938
2048
  });
2049
+ this.$(sel("load-more")).forEach((el) => show(el, Boolean(this.pageInfo?.hasNextPage)));
1939
2050
  notifyLayoutChanged("list");
1940
2051
  }
1941
2052
  };
@@ -1951,6 +2062,8 @@
1951
2062
  mountedProducts: () => mountedProducts,
1952
2063
  pruneLive: () => pruneLive,
1953
2064
  retryFailed: () => retryFailed,
2065
+ settleMarket: () => settleMarket,
2066
+ trackMarket: () => trackMarket,
1954
2067
  within: () => within
1955
2068
  });
1956
2069
  var mountedProducts = /* @__PURE__ */ new WeakSet();
@@ -1989,7 +2102,9 @@
1989
2102
  const current = () => requestToken.get(el) === token;
1990
2103
  if (!scopes.get(el)) setScopeStatus(el, "loading");
1991
2104
  else el.setAttribute(attr("state"), "updating");
1992
- getProduct(key).then((p) => {
2105
+ const loading = getProduct(key);
2106
+ marketPending?.add(loading);
2107
+ loading.then((p) => {
1993
2108
  if (!current()) return;
1994
2109
  const existing = scopes.get(el);
1995
2110
  if (existing) existing.update(p);
@@ -2043,6 +2158,22 @@
2043
2158
  }).catch((err) => reportError(err, `refresh ${keys[i]}`)));
2044
2159
  }
2045
2160
  var readScripts = /* @__PURE__ */ new WeakSet();
2161
+ var marketPending = null;
2162
+ var trackMarket = (p) => {
2163
+ marketPending?.add(p);
2164
+ return p;
2165
+ };
2166
+ function settleMarket() {
2167
+ if (!marketPending) return;
2168
+ const waits = [...marketPending];
2169
+ Promise.allSettled(waits).then(() => {
2170
+ if (!marketPending || waits.some((w) => !marketPending.has(w))) return;
2171
+ if (waits.length === marketPending.size) {
2172
+ marketPending = null;
2173
+ document.documentElement.classList.remove(`${cfg.prefix}-market-pending`);
2174
+ } else settleMarket();
2175
+ });
2176
+ }
2046
2177
  function hydrateFromPage(root = document) {
2047
2178
  qsa(root.ownerDocument || root, 'script[type="application/json"][data-tether-hydrate]').forEach((s) => {
2048
2179
  if (readScripts.has(s)) return;
@@ -2052,7 +2183,13 @@
2052
2183
  rememberOptionNames(data.products);
2053
2184
  const same = (a, b) => String(a || "").toUpperCase() === String(b || "").toUpperCase();
2054
2185
  if (same(data.country, cfg.country) && same(data.language, cfg.language)) hydrateProducts(data.products);
2055
- else log("hydration skipped: other market");
2186
+ else {
2187
+ log("hydration skipped: other market");
2188
+ if (!marketPending) {
2189
+ marketPending = /* @__PURE__ */ new Set();
2190
+ document.documentElement.classList.add(`${cfg.prefix}-market-pending`);
2191
+ }
2192
+ }
2056
2193
  } catch (err) {
2057
2194
  warn("bad data-tether-hydrate", err);
2058
2195
  }
@@ -2096,7 +2233,7 @@
2096
2233
  }
2097
2234
 
2098
2235
  // src/index.js
2099
- var VERSION = "0.5.0";
2236
+ var VERSION = "0.6.1";
2100
2237
  var initPromise = null;
2101
2238
  var observer = null;
2102
2239
  function mount(root = document) {
@@ -2111,7 +2248,7 @@
2111
2248
  const list = new ListScope(el);
2112
2249
  liveLists.add(list);
2113
2250
  el.addEventListener(`${cfg.prefix}:list-rendered`, (e) => mount(e.detail.container));
2114
- list.load();
2251
+ trackMarket(list.load());
2115
2252
  });
2116
2253
  within(root, sel("product")).forEach((el) => {
2117
2254
  if (mountProduct(el)) count++;
@@ -2119,9 +2256,16 @@
2119
2256
  if (within(root, `${sel("country-select")},${sel("country-bind")}`).length) {
2120
2257
  loadPart("markets").then((m) => m.renderCountrySelects(root)).catch((err) => reportError(err, "markets"));
2121
2258
  }
2259
+ if ((cfg.account?.clientId || cfg.account?.server) && (within(root, `[${attr("account-login")}],[${attr("account-bind")}],[${attr("orders")}],[${attr("show")}^="logged-"]`).length || /[?&]code=/.test(window.location.search) || hasAccountSession())) {
2260
+ loadPart("account").then((m) => m.mountAccount(root)).catch((err) => reportError(err, "account"));
2261
+ }
2262
+ if (within(root, sel("search")).length) {
2263
+ loadPart("search").then((m) => m.mountSearch(root)).catch((err) => reportError(err, "search"));
2264
+ }
2122
2265
  qsa(root, sel("account-link")).forEach((a) => {
2123
2266
  a.setAttribute("href", `https://${cfg.domain}/account`);
2124
2267
  });
2268
+ settleMarket();
2125
2269
  Cart.render();
2126
2270
  log(`mounted ${count} scope(s)`, root);
2127
2271
  return count;
@@ -2170,9 +2314,12 @@
2170
2314
  e.preventDefault();
2171
2315
  fn().catch((err) => reportError(err, "cart line"));
2172
2316
  };
2173
- if (t.closest(sel("line-remove"))) return run(() => Cart.removeLine(id));
2174
- if (current && t.closest(sel("line-plus"))) return run(() => Cart.updateLine(id, current.quantity + 1));
2175
- if (current && t.closest(sel("line-minus"))) return run(() => Cart.updateLine(id, current.quantity - 1));
2317
+ if (t.closest(sel("line-remove"))) {
2318
+ line.classList.add("is-busy");
2319
+ return run(() => Cart.removeLine(id));
2320
+ }
2321
+ if (current && t.closest(sel("line-plus"))) return run(() => Cart.changeQuantity(id, 1));
2322
+ if (current && t.closest(sel("line-minus"))) return run(() => Cart.changeQuantity(id, -1));
2176
2323
  }
2177
2324
  const removeCode = t.closest(sel("discount-remove"));
2178
2325
  if (removeCode) {
@@ -2192,10 +2339,15 @@
2192
2339
  if (!t?.closest) return;
2193
2340
  const lineEl = t.closest(dataKeySel());
2194
2341
  const id = lineEl?.getAttribute(`data-${cfg.prefix}-key`);
2342
+ const busyLine = (p, what) => {
2343
+ lineEl.classList.add("is-busy");
2344
+ lineEl.setAttribute("aria-busy", "true");
2345
+ p.catch((err) => reportError(err, what));
2346
+ };
2195
2347
  if (id && t.matches(`input${sel("line", "quantity")}`)) {
2196
- Cart.updateLine(id, parseInt(t.value, 10) || 0).catch((err) => reportError(err, "cart line"));
2348
+ busyLine(Cart.updateLine(id, parseInt(t.value, 10) || 0), "cart line");
2197
2349
  } else if (id && t.matches(`select${sel("line-plan")}`)) {
2198
- Cart.updateLine(id, { sellingPlanId: t.value || null }).catch((err) => reportError(err, "cart line plan"));
2350
+ busyLine(Cart.updateLine(id, { sellingPlanId: t.value || null }), "cart line plan");
2199
2351
  } else if (t.matches(`select${sel("country-select")}`)) {
2200
2352
  if (t.value) setCountry(t.value).catch((err) => reportError(err, "country"));
2201
2353
  } else if (t.matches(sel("cart-note"))) {
@@ -2210,25 +2362,57 @@
2210
2362
  const code = input?.value.trim();
2211
2363
  if (!code) return;
2212
2364
  busy(form, async () => {
2365
+ const mark = (bad) => {
2366
+ form.classList.toggle("is-invalid", bad);
2367
+ if (input) {
2368
+ if (bad) input.setAttribute("aria-invalid", "true");
2369
+ else input.removeAttribute("aria-invalid");
2370
+ }
2371
+ };
2213
2372
  try {
2214
- const cart = await Cart.applyDiscount(code);
2215
- const ok = (cart?.discountCodes || []).find((d) => d.code.toLowerCase() === code.toLowerCase())?.applicable;
2216
- if (!ok) await Cart.removeDiscount(code).catch(() => {
2217
- });
2218
- form.classList.toggle("is-invalid", !ok);
2219
- emit(ok ? "discount-applied" : "discount-invalid", { code }, form);
2220
- if (ok && input) input.value = "";
2373
+ await Cart.applyDiscount(code);
2374
+ mark(false);
2375
+ emit("discount-applied", { code }, form);
2376
+ if (input) input.value = "";
2221
2377
  } catch (err) {
2222
- form.classList.add("is-invalid");
2223
- reportError(err, "discount");
2378
+ mark(true);
2379
+ if (err?.code === "INVALID_DISCOUNT") emit("discount-invalid", { code }, form);
2380
+ else reportError(err, "discount");
2224
2381
  }
2225
2382
  });
2226
2383
  }, true);
2227
2384
  document.addEventListener("input", (e) => {
2228
- e.target.closest?.(sel("discount-form"))?.classList.remove("is-invalid");
2385
+ const f = e.target.closest?.(sel("discount-form"));
2386
+ if (f) {
2387
+ f.classList.remove("is-invalid");
2388
+ e.target.removeAttribute?.("aria-invalid");
2389
+ }
2390
+ });
2391
+ window.addEventListener("storage", (e) => {
2392
+ if (cfg.domain && e.key?.startsWith(`${cfg.prefix}:`)) loadPart("extras").then((m) => m.onStorage(e)).catch(() => {
2393
+ });
2394
+ });
2395
+ document.addEventListener(`${cfg.prefix}:variant-changed`, (e) => {
2396
+ const root = e.target;
2397
+ if (cfg.variantUrl || root.hasAttribute?.(attr("variant-url"))) loadPart("extras").then((m) => m.writeVariantUrl(root, e.detail)).catch(() => {
2398
+ });
2229
2399
  });
2230
2400
  if (cfg.astro !== false) {
2231
- document.addEventListener("astro:page-load", () => mount(document));
2401
+ document.addEventListener("astro:page-load", () => (initPromise || Promise.resolve()).then(() => {
2402
+ let target = pageMarket();
2403
+ if (!target && onPageMarket) {
2404
+ try {
2405
+ target = JSON.parse(localStorage.getItem(`${cfg.prefix}:market:${cfg.domain}`) || "null");
2406
+ } catch {
2407
+ target = null;
2408
+ }
2409
+ target = target?.country ? target : baseMarket;
2410
+ }
2411
+ onPageMarket = Boolean(pageMarket());
2412
+ const differs = target?.country && (String(target.country).toUpperCase() !== String(cfg.country || "").toUpperCase() || String(target.language || "").toUpperCase() !== String(cfg.language || "").toUpperCase());
2413
+ if (differs) setCountry(target.country, { language: target.language ?? null, locale: target.locale, remember: false }).then(() => mount(document), () => mount(document));
2414
+ else mount(document);
2415
+ }));
2232
2416
  document.addEventListener("astro:before-swap", () => unmount());
2233
2417
  }
2234
2418
  window.addEventListener("online", () => {
@@ -2258,30 +2442,8 @@
2258
2442
  function dataKeySel() {
2259
2443
  return `[data-${cfg.prefix}-key]`;
2260
2444
  }
2261
- async function addGroup(btn) {
2262
- const group = btn.closest(sel("group"));
2263
- if (!group) return null;
2264
- const name = get(group, "group");
2265
- const extra = toAttributes(parseJSONAttr(group, "line-attributes"));
2266
- const groupAttrs = name ? [{ key: "_group", value: name }, ...extra] : extra;
2267
- const lines = qsa(group, sel("product")).map((el) => scopes.get(el)).filter((s) => s && s.variant?.availableForSale && s.quantity > 0).map((s) => {
2268
- const l = s.getLine();
2269
- l.attributes = [...l.attributes || [], ...groupAttrs];
2270
- return l;
2271
- });
2272
- if (!lines.length) {
2273
- warn("group-add: nothing selected");
2274
- return null;
2275
- }
2276
- try {
2277
- const cart = await Cart.add(lines);
2278
- emit("group-added", { group: name, lines }, group);
2279
- return cart;
2280
- } catch (err) {
2281
- reportError(err, "group add");
2282
- return null;
2283
- }
2284
- }
2445
+ var addGroup = (btn) => loadPart("extras").then((m) => m.addGroup(btn));
2446
+ var analyticsQueue = null;
2285
2447
  function init(overrides = {}) {
2286
2448
  if (initPromise && !Object.keys(overrides).length) return initPromise;
2287
2449
  configure({ ...window.TETHER_CONFIG || {}, ...overrides });
@@ -2292,6 +2454,12 @@
2292
2454
  }
2293
2455
  bindControls();
2294
2456
  if (cfg.observe) startObserver();
2457
+ if (cfg.analytics && !analyticsQueue) {
2458
+ analyticsQueue = [];
2459
+ ["product-ready", "list-ready", "cart-updated", "cart-toggled", "checkout", "search"].forEach((n) => document.addEventListener(`${cfg.prefix}:${n}`, (e) => analyticsQueue.push(e)));
2460
+ document.addEventListener("astro:page-load", () => analyticsQueue.push(new CustomEvent(`${cfg.prefix}:page`)));
2461
+ loadPart("analytics").then((m) => m.startAnalytics(analyticsQueue)).catch((err) => reportError(err, "analytics"));
2462
+ }
2295
2463
  initPromise = applyMarket(overrides).then(() => Cart.init()).then(async (cart) => {
2296
2464
  const want = cfg.country ? String(cfg.country).toUpperCase() : null;
2297
2465
  if (cart && want && cart.buyerIdentity?.countryCode && cart.buyerIdentity.countryCode !== want) {
@@ -2306,8 +2474,24 @@
2306
2474
  });
2307
2475
  return initPromise;
2308
2476
  }
2477
+ function pageMarket(doc = document) {
2478
+ const get2 = (n) => doc.querySelector(`meta[name="${cfg.prefix}-page-${n}"]`)?.getAttribute("content") || void 0;
2479
+ const country = get2("country");
2480
+ return country ? { country: country.toUpperCase(), language: get2("language"), locale: get2("locale") } : null;
2481
+ }
2482
+ var baseMarket = null;
2483
+ var onPageMarket = false;
2309
2484
  async function applyMarket(overrides) {
2310
2485
  const m = cfg.markets || {};
2486
+ baseMarket = { country: cfg.country, language: cfg.language, locale: cfg.locale };
2487
+ const page = pageMarket();
2488
+ onPageMarket = Boolean(page);
2489
+ if (page) {
2490
+ cfg.country = page.country;
2491
+ cfg.language = page.language ? page.language.toUpperCase() : void 0;
2492
+ if (page.locale) cfg.locale = page.locale;
2493
+ return;
2494
+ }
2311
2495
  if (m.detect === false) return;
2312
2496
  let market = null;
2313
2497
  if (m.detect) {
@@ -2330,6 +2514,14 @@
2330
2514
  if (market.locale && !overrides.locale) cfg.locale = market.locale;
2331
2515
  log("market", market);
2332
2516
  }
2517
+ function hasAccountSession() {
2518
+ try {
2519
+ return Boolean(sessionStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account:${cfg.domain}`] || localStorage[`${cfg.prefix}:account-hint:${cfg.domain}`]);
2520
+ } catch {
2521
+ return false;
2522
+ }
2523
+ }
2524
+ var accountApi = new Proxy({}, { get: (_, n) => (...a) => loadPart("account").then((m) => m[n](...a)) });
2333
2525
  function setCountry(code, opts) {
2334
2526
  return loadPart("markets").then((m) => m.setCountry(code, opts));
2335
2527
  }
@@ -2370,6 +2562,7 @@
2370
2562
  getRecommendations,
2371
2563
  getScope,
2372
2564
  addGroup,
2565
+ account: accountApi,
2373
2566
  primeProduct,
2374
2567
  normalizeProductKey,
2375
2568
  clearProductCache,