@redeemid/emdash 0.0.16 → 0.0.18

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 (73) hide show
  1. package/README.md +447 -232
  2. package/bin/redeemid-emdash.mjs +13 -1
  3. package/docs/developer-api.md +126 -0
  4. package/docs/shopify-setup.md +1 -1
  5. package/package.json +3 -1
  6. package/src/access-admin.ts +754 -0
  7. package/src/access.ts +84 -18
  8. package/src/admin-api.ts +9 -2
  9. package/src/agents.ts +62 -3
  10. package/src/api-version.ts +6 -0
  11. package/src/blocks/CollectionBlock.astro +4 -1
  12. package/src/blocks/SearchBlock.astro +14 -0
  13. package/src/blocks/index.ts +2 -0
  14. package/src/blocks-names.ts +1 -0
  15. package/src/cart-ops.ts +164 -0
  16. package/src/catalog.ts +287 -0
  17. package/src/checkout.ts +73 -0
  18. package/src/client/redeemid.ts +563 -0
  19. package/src/components/AccessBadge.astro +7 -5
  20. package/src/components/Account.astro +1 -1
  21. package/src/components/BrowseControls.astro +87 -0
  22. package/src/components/Cart.astro +12 -6
  23. package/src/components/CartDrawer.astro +59 -0
  24. package/src/components/CartView.astro +2 -1
  25. package/src/components/Collection.astro +23 -18
  26. package/src/components/Content.astro +10 -5
  27. package/src/components/Contribution.astro +1 -1
  28. package/src/components/Gate.astro +4 -2
  29. package/src/components/LockedPanel.astro +5 -3
  30. package/src/components/MemberCard.astro +1 -1
  31. package/src/components/Notices.astro +3 -28
  32. package/src/components/Plan.astro +1 -1
  33. package/src/components/Preferences.astro +1 -1
  34. package/src/components/Product.astro +116 -25
  35. package/src/components/ProductGrid.astro +52 -0
  36. package/src/components/ProductSearch.astro +83 -0
  37. package/src/components/Recommendations.astro +45 -0
  38. package/src/components/RedeemIDClient.astro +45 -0
  39. package/src/components/YourData.astro +1 -1
  40. package/src/config.ts +31 -5
  41. package/src/contract.ts +50 -0
  42. package/src/descriptor.ts +7 -0
  43. package/src/events.ts +3 -0
  44. package/src/features.ts +1 -1
  45. package/src/files.ts +1 -1
  46. package/src/guard.ts +5 -3
  47. package/src/index.ts +3 -0
  48. package/src/log.ts +96 -0
  49. package/src/monitor.ts +560 -0
  50. package/src/notices.ts +29 -0
  51. package/src/plugin.ts +234 -88
  52. package/src/rate-limit.ts +7 -0
  53. package/src/routes/api/cart.ts +84 -0
  54. package/src/routes/api/search.ts +49 -0
  55. package/src/routes/api/shared.ts +4 -0
  56. package/src/routes/api/state.ts +69 -0
  57. package/src/routes/buy.ts +4 -3
  58. package/src/routes/callback.ts +23 -5
  59. package/src/routes/cart-edit.ts +15 -66
  60. package/src/routes/cart.ts +4 -2
  61. package/src/routes/preferences.ts +2 -2
  62. package/src/routes/privacy.ts +2 -2
  63. package/src/routes/shared.ts +11 -0
  64. package/src/routes/webhook.ts +24 -11
  65. package/src/settings.ts +195 -0
  66. package/src/shopify.ts +37 -1
  67. package/src/site-config.ts +38 -5
  68. package/src/staff.ts +12 -23
  69. package/src/storage.ts +12 -0
  70. package/src/storefront.ts +231 -59
  71. package/src/text.ts +43 -2
  72. package/src/version.ts +1 -1
  73. package/src/webhooks.ts +2 -1
package/src/catalog.ts ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Catalog browsing through the Storefront API: product search, a
3
+ * collection's products with Shopify's filters and sorting, paging,
4
+ * predictive search and recommendations.
5
+ *
6
+ * Filters are Shopify's own (Settings in the Search & Discovery app decide
7
+ * which a store offers). Each filter value carries Shopify's `input`, a JSON
8
+ * string, which goes back to Shopify as it came: nothing here invents a
9
+ * filter or a price.
10
+ */
11
+
12
+ import { shopOrigin, type RedeemIDConfig } from "./config.js";
13
+ import { CARD_FIELDS, cachedRead, contextKey, storefront, toCard, type Money, type ProductCard, type RawCard, type StoreContext } from "./storefront.js";
14
+
15
+ export interface FilterValue {
16
+ id: string;
17
+ label: string;
18
+ count: number;
19
+ /** Shopify's filter input, as a JSON string. */
20
+ input: string;
21
+ }
22
+
23
+ export interface Filter {
24
+ id: string;
25
+ label: string;
26
+ /** LIST, PRICE_RANGE or BOOLEAN. */
27
+ type: string;
28
+ values: FilterValue[];
29
+ }
30
+
31
+ export interface PageInfo {
32
+ hasNextPage: boolean;
33
+ hasPreviousPage: boolean;
34
+ startCursor: string | null;
35
+ endCursor: string | null;
36
+ }
37
+
38
+ export interface ProductPage {
39
+ products: ProductCard[];
40
+ filters: Filter[];
41
+ pageInfo: PageInfo;
42
+ /** Search only: how many products match. */
43
+ total?: number;
44
+ }
45
+
46
+ export interface CollectionPage extends ProductPage {
47
+ handle: string;
48
+ title: string;
49
+ description: string;
50
+ }
51
+
52
+ /** Sort orders a visitor can pick. Search offers relevance and price only. */
53
+ export const SORTS = ["relevance", "best-selling", "newest", "price-asc", "price-desc", "title-asc", "title-desc"] as const;
54
+ export type Sort = (typeof SORTS)[number];
55
+ export const SEARCH_SORTS: readonly Sort[] = ["relevance", "price-asc", "price-desc"];
56
+
57
+ export interface Browse {
58
+ /** Products per page, 1 to 50. */
59
+ first?: number;
60
+ /** Cursor of the page before (next page). */
61
+ after?: string | null;
62
+ /** Cursor of the page after (previous page). */
63
+ before?: string | null;
64
+ sort?: Sort | null;
65
+ /** Shopify filter inputs (parsed objects), at most 20. */
66
+ filters?: Array<Record<string, unknown>>;
67
+ }
68
+
69
+ const MAX_FILTERS = 20;
70
+ const MAX_FILTER_LENGTH = 500;
71
+
72
+ /** Parse filter inputs from strings (URL `filter` values), dropping anything that isn't a small JSON object. */
73
+ export function parseFilters(raw: Iterable<string>): Array<Record<string, unknown>> {
74
+ const out: Array<Record<string, unknown>> = [];
75
+ for (const s of raw) {
76
+ if (out.length >= MAX_FILTERS) break;
77
+ if (typeof s !== "string" || s.length > MAX_FILTER_LENGTH) continue;
78
+ try {
79
+ const v = JSON.parse(s) as unknown;
80
+ if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 1) out.push(v as Record<string, unknown>);
81
+ } catch {
82
+ // not a filter
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ /** A price filter from min and max (either may be blank). */
89
+ export function priceFilter(min: unknown, max: unknown): Record<string, unknown> | null {
90
+ const n = (v: unknown) => {
91
+ const x = typeof v === "string" && v.trim() !== "" ? Number(v) : typeof v === "number" ? v : Number.NaN;
92
+ return Number.isFinite(x) && x >= 0 ? x : null;
93
+ };
94
+ const lo = n(min);
95
+ const hi = n(max);
96
+ if (lo === null && hi === null) return null;
97
+ return { price: { ...(lo !== null ? { min: lo } : {}), ...(hi !== null ? { max: hi } : {}) } };
98
+ }
99
+
100
+ /** Read browsing from a URL's search params: q, sort, after, before, filter (repeatable), price_min, price_max. */
101
+ export function browseFromUrl(params: URLSearchParams, first = 24): Browse & { query: string } {
102
+ const filters = parseFilters(params.getAll("filter"));
103
+ const price = priceFilter(params.get("price_min"), params.get("price_max"));
104
+ if (price && filters.length < MAX_FILTERS) filters.push(price);
105
+ const sort = params.get("sort");
106
+ const cursor = (v: string | null) => (v && v.length <= 300 ? v : null);
107
+ return {
108
+ query: (params.get("q") ?? "").trim().slice(0, 200),
109
+ first,
110
+ after: cursor(params.get("after")),
111
+ before: cursor(params.get("before")),
112
+ sort: (SORTS as readonly string[]).includes(sort ?? "") ? (sort as Sort) : null,
113
+ filters,
114
+ };
115
+ }
116
+
117
+ function collectionSort(sort: Sort | null | undefined): { sortKey: string; reverse: boolean } | null {
118
+ switch (sort) {
119
+ case "best-selling":
120
+ return { sortKey: "BEST_SELLING", reverse: false };
121
+ case "newest":
122
+ return { sortKey: "CREATED", reverse: true };
123
+ case "price-asc":
124
+ return { sortKey: "PRICE", reverse: false };
125
+ case "price-desc":
126
+ return { sortKey: "PRICE", reverse: true };
127
+ case "title-asc":
128
+ return { sortKey: "TITLE", reverse: false };
129
+ case "title-desc":
130
+ return { sortKey: "TITLE", reverse: true };
131
+ case "relevance":
132
+ return { sortKey: "RELEVANCE", reverse: false };
133
+ default:
134
+ return null;
135
+ }
136
+ }
137
+
138
+ function searchSort(sort: Sort | null | undefined): { sortKey: string; reverse: boolean } {
139
+ if (sort === "price-asc") return { sortKey: "PRICE", reverse: false };
140
+ if (sort === "price-desc") return { sortKey: "PRICE", reverse: true };
141
+ return { sortKey: "RELEVANCE", reverse: false };
142
+ }
143
+
144
+ function paging(b: Browse): Record<string, unknown> {
145
+ const n = Math.max(1, Math.min(50, Math.floor(b.first ?? 24)));
146
+ return b.before ? { last: n, before: b.before } : { first: n, after: b.after ?? null };
147
+ }
148
+
149
+ const FILTER_FIELDS = "id label type values { id label count input }";
150
+ const PAGE_FIELDS = "hasNextPage hasPreviousPage startCursor endCursor";
151
+
152
+ type RawPage = { filters?: Filter[]; productFilters?: Filter[]; pageInfo: PageInfo; nodes: Array<RawCard | Record<string, never>>; totalCount?: number };
153
+
154
+ function toPage(p: RawPage): ProductPage {
155
+ return {
156
+ products: p.nodes.filter((n): n is RawCard => typeof (n as RawCard).handle === "string").map(toCard),
157
+ filters: p.filters ?? p.productFilters ?? [],
158
+ pageInfo: p.pageInfo,
159
+ ...(typeof p.totalCount === "number" ? { total: p.totalCount } : {}),
160
+ };
161
+ }
162
+
163
+ const SEARCH_QUERY = `query Search($query: String!, $first: Int, $last: Int, $after: String, $before: String, $sortKey: SearchSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
164
+ search(query: $query, first: $first, last: $last, after: $after, before: $before, types: [PRODUCT], sortKey: $sortKey, reverse: $reverse, productFilters: $filters, unavailableProducts: LAST) {
165
+ totalCount
166
+ productFilters { ${FILTER_FIELDS} }
167
+ pageInfo { ${PAGE_FIELDS} }
168
+ nodes { ... on Product { ${CARD_FIELDS} } }
169
+ }
170
+ }`;
171
+
172
+ /** Products matching a search, with the filters Shopify offers for them. Cached for five minutes. */
173
+ export async function searchProducts(
174
+ config: RedeemIDConfig,
175
+ query: string,
176
+ b: Browse = {},
177
+ opts: { fetcher?: typeof fetch; context?: StoreContext } = {},
178
+ ): Promise<ProductPage> {
179
+ const q = query.trim().slice(0, 200);
180
+ if (!q) return { products: [], filters: [], pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null }, total: 0 };
181
+ const vars = { query: q, ...paging(b), ...searchSort(b.sort), filters: b.filters ?? [] };
182
+ return cachedRead(`${shopOrigin(config)}|search|${JSON.stringify(vars)}|${contextKey(opts.context)}`, async () => {
183
+ const data = await storefront<{ search: RawPage }>(config, SEARCH_QUERY, vars, opts);
184
+ return toPage(data.search);
185
+ });
186
+ }
187
+
188
+ const COLLECTION_PAGE_QUERY = `query CollectionPage($handle: String!, $first: Int, $last: Int, $after: String, $before: String, $sortKey: ProductCollectionSortKeys, $reverse: Boolean, $filters: [ProductFilter!]) {
189
+ collection(handle: $handle) {
190
+ handle title description
191
+ products(first: $first, last: $last, after: $after, before: $before, sortKey: $sortKey, reverse: $reverse, filters: $filters) {
192
+ filters { ${FILTER_FIELDS} }
193
+ pageInfo { ${PAGE_FIELDS} }
194
+ nodes { ${CARD_FIELDS} }
195
+ }
196
+ }
197
+ }`;
198
+
199
+ /** One page of a collection's products, with its filters. Null when Shopify has no such collection. Cached for five minutes. */
200
+ export async function collectionPage(
201
+ config: RedeemIDConfig,
202
+ handle: string,
203
+ b: Browse = {},
204
+ opts: { fetcher?: typeof fetch; context?: StoreContext } = {},
205
+ ): Promise<CollectionPage | null> {
206
+ const sort = collectionSort(b.sort);
207
+ const vars = { handle, ...paging(b), ...(sort ?? {}), filters: b.filters ?? [] };
208
+ return cachedRead(`${shopOrigin(config)}|collection-page|${JSON.stringify(vars)}|${contextKey(opts.context)}`, async () => {
209
+ type Data = { collection: { handle: string; title: string; description: string; products: RawPage } | null };
210
+ const { collection: c } = await storefront<Data>(config, COLLECTION_PAGE_QUERY, vars, opts);
211
+ if (!c) return null;
212
+ return { handle: c.handle, title: c.title, description: c.description, ...toPage(c.products) };
213
+ });
214
+ }
215
+
216
+ export interface Suggestions {
217
+ queries: string[];
218
+ products: ProductCard[];
219
+ collections: Array<{ handle: string; title: string }>;
220
+ }
221
+
222
+ const PREDICTIVE_QUERY = `query Predictive($query: String!, $limit: Int!) {
223
+ predictiveSearch(query: $query, limit: $limit, types: [QUERY, PRODUCT, COLLECTION], unavailableProducts: LAST) {
224
+ queries { text }
225
+ products { ${CARD_FIELDS} }
226
+ collections { handle title }
227
+ }
228
+ }`;
229
+
230
+ /** Suggestions while a visitor types: search terms, products and collections. Cached for five minutes. */
231
+ export async function suggest(
232
+ config: RedeemIDConfig,
233
+ query: string,
234
+ limit = 6,
235
+ opts: { fetcher?: typeof fetch; context?: StoreContext } = {},
236
+ ): Promise<Suggestions> {
237
+ const q = query.trim().slice(0, 100);
238
+ if (!q) return { queries: [], products: [], collections: [] };
239
+ const n = Math.max(1, Math.min(10, Math.floor(limit)));
240
+ return cachedRead(`${shopOrigin(config)}|suggest|${q.toLowerCase()}|${n}|${contextKey(opts.context)}`, async () => {
241
+ type Data = { predictiveSearch: { queries: Array<{ text: string }>; products: RawCard[]; collections: Array<{ handle: string; title: string }> } | null };
242
+ const { predictiveSearch: p } = await storefront<Data>(config, PREDICTIVE_QUERY, { query: q, limit: n }, opts);
243
+ return { queries: (p?.queries ?? []).map((x) => x.text), products: (p?.products ?? []).map(toCard), collections: p?.collections ?? [] };
244
+ });
245
+ }
246
+
247
+ const RECOMMENDATIONS_QUERY = `query Recommendations($id: ID!, $intent: ProductRecommendationIntent) {
248
+ productRecommendations(productId: $id, intent: $intent) { ${CARD_FIELDS} }
249
+ }`;
250
+
251
+ /** Shopify's recommendations for a product (by its gid): "related" (You may also like) or "complementary" (Pair it with). */
252
+ export async function recommendations(
253
+ config: RedeemIDConfig,
254
+ productId: string,
255
+ intent: "related" | "complementary" = "related",
256
+ opts: { fetcher?: typeof fetch; context?: StoreContext } = {},
257
+ ): Promise<ProductCard[]> {
258
+ if (!/^gid:\/\/shopify\/Product\/\d+$/.test(productId)) return [];
259
+ return cachedRead(`${shopOrigin(config)}|recs|${productId}|${intent}|${contextKey(opts.context)}`, async () => {
260
+ const data = await storefront<{ productRecommendations: RawCard[] | null }>(
261
+ config,
262
+ RECOMMENDATIONS_QUERY,
263
+ { id: productId, intent: intent === "complementary" ? "COMPLEMENTARY" : "RELATED" },
264
+ opts,
265
+ );
266
+ return (data.productRecommendations ?? []).map(toCard);
267
+ });
268
+ }
269
+
270
+ /** A browse URL with one setting changed, for links and paging. Keeps everything else. */
271
+ export function browseHref(current: URL, change: Record<string, string | null>): string {
272
+ const params = new URLSearchParams(current.search);
273
+ params.delete("redeemid_notice");
274
+ for (const [k, v] of Object.entries(change)) {
275
+ if (v === null) params.delete(k);
276
+ else params.set(k, v);
277
+ }
278
+ // A new sort or filter starts from the first page.
279
+ if (!("after" in change) && !("before" in change)) {
280
+ params.delete("after");
281
+ params.delete("before");
282
+ }
283
+ const qs = params.toString();
284
+ return `${current.pathname}${qs ? `?${qs}` : ""}`;
285
+ }
286
+
287
+ export type { Money };
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The handoff to Shopify's checkout.
3
+ *
4
+ * A signed-in member's cart gets their Customer Account API access token
5
+ * (cartBuyerIdentityUpdate), so checkout opens signed in and the order lands
6
+ * on their Shopify customer, where the shop's Flow or apps tag it. The token
7
+ * lives only in the server-side session and is renewed with its refresh
8
+ * token when it's about to expire. Anything that fails falls back to a guest
9
+ * checkout: buying never breaks because sign-in couldn't be carried over.
10
+ */
11
+
12
+ import { env, ENV, type RedeemIDConfig } from "./config.js";
13
+ import { SESSION_CUSTOMER_TOKEN, callbackUrl, type CustomerToken } from "./routes/shared.js";
14
+ import { discover, refreshTokens } from "./shopify.js";
15
+ import { signInCart } from "./storefront.js";
16
+
17
+ type Session = { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
18
+
19
+ /** Renew this long before the token expires. */
20
+ const RENEW_MS = 2 * 60 * 1000;
21
+
22
+ /** The member's access token, renewed when it's close to expiring. Null for guests or when it can't be renewed. */
23
+ export async function customerToken(
24
+ config: RedeemIDConfig,
25
+ session: Session,
26
+ opts: { url: URL; emdashConfig?: unknown; fetcher?: typeof fetch; now?: number },
27
+ ): Promise<string | null> {
28
+ const t = (await session?.get(SESSION_CUSTOMER_TOKEN)) as CustomerToken | undefined;
29
+ if (!t?.accessToken) return null;
30
+ const now = opts.now ?? Date.now();
31
+ if (!t.expiresAt || t.expiresAt - now > RENEW_MS) return t.accessToken;
32
+ const forget = () => {
33
+ if (session?.delete) session.delete(SESSION_CUSTOMER_TOKEN);
34
+ else session?.set(SESSION_CUSTOMER_TOKEN, undefined);
35
+ };
36
+ const clientId = env(ENV.clientId);
37
+ if (!t.refreshToken || !clientId) {
38
+ forget();
39
+ return null;
40
+ }
41
+ try {
42
+ const redirect = callbackUrl(opts.url, opts.emdashConfig);
43
+ const next = await refreshTokens(
44
+ await discover(config, opts.fetcher),
45
+ { clientId, clientSecret: env(ENV.clientSecret), origin: new URL(redirect).origin, refreshToken: t.refreshToken },
46
+ opts.fetcher,
47
+ );
48
+ session?.set(SESSION_CUSTOMER_TOKEN, next satisfies CustomerToken);
49
+ return next.accessToken;
50
+ } catch {
51
+ forget();
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Where to send the visitor to pay for this cart: the signed-in checkout for
58
+ * a member (when config `checkout.signIn` isn't false), else the cart's own
59
+ * checkout URL.
60
+ */
61
+ export async function checkoutFor(
62
+ config: RedeemIDConfig,
63
+ cart: { id: string; checkoutUrl: string },
64
+ opts: { session: Session; request: Request; emdashConfig?: unknown; fetcher?: typeof fetch },
65
+ ): Promise<string> {
66
+ if (config.checkout?.signIn === false) return cart.checkoutUrl;
67
+ const user = (await opts.session?.get("user")) as { id?: string } | undefined;
68
+ if (!user?.id) return cart.checkoutUrl;
69
+ const token = await customerToken(config, opts.session, { url: new URL(opts.request.url), emdashConfig: opts.emdashConfig, fetcher: opts.fetcher });
70
+ if (!token) return cart.checkoutUrl;
71
+ const url = await signInCart(config, cart.id, token, { buyerIp: opts.request.headers.get("cf-connecting-ip"), fetcher: opts.fetcher });
72
+ return url ?? cart.checkoutUrl;
73
+ }