@occluze/tether 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -8
- package/dist/esm/astro.js +2 -0
- package/dist/esm/chunks/account-WAWNASZU.js +1 -0
- package/dist/esm/chunks/analytics-22NMIVBV.js +1 -0
- package/dist/esm/chunks/chunk-4UR45XTC.js +1 -0
- package/dist/esm/chunks/chunk-6R752OYZ.js +1 -0
- package/dist/esm/chunks/chunk-BGIKQY6H.js +1 -0
- package/dist/esm/chunks/chunk-CISGFYOF.js +1 -0
- package/dist/esm/chunks/chunk-KARKXC6G.js +1 -0
- package/dist/esm/chunks/chunk-VC46IEJQ.js +1 -0
- package/dist/esm/chunks/chunk-YXCXIPQV.js +4 -0
- package/dist/esm/chunks/{chunk-KYQ5OGBX.js → chunk-YZMBXS6Y.js} +1 -1
- package/dist/esm/chunks/chunk-ZVUPVSLF.js +1 -0
- package/dist/esm/chunks/diagnose-6ZSK5DGE.js +1 -0
- package/dist/esm/chunks/extras-QDCSW6VU.js +1 -0
- package/dist/esm/chunks/markets-ui-HJCVLCKJ.js +1 -0
- package/dist/esm/chunks/search-LN2H6SXS.js +1 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/server.js +1 -0
- package/dist/tether.account.js +374 -0
- package/dist/tether.account.min.js +1 -0
- package/dist/tether.analytics.js +259 -0
- package/dist/tether.analytics.min.js +1 -0
- package/dist/tether.diagnose.js +14 -4
- package/dist/tether.diagnose.min.js +1 -1
- package/dist/tether.extras.js +87 -0
- package/dist/tether.extras.min.js +1 -0
- package/dist/tether.js +486 -125
- package/dist/tether.markets.js +17 -11
- package/dist/tether.markets.min.js +1 -1
- package/dist/tether.min.js +3 -3
- package/dist/tether.min.js.map +3 -3
- package/dist/tether.search.js +176 -0
- package/dist/tether.search.min.js +1 -0
- package/package.json +31 -5
- package/src/account.js +313 -0
- package/src/analytics.js +232 -0
- package/src/astro.js +76 -0
- package/src/cart.js +119 -16
- package/src/client.js +30 -15
- package/src/collection.js +15 -10
- package/src/config.js +5 -0
- package/src/diagnose.js +12 -3
- package/src/dom.js +39 -10
- package/src/extras.js +63 -0
- package/src/fragments.js +13 -12
- package/src/index.js +114 -40
- package/src/initial.js +75 -0
- package/src/lazy.js +9 -1
- package/src/loader.js +53 -7
- package/src/markets-ui.js +15 -6
- package/src/markets.js +1 -1
- package/src/mounting.js +68 -3
- package/src/parts/account.js +3 -0
- package/src/parts/analytics.js +3 -0
- package/src/parts/extras.js +3 -0
- package/src/parts/search.js +3 -0
- package/src/product.js +51 -13
- package/src/search.js +153 -0
- package/src/server.js +251 -0
- package/src/status.js +3 -8
- package/types/astro.d.ts +15 -0
- package/types/index.d.ts +72 -4
- package/types/server.d.ts +102 -0
- package/dist/esm/chunks/chunk-GHIUMD5H.js +0 -1
- package/dist/esm/chunks/chunk-LI3YDMGF.js +0 -4
- package/dist/esm/chunks/diagnose-ZXXXJIQE.js +0 -1
- package/dist/esm/chunks/markets-ui-AG6Y5VUE.js +0 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __export = (target, all) => {
|
|
4
|
+
for (var name in all)
|
|
5
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
// src/search.js
|
|
9
|
+
var search_exports = {};
|
|
10
|
+
__export(search_exports, {
|
|
11
|
+
mountSearch: () => mountSearch
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// tether-core:config.js
|
|
15
|
+
var m = window.Tether.__core["config.js"];
|
|
16
|
+
var { DEFAULTS, cfg, configure, isConfigured, log, reportError, warn } = m;
|
|
17
|
+
|
|
18
|
+
// tether-core:client.js
|
|
19
|
+
var m2 = window.Tether.__core["client.js"];
|
|
20
|
+
var { TetherError, endpoint, gql, request, unwrap, withContext } = m2;
|
|
21
|
+
|
|
22
|
+
// tether-core:dom.js
|
|
23
|
+
var m3 = window.Tether.__core["dom.js"];
|
|
24
|
+
var { attr, busy, cdnUrl, dataKey, emit, formatDate, formatMoney, formatPercent, get, has, hasTemplate, imageUrl, notifyLayoutChanged, parseJSONAttr, productUrl, qsa, readText, repeat, repeatKeyed, sameImage, scoped, sel, setImage, setSelected, setText, setTextTracked, show, toAttributes, watchImages } = m3;
|
|
25
|
+
|
|
26
|
+
// src/search.js
|
|
27
|
+
var QUERY = `query TetherPredictive($q:String!,$limit:Int!){predictiveSearch(query:$q,limit:$limit,limitScope:EACH,types:[PRODUCT,COLLECTION,QUERY]){products{id handle title availableForSale featuredImage{url altText}priceRange{minVariantPrice{amount currencyCode}}}collections{handle title image{url altText}}queries{text}}}`;
|
|
28
|
+
var mounted = /* @__PURE__ */ new WeakSet();
|
|
29
|
+
var uid = 0;
|
|
30
|
+
function mountSearch(root = document) {
|
|
31
|
+
const roots = [...root.matches?.(sel("search")) ? [root] : [], ...qsa(root, sel("search"))];
|
|
32
|
+
roots.forEach((el) => {
|
|
33
|
+
if (!mounted.has(el)) {
|
|
34
|
+
mounted.add(el);
|
|
35
|
+
setup(el);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function setup(rootEl) {
|
|
40
|
+
const input = rootEl.querySelector(sel("search-input")) || rootEl.querySelector("input");
|
|
41
|
+
const results = rootEl.querySelector(sel("search-results"));
|
|
42
|
+
if (!input || !results) return;
|
|
43
|
+
const limit = parseInt(get(rootEl, "search-limit") || "6", 10) || 6;
|
|
44
|
+
const min = parseInt(get(rootEl, "search-min") || "2", 10) || 2;
|
|
45
|
+
const id = results.id || (results.id = `${cfg.prefix}-search-${++uid}`);
|
|
46
|
+
input.setAttribute("role", "combobox");
|
|
47
|
+
input.setAttribute("aria-autocomplete", "list");
|
|
48
|
+
input.setAttribute("aria-controls", id);
|
|
49
|
+
input.setAttribute("aria-expanded", "false");
|
|
50
|
+
input.setAttribute("autocomplete", "off");
|
|
51
|
+
if (!results.hasAttribute("role")) results.setAttribute("role", "listbox");
|
|
52
|
+
results.hidden = true;
|
|
53
|
+
let token = 0;
|
|
54
|
+
let timer = null;
|
|
55
|
+
let last = "";
|
|
56
|
+
const cache = /* @__PURE__ */ new Map();
|
|
57
|
+
const setState = (state) => rootEl.setAttribute(attr("search-state"), state);
|
|
58
|
+
const open = (yes) => {
|
|
59
|
+
rootEl.classList.toggle("is-open", yes);
|
|
60
|
+
results.hidden = !yes;
|
|
61
|
+
input.setAttribute("aria-expanded", String(yes));
|
|
62
|
+
if (!yes) input.removeAttribute("aria-activedescendant");
|
|
63
|
+
};
|
|
64
|
+
const items = () => qsa(results, '[role="option"]');
|
|
65
|
+
async function run(term) {
|
|
66
|
+
const t = ++token;
|
|
67
|
+
if (term.length < min) {
|
|
68
|
+
open(false);
|
|
69
|
+
setState("idle");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
setState("loading");
|
|
73
|
+
try {
|
|
74
|
+
const data = cache.get(term) || (await gql(QUERY, { q: term, limit })).predictiveSearch;
|
|
75
|
+
cache.set(term, data);
|
|
76
|
+
if (t !== token) return;
|
|
77
|
+
render(data, term);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (t !== token) return;
|
|
80
|
+
setState("error");
|
|
81
|
+
reportError(err, "search");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function render(data, term) {
|
|
85
|
+
const products = data?.products || [];
|
|
86
|
+
const collections = data?.collections || [];
|
|
87
|
+
const queries = (data?.queries || []).filter((q) => q.text.toLowerCase() !== term.toLowerCase());
|
|
88
|
+
const fill = (name, list, each) => qsa(results, sel(name)).forEach((c) => {
|
|
89
|
+
repeat(c, list, each);
|
|
90
|
+
show(c, list.length > 0);
|
|
91
|
+
});
|
|
92
|
+
fill("search-products", products, (node, p) => {
|
|
93
|
+
binds(node, { title: p.title, price: formatMoney(p.priceRange?.minVariantPrice), image: p.featuredImage });
|
|
94
|
+
link(node, productUrl(p.handle, p.id));
|
|
95
|
+
node.classList.toggle("is-sold-out", !p.availableForSale);
|
|
96
|
+
});
|
|
97
|
+
fill("search-collections", collections, (node, c) => {
|
|
98
|
+
binds(node, { title: c.title, image: c.image });
|
|
99
|
+
link(node, `${(cfg.collectionPath || "/collection").replace(/\/$/, "")}/${c.handle}`);
|
|
100
|
+
});
|
|
101
|
+
fill("search-queries", queries, (node, q) => {
|
|
102
|
+
binds(node, { text: q.text });
|
|
103
|
+
const a = node.tagName === "A" ? node : node.querySelector("a");
|
|
104
|
+
const action = rootEl.getAttribute("action") || "/search";
|
|
105
|
+
if (a) a.setAttribute("href", `${action}${action.includes("?") ? "&" : "?"}${input.name || "q"}=${encodeURIComponent(q.text)}`);
|
|
106
|
+
});
|
|
107
|
+
const count = products.length + collections.length + queries.length;
|
|
108
|
+
qsa(rootEl, sel("show")).forEach((el) => {
|
|
109
|
+
const k = get(el, "show");
|
|
110
|
+
if (k === "search-empty") show(el, count === 0);
|
|
111
|
+
if (k === "search-results") show(el, count > 0);
|
|
112
|
+
});
|
|
113
|
+
items().forEach((el, i) => {
|
|
114
|
+
el.id || (el.id = `${id}-${i}`);
|
|
115
|
+
el.setAttribute("aria-selected", "false");
|
|
116
|
+
});
|
|
117
|
+
setState(count ? "ready" : "empty");
|
|
118
|
+
open(true);
|
|
119
|
+
emit("search", { term, products, collections, queries }, rootEl);
|
|
120
|
+
}
|
|
121
|
+
function binds(node, values) {
|
|
122
|
+
qsa(node, sel("search-bind")).forEach((el) => {
|
|
123
|
+
const k = get(el, "search-bind");
|
|
124
|
+
if (k === "image") setImage(el, values.image?.url, values.image?.altText || values.title);
|
|
125
|
+
else if (values[k] != null) setText(el, values[k]);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function link(node, href) {
|
|
129
|
+
const a = node.tagName === "A" ? node : node.querySelector("a");
|
|
130
|
+
if (a) {
|
|
131
|
+
a.setAttribute("href", href);
|
|
132
|
+
a.setAttribute("role", "option");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
input.addEventListener("input", () => {
|
|
136
|
+
const term = input.value.trim();
|
|
137
|
+
if (term === last) return;
|
|
138
|
+
last = term;
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
timer = setTimeout(() => run(term), cfg.searchDelay ?? 150);
|
|
141
|
+
});
|
|
142
|
+
input.addEventListener("focus", () => {
|
|
143
|
+
if (input.value.trim().length >= min && items().length) open(true);
|
|
144
|
+
});
|
|
145
|
+
input.addEventListener("keydown", (e) => {
|
|
146
|
+
const list = items();
|
|
147
|
+
const cur = list.findIndex((el) => el.getAttribute("aria-selected") === "true");
|
|
148
|
+
const move = (i) => {
|
|
149
|
+
list.forEach((el, j) => el.setAttribute("aria-selected", String(j === i)));
|
|
150
|
+
if (list[i]) {
|
|
151
|
+
input.setAttribute("aria-activedescendant", list[i].id);
|
|
152
|
+
list[i].scrollIntoView?.({ block: "nearest" });
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
if (e.key === "ArrowDown" && list.length) {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
open(true);
|
|
158
|
+
move((cur + 1) % list.length);
|
|
159
|
+
} else if (e.key === "ArrowUp" && list.length) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
move(cur <= 0 ? list.length - 1 : cur - 1);
|
|
162
|
+
} else if (e.key === "Enter" && cur >= 0) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
list[cur].click();
|
|
165
|
+
} else if (e.key === "Escape") {
|
|
166
|
+
open(false);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
document.addEventListener("click", (e) => {
|
|
170
|
+
if (!rootEl.contains(e.target)) open(false);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/parts/search.js
|
|
175
|
+
window.Tether.__parts.search = search_exports;
|
|
176
|
+
})();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var Q=Object.defineProperty;var Y=(n,s)=>{for(var c in s)Q(n,c,{get:s[c],enumerable:!0})};var S={};Y(S,{mountSearch:()=>G});var H=window.Tether.__core["config.js"],{DEFAULTS:ee,cfg:A,configure:te,isConfigured:re,log:ie,reportError:E,warn:ae}=H;var J=window.Tether.__core["client.js"],{TetherError:ne,endpoint:ce,gql:U,request:oe,unwrap:le,withContext:ue}=J;var W=window.Tether.__core["dom.js"],{attr:D,busy:he,cdnUrl:fe,dataKey:me,emit:_,formatDate:pe,formatMoney:P,formatPercent:ge,get:g,has:be,hasTemplate:we,imageUrl:Ae,notifyLayoutChanged:ye,parseJSONAttr:xe,productUrl:R,qsa:h,readText:Te,repeat:N,repeatKeyed:qe,sameImage:Se,scoped:$e,sel:u,setImage:O,setSelected:ke,setText:j,setTextTracked:ve,show:y,toAttributes:Ie,watchImages:Le}=W;var z="query TetherPredictive($q:String!,$limit:Int!){predictiveSearch(query:$q,limit:$limit,limitScope:EACH,types:[PRODUCT,COLLECTION,QUERY]){products{id handle title availableForSale featuredImage{url altText}priceRange{minVariantPrice{amount currencyCode}}}collections{handle title image{url altText}}queries{text}}}",F=new WeakSet,B=0;function G(n=document){[...n.matches?.(u("search"))?[n]:[],...h(n,u("search"))].forEach(c=>{F.has(c)||(F.add(c),X(c))})}function X(n){let s=n.querySelector(u("search-input"))||n.querySelector("input"),c=n.querySelector(u("search-results"));if(!s||!c)return;let M=parseInt(g(n,"search-limit")||"6",10)||6,$=parseInt(g(n,"search-min")||"2",10)||2,k=c.id||(c.id=`${A.prefix}-search-${++B}`);s.setAttribute("role","combobox"),s.setAttribute("aria-autocomplete","list"),s.setAttribute("aria-controls",k),s.setAttribute("aria-expanded","false"),s.setAttribute("autocomplete","off"),c.hasAttribute("role")||c.setAttribute("role","listbox"),c.hidden=!0;let x=0,v=null,I="",L=new Map,b=e=>n.setAttribute(D("search-state"),e),d=e=>{n.classList.toggle("is-open",e),c.hidden=!e,s.setAttribute("aria-expanded",String(e)),e||s.removeAttribute("aria-activedescendant")},T=()=>h(c,'[role="option"]');async function V(e){let t=++x;if(e.length<$){d(!1),b("idle");return}b("loading");try{let i=L.get(e)||(await U(z,{q:e,limit:M})).predictiveSearch;if(L.set(e,i),t!==x)return;K(i,e)}catch(i){if(t!==x)return;b("error"),E(i,"search")}}function K(e,t){let i=e?.products||[],o=e?.collections||[],l=(e?.queries||[]).filter(r=>r.text.toLowerCase()!==t.toLowerCase()),f=(r,a,w)=>h(c,u(r)).forEach(p=>{N(p,a,w),y(p,a.length>0)});f("search-products",i,(r,a)=>{q(r,{title:a.title,price:P(a.priceRange?.minVariantPrice),image:a.featuredImage}),C(r,R(a.handle,a.id)),r.classList.toggle("is-sold-out",!a.availableForSale)}),f("search-collections",o,(r,a)=>{q(r,{title:a.title,image:a.image}),C(r,`${(A.collectionPath||"/collection").replace(/\/$/,"")}/${a.handle}`)}),f("search-queries",l,(r,a)=>{q(r,{text:a.text});let w=r.tagName==="A"?r:r.querySelector("a"),p=n.getAttribute("action")||"/search";w&&w.setAttribute("href",`${p}${p.includes("?")?"&":"?"}${s.name||"q"}=${encodeURIComponent(a.text)}`)});let m=i.length+o.length+l.length;h(n,u("show")).forEach(r=>{let a=g(r,"show");a==="search-empty"&&y(r,m===0),a==="search-results"&&y(r,m>0)}),T().forEach((r,a)=>{r.id||(r.id=`${k}-${a}`),r.setAttribute("aria-selected","false")}),b(m?"ready":"empty"),d(!0),_("search",{term:t,products:i,collections:o,queries:l},n)}function q(e,t){h(e,u("search-bind")).forEach(i=>{let o=g(i,"search-bind");o==="image"?O(i,t.image?.url,t.image?.altText||t.title):t[o]!=null&&j(i,t[o])})}function C(e,t){let i=e.tagName==="A"?e:e.querySelector("a");i&&(i.setAttribute("href",t),i.setAttribute("role","option"))}s.addEventListener("input",()=>{let e=s.value.trim();e!==I&&(I=e,clearTimeout(v),v=setTimeout(()=>V(e),A.searchDelay??150))}),s.addEventListener("focus",()=>{s.value.trim().length>=$&&T().length&&d(!0)}),s.addEventListener("keydown",e=>{let t=T(),i=t.findIndex(l=>l.getAttribute("aria-selected")==="true"),o=l=>{t.forEach((f,m)=>f.setAttribute("aria-selected",String(m===l))),t[l]&&(s.setAttribute("aria-activedescendant",t[l].id),t[l].scrollIntoView?.({block:"nearest"}))};e.key==="ArrowDown"&&t.length?(e.preventDefault(),d(!0),o((i+1)%t.length)):e.key==="ArrowUp"&&t.length?(e.preventDefault(),o(i<=0?t.length-1:i-1)):e.key==="Enter"&&i>=0?(e.preventDefault(),t[i].click()):e.key==="Escape"&&d(!1)}),document.addEventListener("click",e=>{n.contains(e.target)||d(!1)})}window.Tether.__parts.search=S;})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@occluze/tether",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Free, open-source alternative to Smootify and Storesynk (Shopyflow): Shopify products, cart and subscriptions in Webflow with HTML attributes. Optional Shopify to Webflow CMS sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
"types": "./types/index.d.ts",
|
|
11
11
|
"default": "./dist/esm/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./server": {
|
|
14
|
+
"types": "./types/server.d.ts",
|
|
15
|
+
"default": "./dist/esm/server.js"
|
|
16
|
+
},
|
|
17
|
+
"./astro": {
|
|
18
|
+
"types": "./types/astro.d.ts",
|
|
19
|
+
"default": "./dist/esm/astro.js"
|
|
20
|
+
},
|
|
13
21
|
"./presets/barba": {
|
|
14
22
|
"types": "./types/barba.d.ts",
|
|
15
23
|
"default": "./dist/presets/barba.esm.js"
|
|
@@ -22,7 +30,9 @@
|
|
|
22
30
|
"LICENSE",
|
|
23
31
|
"README.md",
|
|
24
32
|
"types/index.d.ts",
|
|
25
|
-
"types/barba.d.ts"
|
|
33
|
+
"types/barba.d.ts",
|
|
34
|
+
"types/server.d.ts",
|
|
35
|
+
"types/astro.d.ts"
|
|
26
36
|
],
|
|
27
37
|
"scripts": {
|
|
28
38
|
"build": "node scripts/build.mjs",
|
|
@@ -32,9 +42,12 @@
|
|
|
32
42
|
"pretest": "node scripts/build.mjs",
|
|
33
43
|
"presmoke": "node scripts/build.mjs",
|
|
34
44
|
"test:types": "tsc -p types/tsconfig.json",
|
|
35
|
-
"release": "node scripts/release.mjs"
|
|
45
|
+
"release": "node scripts/release.mjs",
|
|
46
|
+
"e2e:build": "npm run build && cd examples/astro && (test -f .env || cp .env.example .env) && npm install --no-audit --no-fund --silent && npm install --no-save --silent ../.. && npx astro build",
|
|
47
|
+
"e2e": "npm run e2e:build && playwright test -c e2e/playwright.config.js"
|
|
36
48
|
},
|
|
37
49
|
"devDependencies": {
|
|
50
|
+
"@playwright/test": "^1.64.1",
|
|
38
51
|
"esbuild": "^0.25.0",
|
|
39
52
|
"jsdom": "^26.0.0",
|
|
40
53
|
"typescript": "^5.9.3"
|
|
@@ -53,25 +66,38 @@
|
|
|
53
66
|
"url": "https://github.com/ravi-klaassens/tether/issues"
|
|
54
67
|
},
|
|
55
68
|
"keywords": [
|
|
69
|
+
"astro",
|
|
70
|
+
"astro-integration",
|
|
56
71
|
"cart",
|
|
57
72
|
"ecommerce",
|
|
58
73
|
"headless",
|
|
74
|
+
"headless-commerce",
|
|
59
75
|
"headless-shopify",
|
|
60
76
|
"selling-plans",
|
|
61
77
|
"shopify",
|
|
62
78
|
"shopify-subscriptions",
|
|
63
79
|
"shopyflow-alternative",
|
|
64
80
|
"smootify-alternative",
|
|
81
|
+
"ssr",
|
|
65
82
|
"storefront-api",
|
|
66
83
|
"storesynk-alternative",
|
|
67
84
|
"subscriptions",
|
|
68
85
|
"webflow",
|
|
69
86
|
"webflow-cms",
|
|
70
87
|
"webflow-ecommerce",
|
|
71
|
-
"webflow-shopify"
|
|
88
|
+
"webflow-shopify",
|
|
89
|
+
"withastro"
|
|
72
90
|
],
|
|
73
91
|
"publishConfig": {
|
|
74
92
|
"access": "public"
|
|
75
93
|
},
|
|
76
|
-
"types": "types/index.d.ts"
|
|
94
|
+
"types": "types/index.d.ts",
|
|
95
|
+
"peerDependencies": {
|
|
96
|
+
"astro": ">=4"
|
|
97
|
+
},
|
|
98
|
+
"peerDependenciesMeta": {
|
|
99
|
+
"astro": {
|
|
100
|
+
"optional": true
|
|
101
|
+
}
|
|
102
|
+
}
|
|
77
103
|
}
|
package/src/account.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer accounts (Shopify Customer Account API), as an optional part: loaded only when
|
|
3
|
+
* config.account is set and the page uses it (or a login is in progress / the visitor is logged in).
|
|
4
|
+
*
|
|
5
|
+
* window.TETHER_CONFIG = { …, account: { clientId: '…' } } // Headless channel → Customer Account API
|
|
6
|
+
*
|
|
7
|
+
* <button tether-account-login>Log in</button>
|
|
8
|
+
* <button tether-account-logout tether-show="logged-in">Log out</button>
|
|
9
|
+
* <span tether-account-bind="first-name"></span> first-name, last-name, name, email
|
|
10
|
+
* <div tether-orders> the customer's orders (first child = template)
|
|
11
|
+
* <a tether-order-bind="url"><span tether-order-bind="name"></span> · <span tether-order-bind="date"></span> ·
|
|
12
|
+
* <span tether-order-bind="total"></span> · <span tether-order-bind="status"></span></a>
|
|
13
|
+
* </div>
|
|
14
|
+
* <p tether-show="logged-out">…</p>
|
|
15
|
+
*
|
|
16
|
+
* Everything runs between the visitor's browser and Shopify: login on Shopify's own page (OAuth with
|
|
17
|
+
* PKCE), tokens kept in this browser (window.sessionStorage; account.remember: true for window.localStorage), the
|
|
18
|
+
* cart linked to the customer so checkout knows them. No other server sees the customer's data.
|
|
19
|
+
* Shopify needs HTTPS (no localhost: use a tunnel) and the site's origin, callback and logout URLs
|
|
20
|
+
* registered in the Headless channel's Customer Account API settings (client type: Public).
|
|
21
|
+
*/
|
|
22
|
+
import { cfg, log, reportError } from './config.js';
|
|
23
|
+
import { TetherError } from './client.js';
|
|
24
|
+
import { sel, get, qsa, setText, repeat, show, formatMoney, emit } from './dom.js';
|
|
25
|
+
import { Cart } from './cart.js';
|
|
26
|
+
|
|
27
|
+
const KEY = () => `${cfg.prefix}:account:${cfg.domain}`;
|
|
28
|
+
const PENDING = () => `${cfg.prefix}:account-login:${cfg.domain}`;
|
|
29
|
+
const store = () => (cfg.account?.remember ? window.localStorage : window.sessionStorage);
|
|
30
|
+
const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
31
|
+
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
|
|
32
|
+
const sha256 = async (s) => b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));
|
|
33
|
+
const go = (url) => (cfg.account?.navigate || ((u) => window.location.assign(u)))(url); // overridable (popup, tests)
|
|
34
|
+
const read = (s, k) => { try { return JSON.parse(s.getItem(k) || 'null'); } catch { return null; } };
|
|
35
|
+
const write = (s, k, v) => { try { if (v) s.setItem(k, JSON.stringify(v)); else s.removeItem(k); } catch { /* private mode */ } };
|
|
36
|
+
|
|
37
|
+
let discovery = null;
|
|
38
|
+
let session = null; // { access, expiresAt, refresh, idToken }
|
|
39
|
+
let refreshing = null;
|
|
40
|
+
let customerCache = null;
|
|
41
|
+
|
|
42
|
+
// Server mode (account.server): the Worker holds the tokens, this browser only a session cookie it
|
|
43
|
+
// can't read. Recommended for live sites.
|
|
44
|
+
const server = () => (cfg.account?.server ? String(cfg.account.server).replace(/\/$/, '') : '');
|
|
45
|
+
const HINT = () => `${cfg.prefix}:account-hint:${cfg.domain}`; // "may be logged in": loads this part, no secret
|
|
46
|
+
let serverState = null; // { loggedIn, customer }
|
|
47
|
+
async function serverFetch(path, { method = 'GET', body } = {}) {
|
|
48
|
+
const res = await fetch(`${server()}${path}`, {
|
|
49
|
+
method, credentials: 'include',
|
|
50
|
+
headers: method === 'POST' ? { 'Content-Type': 'application/json', 'X-Tether-Account': '1' } : {},
|
|
51
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
52
|
+
});
|
|
53
|
+
const data = await res.json().catch(() => ({}));
|
|
54
|
+
if (!res.ok && res.status !== 401) throw new TetherError(`Customer accounts: ${data.error || res.status}`, { status: res.status });
|
|
55
|
+
return data;
|
|
56
|
+
}
|
|
57
|
+
async function serverMe() {
|
|
58
|
+
serverState ||= await serverFetch('/me');
|
|
59
|
+
try { if (serverState.loggedIn) window.localStorage.setItem(HINT(), '1'); else window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
|
|
60
|
+
return serverState;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shopify's endpoints for this shop (they differ for shops with their own accounts domain). */
|
|
64
|
+
async function discover() {
|
|
65
|
+
if (discovery) return discovery;
|
|
66
|
+
const shop = cfg.account?.shopDomain || cfg.domain;
|
|
67
|
+
const get = (p) => fetch(`https://${shop}/.well-known/${p}`).then((r) => {
|
|
68
|
+
if (!r.ok) throw new TetherError(`Customer accounts: ${p} answered ${r.status}`, { status: r.status });
|
|
69
|
+
return r.json();
|
|
70
|
+
});
|
|
71
|
+
const [oidc, api] = await Promise.all([get('openid-configuration'), get('customer-account-api')]);
|
|
72
|
+
discovery = { ...oidc, graphql: api.graphql_api };
|
|
73
|
+
return discovery;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const callbackUrl = () => cfg.account?.callbackUrl || `${window.location.origin}${window.location.pathname}`;
|
|
77
|
+
|
|
78
|
+
export function isLoggedIn() { return server() ? Boolean(serverState?.loggedIn) : Boolean(session?.refresh || (session?.access && session.expiresAt > Date.now())); }
|
|
79
|
+
|
|
80
|
+
/** Redirects to Shopify's login page; the visitor comes back to account.callbackUrl (default: this page). */
|
|
81
|
+
export async function login({ returnTo = window.location.href, locale } = {}) {
|
|
82
|
+
if (server()) {
|
|
83
|
+
const u = new URL(`${server()}/login`);
|
|
84
|
+
u.searchParams.set('returnTo', returnTo);
|
|
85
|
+
if (Cart.id()) u.searchParams.set('cart', Cart.id());
|
|
86
|
+
const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
|
|
87
|
+
if (lang) u.searchParams.set('locale', lang);
|
|
88
|
+
if (cfg.country) u.searchParams.set('country', String(cfg.country).toUpperCase());
|
|
89
|
+
go(u.toString());
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!cfg.account?.clientId) throw new TetherError('Customer accounts: set account.clientId', { code: 'NOT_CONFIGURED' });
|
|
93
|
+
const d = await discover();
|
|
94
|
+
const state = random();
|
|
95
|
+
const nonce = random();
|
|
96
|
+
const verifier = random();
|
|
97
|
+
const redirect = callbackUrl();
|
|
98
|
+
write(window.sessionStorage, PENDING(), { state, nonce, verifier, redirect, returnTo, at: Date.now() });
|
|
99
|
+
const u = new URL(d.authorization_endpoint);
|
|
100
|
+
const p = {
|
|
101
|
+
client_id: cfg.account.clientId, scope: 'openid email customer-account-api:full', response_type: 'code',
|
|
102
|
+
redirect_uri: redirect, state, nonce, code_challenge: await sha256(verifier), code_challenge_method: 'S256',
|
|
103
|
+
};
|
|
104
|
+
const lang = locale || cfg.account.locale || (cfg.locale || '').split('-')[0];
|
|
105
|
+
if (lang) p.locale = lang;
|
|
106
|
+
if (cfg.country) p.region_country = String(cfg.country).toUpperCase();
|
|
107
|
+
Object.entries(p).forEach(([k, v]) => u.searchParams.set(k, v));
|
|
108
|
+
go(u.toString());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Token endpoint: from the browser (a "simple" request), or account.tokenUrl on your own server. */
|
|
112
|
+
async function tokenRequest(params) {
|
|
113
|
+
const d = await discover();
|
|
114
|
+
const res = await fetch(cfg.account.tokenUrl || d.token_endpoint, { method: 'POST', body: new URLSearchParams(params) });
|
|
115
|
+
const data = await res.json().catch(() => ({}));
|
|
116
|
+
if (!res.ok || !data.access_token) throw new TetherError(`Customer accounts: token ${res.status} ${data.error || ''}`.trim(), { status: res.status, code: res.status === 400 || res.status === 401 ? 'SESSION_ENDED' : 'TOKEN_FAILED' });
|
|
117
|
+
return data;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function decodeJwt(t) {
|
|
121
|
+
try { return JSON.parse(atob(t.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); } catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function keep(data, previous = session) {
|
|
125
|
+
session = {
|
|
126
|
+
access: data.access_token,
|
|
127
|
+
expiresAt: Date.now() + Math.max(0, (Number(data.expires_in) || 3600) - 120) * 1000,
|
|
128
|
+
refresh: data.refresh_token || previous?.refresh,
|
|
129
|
+
idToken: data.id_token || previous?.idToken,
|
|
130
|
+
};
|
|
131
|
+
write(store(), KEY(), session);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** On the callback page: ?code=&state= from Shopify. Returns true when a login finished. */
|
|
135
|
+
export async function handleCallback() {
|
|
136
|
+
const q = new URLSearchParams(window.location.search);
|
|
137
|
+
const pending = read(window.sessionStorage, PENDING());
|
|
138
|
+
if (!pending || (!q.has('code') && !q.has('error')) || q.get('state') !== pending.state) return false;
|
|
139
|
+
write(window.sessionStorage, PENDING(), null);
|
|
140
|
+
const clean = new URL(window.location.href);
|
|
141
|
+
['code', 'state', 'iss', 'error', 'error_description'].forEach((k) => clean.searchParams.delete(k));
|
|
142
|
+
window.history.replaceState(window.history.state, '', clean);
|
|
143
|
+
if (q.has('error')) { emit('account-error', { error: q.get('error') }); return false; }
|
|
144
|
+
if (Date.now() - pending.at > 10 * 60000) throw new TetherError('Customer accounts: the login took too long, please try again');
|
|
145
|
+
const data = await tokenRequest({
|
|
146
|
+
grant_type: 'authorization_code', client_id: cfg.account.clientId, redirect_uri: pending.redirect,
|
|
147
|
+
code: q.get('code'), code_verifier: pending.verifier,
|
|
148
|
+
});
|
|
149
|
+
const d = await discover();
|
|
150
|
+
const claims = decodeJwt(data.id_token || '');
|
|
151
|
+
const aud = [].concat(claims?.aud || []);
|
|
152
|
+
if (!claims || claims.nonce !== pending.nonce || claims.iss !== d.issuer || !aud.includes(cfg.account.clientId) || claims.exp * 1000 < Date.now()) {
|
|
153
|
+
throw new TetherError('Customer accounts: the login answer did not check out (nonce/issuer/audience)');
|
|
154
|
+
}
|
|
155
|
+
keep(data, null);
|
|
156
|
+
await linkCart();
|
|
157
|
+
emit('account-login', {});
|
|
158
|
+
const back = pending.returnTo && new URL(pending.returnTo, window.location.href);
|
|
159
|
+
if (back && back.origin === window.location.origin && back.href !== clean.href) go(back.href);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A valid access token (refreshed when needed, one refresh at a time), or null when logged out. */
|
|
164
|
+
export async function getToken() {
|
|
165
|
+
if (server()) return null; // server mode: tokens never come to the browser
|
|
166
|
+
if (!session) session = read(store(), KEY());
|
|
167
|
+
if (!session) return null;
|
|
168
|
+
if (session.access && session.expiresAt > Date.now()) return session.access;
|
|
169
|
+
if (!session.refresh) { endSession(); return null; }
|
|
170
|
+
refreshing ||= (async () => {
|
|
171
|
+
try {
|
|
172
|
+
keep(await tokenRequest({ grant_type: 'refresh_token', client_id: cfg.account.clientId, refresh_token: session.refresh }));
|
|
173
|
+
await linkCart();
|
|
174
|
+
} catch (err) {
|
|
175
|
+
if (err.code === 'SESSION_ENDED') endSession(); else throw err;
|
|
176
|
+
} finally { refreshing = null; }
|
|
177
|
+
})();
|
|
178
|
+
await refreshing;
|
|
179
|
+
return session?.access || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function endSession() {
|
|
183
|
+
session = null;
|
|
184
|
+
customerCache = null;
|
|
185
|
+
write(store(), KEY(), null);
|
|
186
|
+
Cart.customerAccessToken = null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** The customer's checkout is theirs: saved addresses, vaulted cards, order window.history. */
|
|
190
|
+
async function linkCart() {
|
|
191
|
+
Cart.customerAccessToken = session?.access || null;
|
|
192
|
+
if (Cart.id() && session?.access) {
|
|
193
|
+
await Cart.setBuyerIdentity({ customerAccessToken: session.access }, { create: false }).catch((err) => reportError(err, 'account cart'));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Any Customer Account API query. */
|
|
198
|
+
export async function query(q, variables = {}) {
|
|
199
|
+
if (server()) throw new TetherError('Customer accounts in server mode only answer customer() and orders()', { code: 'NOT_ALLOWED' });
|
|
200
|
+
const token = await getToken();
|
|
201
|
+
if (!token) throw new TetherError('Not logged in', { code: 'NOT_LOGGED_IN' });
|
|
202
|
+
const d = await discover();
|
|
203
|
+
const run = (t) => fetch(d.graphql, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: t }, body: JSON.stringify({ query: q, variables }) });
|
|
204
|
+
let res = await run(token);
|
|
205
|
+
if (res.status === 401 && session?.refresh) { session.expiresAt = 0; res = await run(await getToken()); }
|
|
206
|
+
const json = await res.json().catch(() => ({}));
|
|
207
|
+
if (!res.ok || json.errors?.length) throw new TetherError(json.errors?.map((e) => e.message).join(' | ') || `Customer accounts: ${res.status}`, { status: res.status });
|
|
208
|
+
return json.data;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function customer() {
|
|
212
|
+
if (server()) {
|
|
213
|
+
const me = await serverMe();
|
|
214
|
+
return me.loggedIn ? { ...me.customer, emailAddress: { emailAddress: me.customer.email }, defaultAddress: { formatted: me.customer.address } } : null;
|
|
215
|
+
}
|
|
216
|
+
if (customerCache) return customerCache;
|
|
217
|
+
const data = await query(`query TetherCustomer { customer { id firstName lastName displayName emailAddress { emailAddress } defaultAddress { formatted } } }`);
|
|
218
|
+
customerCache = data.customer;
|
|
219
|
+
return customerCache;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function orders({ first = 10 } = {}) {
|
|
223
|
+
if (server()) return (await serverFetch(`/orders?first=${first}`)).orders || [];
|
|
224
|
+
const data = await query(`query TetherOrders($first: Int!) { customer { orders(first: $first, sortKey: PROCESSED_AT, reverse: true) {
|
|
225
|
+
nodes { id name processedAt financialStatus fulfillmentStatus statusPageUrl totalPrice { amount currencyCode }
|
|
226
|
+
lineItems(first: 20) { nodes { title variantTitle quantity image { url altText } } } } } } }`, { first });
|
|
227
|
+
return data.customer?.orders?.nodes || [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Logs out here and at Shopify, unlinks the cart. */
|
|
231
|
+
export async function logout({ returnTo = cfg.account?.logoutUrl || `${window.location.origin}/` } = {}) {
|
|
232
|
+
if (server()) {
|
|
233
|
+
const r = await serverFetch('/logout', { method: 'POST', body: { returnTo, cartId: Cart.id() } });
|
|
234
|
+
serverState = { loggedIn: false };
|
|
235
|
+
try { window.localStorage.removeItem(HINT()); } catch { /* private mode */ }
|
|
236
|
+
emit('account-logout', {});
|
|
237
|
+
if (r.logoutUrl) go(r.logoutUrl); else render(document);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const idToken = session?.idToken || read(store(), KEY())?.idToken;
|
|
241
|
+
if (Cart.id()) await Cart.setBuyerIdentity({ customerAccessToken: null }, { create: false }).catch(() => {});
|
|
242
|
+
endSession();
|
|
243
|
+
emit('account-logout', {});
|
|
244
|
+
if (!idToken) { render(document); return; }
|
|
245
|
+
const d = await discover();
|
|
246
|
+
const u = new URL(d.end_session_endpoint);
|
|
247
|
+
u.searchParams.set('id_token_hint', idToken);
|
|
248
|
+
u.searchParams.set('post_logout_redirect_uri', returnTo);
|
|
249
|
+
go(u.toString());
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------------------------------------------------------------- markup
|
|
253
|
+
|
|
254
|
+
const date = (s) => { try { return new Date(s).toLocaleDateString(cfg.locale || undefined); } catch { return s; } };
|
|
255
|
+
|
|
256
|
+
export async function render(root = document) {
|
|
257
|
+
const logged = server() ? Boolean((await serverMe().catch(() => ({ loggedIn: false }))).loggedIn) : isLoggedIn() || Boolean(await getToken().catch(() => null));
|
|
258
|
+
qsa(root, sel('show')).forEach((el) => {
|
|
259
|
+
const k = get(el, 'show');
|
|
260
|
+
if (k === 'logged-in') show(el, logged);
|
|
261
|
+
if (k === 'logged-out') show(el, !logged);
|
|
262
|
+
});
|
|
263
|
+
if (!logged) return;
|
|
264
|
+
const binds = qsa(root, sel('account-bind'));
|
|
265
|
+
if (binds.length) {
|
|
266
|
+
const c = await customer().catch((err) => { reportError(err, 'account'); return null; });
|
|
267
|
+
binds.forEach((el) => {
|
|
268
|
+
const v = { 'first-name': c?.firstName, 'last-name': c?.lastName, name: c?.displayName, email: c?.emailAddress?.emailAddress, address: c?.defaultAddress?.formatted?.join(', ') }[get(el, 'account-bind')];
|
|
269
|
+
if (v != null) setText(el, v);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const lists = qsa(root, sel('orders'));
|
|
273
|
+
if (lists.length) {
|
|
274
|
+
const list = await orders({ first: parseInt(get(lists[0], 'limit') || '10', 10) }).catch((err) => { reportError(err, 'orders'); return []; });
|
|
275
|
+
lists.forEach((c) => repeat(c, list, (node, o) => {
|
|
276
|
+
qsa(node, sel('order-bind')).forEach((el) => {
|
|
277
|
+
const k = get(el, 'order-bind');
|
|
278
|
+
if (k === 'url') { el.setAttribute('href', o.statusPageUrl); return; }
|
|
279
|
+
const v = { name: o.name, date: date(o.processedAt), total: formatMoney(o.totalPrice), status: o.financialStatus?.toLowerCase(), fulfillment: o.fulfillmentStatus?.toLowerCase(), items: o.lineItems.nodes.map((l) => `${l.quantity}× ${l.title}`).join(', ') }[k];
|
|
280
|
+
if (v != null) setText(el, v);
|
|
281
|
+
});
|
|
282
|
+
if (get(node, 'order-bind') === 'url') node.setAttribute('href', o.statusPageUrl);
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let bound = false;
|
|
288
|
+
/** Called by the core on mount: finish a login that's coming back, then fill the page. */
|
|
289
|
+
export async function mountAccount(root = document) {
|
|
290
|
+
if (!bound) {
|
|
291
|
+
bound = true;
|
|
292
|
+
document.addEventListener('click', (e) => {
|
|
293
|
+
const t = e.target;
|
|
294
|
+
if (t.closest?.(sel('account-login'))) { e.preventDefault(); login().catch((err) => reportError(err, 'login')); }
|
|
295
|
+
else if (t.closest?.(sel('account-logout'))) { e.preventDefault(); logout().catch((err) => reportError(err, 'logout')); }
|
|
296
|
+
});
|
|
297
|
+
if (server()) {
|
|
298
|
+
// Back from the Worker's login: drop ?account=in; at checkout the Worker links the cart.
|
|
299
|
+
const u = new URL(window.location.href);
|
|
300
|
+
if (u.searchParams.get('account')) { u.searchParams.delete('account'); window.history.replaceState(window.history.state, '', u); emit('account-login', {}); }
|
|
301
|
+
Cart.beforeCheckout = async () => {
|
|
302
|
+
if (!(await serverMe()).loggedIn || !Cart.id()) return null;
|
|
303
|
+
const r = await serverFetch('/cart', { method: 'POST', body: { cartId: Cart.id() } }).catch(() => null);
|
|
304
|
+
return r?.checkoutUrl ? `${r.checkoutUrl}${r.checkoutUrl.includes('?') ? '&' : '?'}logged_in=true` : null;
|
|
305
|
+
};
|
|
306
|
+
} else {
|
|
307
|
+
try { await handleCallback(); } catch (err) { reportError(err, 'login'); }
|
|
308
|
+
if (!Cart.customerAccessToken && (await getToken().catch(() => null))) Cart.customerAccessToken = session.access;
|
|
309
|
+
}
|
|
310
|
+
log('account', isLoggedIn() ? 'logged in' : 'logged out');
|
|
311
|
+
}
|
|
312
|
+
await render(root);
|
|
313
|
+
}
|