@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/access.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import type { LevelConfig, RedeemIDConfig, RuleConfig } from "./config.js";
14
+ import type { EntryRule } from "./settings.js";
14
15
  import { siteConfig } from "./site-config.js";
15
16
  import { memberForUser, stores, type MemberRecord } from "./storage.js";
16
17
 
@@ -108,9 +109,14 @@ function combined(rules: RuleConfig[], mode: "all" | "any"): string[][] {
108
109
  return reduce(rules.map((r) => r.levels));
109
110
  }
110
111
 
112
+ /** Rules that can cover `collection`: its own, and term rules for every collection ("*"). */
113
+ function rulesFor(rules: RuleConfig[], collection: string): RuleConfig[] {
114
+ return rules.filter((r) => r.collection === collection || (r.collection === "*" && r.taxonomy && r.term));
115
+ }
116
+
111
117
  /** The rules that decide an entry: matching term rules, else the collection's, else the site default. */
112
118
  function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?: TermTree): RuleConfig[] | "open" {
113
- const forCollection = rules.filter((r) => r.collection === collection);
119
+ const forCollection = rulesFor(rules, collection);
114
120
  const matched: RuleConfig[] = [];
115
121
  for (const [taxonomy, list] of Object.entries(terms)) {
116
122
  const taxRules = forCollection.filter((r) => r.taxonomy === taxonomy && r.term);
@@ -123,7 +129,9 @@ function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?:
123
129
  seen.add(slug);
124
130
  const own = taxRules.filter((r) => r.term === slug);
125
131
  if (own.length > 0) {
126
- matched.push(...own);
132
+ // A rule for this collection beats one for every collection.
133
+ const specific = own.filter((r) => r.collection === collection);
134
+ matched.push(...(specific.length ? specific : own));
127
135
  break;
128
136
  }
129
137
  slug = parents[slug] ?? null;
@@ -132,9 +140,54 @@ function deciding(rules: RuleConfig[], collection: string, terms: Terms, tree?:
132
140
  }
133
141
  if (matched.some((r) => r.open)) return "open";
134
142
  if (matched.length > 0) return matched;
135
- const whole = forCollection.filter((r) => !r.taxonomy && !r.term && !r.open);
143
+ const whole = forCollection.filter((r) => r.collection === collection && !r.taxonomy && !r.term);
144
+ if (whole.some((r) => r.open)) return "open";
136
145
  if (whole.length > 0) return whole;
137
- return rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term && !r.open);
146
+ const site = rules.filter((r) => r.collection === "*" && !r.taxonomy && !r.term);
147
+ if (site.some((r) => r.open)) return "open";
148
+ return site;
149
+ }
150
+
151
+ /** Where the site's rules for an entry come from, in words, for the editor. */
152
+ export function ruleSource(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): string {
153
+ const found = deciding(rules, collection, terms, tree);
154
+ const termRules = (list: RuleConfig[]) => [...new Set(list.map((r) => `${r.taxonomy}: ${r.term}`))].join(", ");
155
+ if (found === "open") {
156
+ const opener = rulesFor(rules, collection).filter((r) => r.open && r.taxonomy);
157
+ const hit = opener.filter((r) => (terms[r.taxonomy!] ?? []).some((t) => slugOf(t) === r.term));
158
+ if (hit.length) return `A term that makes it free (${termRules(hit)})`;
159
+ return rules.some((r) => r.collection === collection && r.open && !r.taxonomy) ? `The rule for ${collection} (free)` : "The site default (free)";
160
+ }
161
+ if (found.length === 0) return "No rule covers it";
162
+ if (found[0]!.taxonomy) return `Terms: ${termRules(found)}`;
163
+ if (found[0]!.collection === collection) return `The rule for ${collection}`;
164
+ return "The site default";
165
+ }
166
+
167
+ /** The entry's own rule (set in the editor), by id or slug. */
168
+ export function entryRuleFor(config: RedeemIDConfig | null, collection: string, id?: string | null): EntryRule | null {
169
+ if (!config?.entries || !id) return null;
170
+ return config.entries[`${collection}:${id}`] ?? null;
171
+ }
172
+
173
+ /**
174
+ * The site's requirement with the entry's own rule applied (see EntryRule).
175
+ * `now` decides the date windows.
176
+ */
177
+ export function applyEntryRule(site: Requirement, own: EntryRule | null, now = Date.now()): Requirement {
178
+ if (!own) return site;
179
+ if (own.openUntil && now < Date.parse(own.openUntil)) return null;
180
+ if (own.lockedUntil && now >= Date.parse(own.lockedUntil)) return null;
181
+ switch (own.mode) {
182
+ case "open":
183
+ return null;
184
+ case "levels":
185
+ return [own.levels];
186
+ case "add":
187
+ return reduce([...(site ?? []), own.levels]);
188
+ default:
189
+ return site;
190
+ }
138
191
  }
139
192
 
140
193
  /**
@@ -156,14 +209,18 @@ export function requirement(
156
209
  }
157
210
 
158
211
  /** Days of membership the entry's deciding rules ask for (the largest drip), or 0. */
159
- export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): number {
212
+ export function dripDays(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree, own?: EntryRule | null): number {
213
+ if (own && typeof own.drip === "number" && own.drip > 0) return own.drip;
214
+ if (own?.mode === "open" || own?.mode === "levels") return 0;
160
215
  const found = deciding(rules, collection, terms, tree);
161
216
  if (found === "open") return 0;
162
217
  return Math.max(0, ...found.map((r) => (typeof r.drip === "number" && r.drip > 0 ? r.drip : 0)));
163
218
  }
164
219
 
165
220
  /** May the meter open the entry? Not when a deciding rule says `meter: false`. */
166
- export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree): boolean {
221
+ export function meterable(rules: RuleConfig[] = [], collection: string, terms: Terms = {}, tree?: TermTree, own?: EntryRule | null): boolean {
222
+ if (own?.meter === false) return false;
223
+ if (own?.mode === "levels") return true;
167
224
  const found = deciding(rules, collection, terms, tree);
168
225
  return found === "open" || !found.some((r) => r.meter === false);
169
226
  }
@@ -229,7 +286,7 @@ export function membership(member: MemberRecord, levels: LevelConfig[] = []): {
229
286
  /** How much of this period's meter the signed-in reader has used, or null when no meter runs. */
230
287
  export async function meterUsage(astro: AstroLike): Promise<{ used: number; limit: number; period: "month" | "week" } | null> {
231
288
  const locals = astro.locals as Locals;
232
- const meter = siteConfig(locals.emdash?.config)?.meter;
289
+ const meter = (await siteConfig(locals))?.meter;
233
290
  const user = locals.user;
234
291
  const db = locals.emdash?.db;
235
292
  if (!meter || !(meter.limit > 0) || !user?.id || !db) return null;
@@ -291,7 +348,7 @@ export function describe(required: Requirement, levels: LevelConfig[] = [], open
291
348
  */
292
349
  export async function termTree(rules: RuleConfig[] = [], collection: string, terms: Terms = {}): Promise<TermTree> {
293
350
  const tree: TermTree = {};
294
- const forCollection = rules.filter((r) => r.collection === collection && r.taxonomy && r.term);
351
+ const forCollection = rulesFor(rules, collection).filter((r) => r.taxonomy && r.term);
295
352
  const wanted = Object.entries(terms).filter(([taxonomy, list]) => {
296
353
  const own = new Set(forCollection.filter((r) => r.taxonomy === taxonomy).map((r) => r.term));
297
354
  return own.size > 0 && (list ?? []).some((t) => !own.has(slugOf(t)));
@@ -316,15 +373,21 @@ export async function termTree(rules: RuleConfig[] = [], collection: string, ter
316
373
  return tree;
317
374
  }
318
375
 
319
- /** The entry's requirement with the site's config, walking term parents. */
376
+ /**
377
+ * The entry's requirement with the site's config, walking term parents. With
378
+ * `id` (EmDash's id or the slug), the entry's own rule from the editor applies.
379
+ */
320
380
  export async function entryRequirement(
321
381
  config: RedeemIDConfig | null,
322
382
  collection: string,
323
383
  terms: Terms = {},
384
+ id?: string | null,
324
385
  ): Promise<Requirement> {
325
386
  if (!config) return null;
387
+ const own = entryRuleFor(config, collection, id);
388
+ if (own && (own.mode === "open" || own.mode === "levels") && !own.openUntil && !own.lockedUntil) return applyEntryRule(null, own);
326
389
  const tree = await termTree(config.rules, collection, terms);
327
- return requirement(config.rules, collection, terms, { combine: config.combine, tree });
390
+ return applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
328
391
  }
329
392
 
330
393
  const viewers = new WeakMap<object, Promise<Viewer>>();
@@ -354,7 +417,7 @@ async function readViewer(astro: AstroLike): Promise<Viewer> {
354
417
  // Member-specific output must never be shared by a cache.
355
418
  astro.response?.headers.set("Cache-Control", "private, no-store");
356
419
 
357
- const config = siteConfig(locals.emdash?.config);
420
+ const config = await siteConfig(locals);
358
421
  const db = locals.emdash?.db;
359
422
  if (!config || !db) return { signedIn: true, member: null, levels: [] };
360
423
 
@@ -369,16 +432,16 @@ async function readViewer(astro: AstroLike): Promise<Viewer> {
369
432
  */
370
433
  export async function canRead(
371
434
  astro: AstroLike,
372
- entry: { collection: string; terms?: Terms },
435
+ entry: { collection: string; terms?: Terms; id?: string; ruleId?: string },
373
436
  override?: { levels: string[] } | { required: string[][] },
374
437
  ): Promise<Verdict> {
375
438
  const locals = astro.locals as Locals;
376
- const config: RedeemIDConfig | null = siteConfig(locals.emdash?.config);
439
+ const config: RedeemIDConfig | null = await siteConfig(locals);
377
440
  const required: Requirement = override
378
441
  ? "required" in override
379
442
  ? override.required
380
443
  : [override.levels]
381
- : await entryRequirement(config, entry.collection, entry.terms);
444
+ : await entryRequirement(config, entry.collection, entry.terms, entry.ruleId ?? entry.id);
382
445
  const v = await viewer(astro);
383
446
 
384
447
  if (required === null) return { allowed: true, reason: "open", required: [], viewer: v };
@@ -426,17 +489,18 @@ export function signOutUrl(returnTo = "/"): string {
426
489
  */
427
490
  export async function readEntry(
428
491
  astro: AstroLike,
429
- entry: { collection: string; terms?: Terms; id?: string },
492
+ entry: { collection: string; terms?: Terms; id?: string; ruleId?: string },
430
493
  override?: { required: string[][] },
431
494
  ): Promise<Verdict> {
432
495
  const verdict = await canRead(astro, entry, override);
433
496
  const locals = astro.locals as Locals;
434
- const config = siteConfig(locals.emdash?.config);
497
+ const config = await siteConfig(locals);
435
498
  if (!config) return verdict;
436
499
 
437
500
  if (verdict.allowed && verdict.reason === "allowed" && verdict.viewer.member) {
501
+ const own = entryRuleFor(config, entry.collection, entry.ruleId ?? entry.id);
438
502
  const tree = await termTree(config.rules, entry.collection, entry.terms);
439
- const days = dripDays(config.rules, entry.collection, entry.terms, tree);
503
+ const days = dripDays(config.rules, entry.collection, entry.terms, tree, own);
440
504
  const opensAt = dripOpensAt(memberSince(verdict.viewer.member, config.levels), days);
441
505
  if (days > 0 && (!opensAt || opensAt.getTime() > Date.now())) {
442
506
  return { ...verdict, allowed: false, reason: "drip", opensAt: opensAt ?? undefined };
@@ -458,7 +522,7 @@ export async function readEntry(
458
522
  ) {
459
523
  return verdict;
460
524
  }
461
- if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms))) {
525
+ if (!meterable(config.rules, entry.collection, entry.terms, await termTree(config.rules, entry.collection, entry.terms), entryRuleFor(config, entry.collection, entry.ruleId ?? entry.id))) {
462
526
  return verdict;
463
527
  }
464
528
  const period = meter.period ?? "month";
@@ -473,6 +537,8 @@ export async function readEntry(
473
537
  await s.meter.put(key, { ...record, items: used.items });
474
538
  const { emit } = await import("./events.js");
475
539
  const data = { userId: user.id, entry: `${entry.collection}:${entry.id}`, used: used.items.length, limit: meter.limit, period: record.period };
540
+ const { record: log } = await import("./log.js");
541
+ await log({ event: used.left === 0 ? "meter.exhausted" : "meter.used", label: entry.collection, status: used.left === 0 ? "used up" : `${used.left} left`, origin: "member" });
476
542
  await emit(config, "meter.used", data);
477
543
  if (used.left === 0) await emit(config, "meter.exhausted", data);
478
544
  }
package/src/admin-api.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import { env, ENV, shopOrigin, type RedeemIDConfig } from "./config.js";
13
13
  import { USER_AGENT } from "./shopify.js";
14
14
  import { cleanTags, emptyMember, stampTags, type MemberRecord, type Stores } from "./storage.js";
15
+ import { timed } from "./log.js";
15
16
 
16
17
  export const ADMIN_API_VERSION = "2026-10";
17
18
 
@@ -68,7 +69,13 @@ export async function adminScopes(config: RedeemIDConfig, fetcher: typeof fetch
68
69
  return (await token(config, fetcher)).scope;
69
70
  }
70
71
 
71
- export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch, retry = true): Promise<T> {
72
+ /** One Admin API call, recorded under App events by its operation name (never its variables or answer). */
73
+ export async function admin<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown> = {}, fetcher: typeof fetch = fetch): Promise<T> {
74
+ const op = (/(?:query|mutation)\s+(\w+)/.exec(query)?.[1] ?? "query").replace(/^RedeemID/, "") || "query";
75
+ return timed("shopify.admin", op, () => call<T>(config, query, variables, fetcher, true));
76
+ }
77
+
78
+ async function call<T>(config: RedeemIDConfig, query: string, variables: Record<string, unknown>, fetcher: typeof fetch, retry: boolean): Promise<T> {
72
79
  const t = await token(config, fetcher);
73
80
  const res = await fetcher(`${shopOrigin(config)}/admin/api/${ADMIN_API_VERSION}/graphql.json`, {
74
81
  method: "POST",
@@ -77,7 +84,7 @@ export async function admin<T>(config: RedeemIDConfig, query: string, variables:
77
84
  });
78
85
  if (res.status === 401 && retry) {
79
86
  tokens.delete(shopOrigin(config));
80
- return admin(config, query, variables, fetcher, false);
87
+ return call(config, query, variables, fetcher, false);
81
88
  }
82
89
  const body = (await res.json().catch(() => ({}))) as { data?: T; errors?: Array<{ message?: string }> };
83
90
  if (!res.ok || !body.data) throw new AdminError(body.errors?.[0]?.message ?? `Admin API request failed (${res.status}).`);
package/src/agents.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { adminConfigured } from "./admin-api.js";
13
- import { describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
13
+ import { applyEntryRule, entryRuleFor, describe as describeRequirement, dripDays, levelsFor, membership, passes, requirement, termTree, type Requirement, type Terms } from "./access.js";
14
14
  import { ENV, env, storeDomain, unknownEnvNames, type RedeemIDConfig } from "./config.js";
15
15
  import { featuresFor } from "./features.js";
16
16
  import { bodyClauses } from "./files.js";
@@ -42,6 +42,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
42
42
  let terms: Terms = input.terms ?? {};
43
43
  let parts: string[][] = [];
44
44
  let entry: { id: string; title: string | null } | null = null;
45
+ let own: ReturnType<typeof entryRuleFor> = null;
45
46
  let item: SoldItem | null = null;
46
47
  if (input.id) {
47
48
  const { getEmDashEntry } = await import("emdash");
@@ -53,6 +54,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
53
54
  parts = bodyClauses(body);
54
55
  item = soldItem(body);
55
56
  entry = { id: String(result?.entry?.id ?? input.id), title: typeof data.title === "string" ? data.title : null };
57
+ own = entryRuleFor(config, collection, typeof data.id === "string" ? data.id : null) ?? entryRuleFor(config, collection, input.id);
56
58
  }
57
59
 
58
60
  let tags = cleanTags(input.tags ?? []);
@@ -67,7 +69,7 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
67
69
  const disabled = member?.status === "disabled";
68
70
  const held = disabled ? [] : levelsFor(tags, levels);
69
71
  const tree = await termTree(config.rules, collection, terms);
70
- const required: Requirement = requirement(config.rules, collection, terms, { combine: config.combine, tree });
72
+ const required: Requirement = applyEntryRule(requirement(config.rules, collection, terms, { combine: config.combine, tree }), own);
71
73
  const owned = !disabled && holdsItem(item, tags);
72
74
  const canReadRule = owned || required === null || (signedIn && !disabled && passes(held, required));
73
75
  const name = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
@@ -90,7 +92,8 @@ export async function explainAccess(config: RedeemIDConfig, s: Stores, input: Ex
90
92
  canRead: canReadRule,
91
93
  reason: canReadRule ? "allowed" : !signedIn ? "sign_in" : disabled ? "disabled" : "upgrade",
92
94
  offered: !canReadRule && missing ? name(missing[0] as string) : null,
93
- dripDays: owned ? 0 : dripDays(config.rules, collection, terms, tree),
95
+ dripDays: owned ? 0 : dripDays(config.rules, collection, terms, tree, own),
96
+ entryRule: own ? { mode: own.mode, levels: own.levels.map(name), drip: own.drip ?? 0, neverMetered: own.meter === false, lockedUntil: own.lockedUntil ?? null, openUntil: own.openUntil ?? null } : null,
94
97
  soldOnItsOwn: item ? { tag: item.tag, product: item.product, held: owned } : null,
95
98
  parts: partResults,
96
99
  note: "Drip and the meter depend on the member's dates and use, so they can still change what the page shows.",
@@ -109,6 +112,7 @@ export function getRules(config: RedeemIDConfig): Record<string, unknown> {
109
112
  teaser: config.teaser ?? {},
110
113
  features: config.features ?? [],
111
114
  files: config.files ?? [],
115
+ entries: Object.values(config.entries ?? {}).filter((e, i, all) => all.findIndex((x) => x.entryId === e.entryId) === i),
112
116
  };
113
117
  }
114
118
 
@@ -178,3 +182,58 @@ export async function levelChanges(config: RedeemIDConfig, s: Stores, input: { s
178
182
  } while (cursor);
179
183
  return out.sort((a, b) => String(a.at).localeCompare(String(b.at)));
180
184
  }
185
+
186
+ // ---------------------------------------------------------------- catalog
187
+
188
+ /**
189
+ * Catalog lookups for agents and editors: product and collection handles,
190
+ * prices and options, read from the Storefront API (public data). Use the
191
+ * handles in Product, Collection and Plan blocks.
192
+ */
193
+ export async function findProducts(config: RedeemIDConfig, input: { query: string; first?: number }): Promise<Record<string, unknown>> {
194
+ const { searchProducts } = await import("./catalog.js");
195
+ const q = String(input.query ?? "").trim();
196
+ if (!q) throw new AgentError("query is required");
197
+ const page = await searchProducts(config, q, { first: Math.max(1, Math.min(25, input.first ?? 10)) });
198
+ return {
199
+ total: page.total ?? page.products.length,
200
+ products: page.products.map((p) => ({ handle: p.handle, title: p.title, availableForSale: p.availableForSale, price: p.price, compareAtPrice: p.compareAtPrice ?? null })),
201
+ };
202
+ }
203
+
204
+ export async function getProduct(config: RedeemIDConfig, input: { handle: string }): Promise<Record<string, unknown>> {
205
+ const { product, realOptions } = await import("./storefront.js");
206
+ const handle = String(input.handle ?? "").trim();
207
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) throw new AgentError("handle must be a Shopify product handle");
208
+ const p = await product(config, handle);
209
+ if (!p) throw new AgentError(`Shopify has no product "${handle}" on this storefront`);
210
+ return {
211
+ handle: p.handle,
212
+ title: p.title,
213
+ vendor: p.vendor ?? null,
214
+ productType: p.productType ?? null,
215
+ description: p.description,
216
+ availableForSale: p.availableForSale,
217
+ options: realOptions(p),
218
+ variants: p.variants.map((v) => ({ id: v.id, title: v.title, availableForSale: v.availableForSale, price: v.price, compareAtPrice: v.compareAtPrice ?? null, sku: v.sku ?? null, options: v.selectedOptions ?? [] })),
219
+ requiresSellingPlan: Boolean(p.requiresSellingPlan),
220
+ sellingPlans: p.sellingPlans ?? [],
221
+ images: (p.images ?? []).length,
222
+ };
223
+ }
224
+
225
+ export async function getCollection(config: RedeemIDConfig, input: { handle: string; first?: number }): Promise<Record<string, unknown>> {
226
+ const { collectionPage } = await import("./catalog.js");
227
+ const handle = String(input.handle ?? "").trim();
228
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) throw new AgentError("handle must be a Shopify collection handle");
229
+ const c = await collectionPage(config, handle, { first: Math.max(1, Math.min(50, input.first ?? 25)) });
230
+ if (!c) throw new AgentError(`Shopify has no collection "${handle}" on this storefront`);
231
+ return {
232
+ handle: c.handle,
233
+ title: c.title,
234
+ description: c.description,
235
+ filters: c.filters.map((f) => ({ label: f.label, type: f.type, values: f.values.map((v) => v.label) })),
236
+ products: c.products.map((p) => ({ handle: p.handle, title: p.title, availableForSale: p.availableForSale, price: p.price })),
237
+ more: c.pageInfo.hasNextPage,
238
+ };
239
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The developer API's version (docs/developer-api.md): the minor part bumps
3
+ * when something is added, the major part when something stable changes or
4
+ * goes. Separate from the package version.
5
+ */
6
+ export const API_VERSION = "1.0.0";
@@ -3,13 +3,14 @@
3
3
  import Collection from "../components/Collection.astro";
4
4
 
5
5
  interface Props {
6
- node: { handle?: string; limit?: string; link?: string };
6
+ node: { handle?: string; limit?: string; link?: string; extras?: string[] };
7
7
  }
8
8
 
9
9
  const { node } = Astro.props;
10
10
  const handle = (node.handle ?? "").trim();
11
11
  const limit = Number.parseInt(node.limit ?? "", 10);
12
12
  const link = (node.link ?? "").trim();
13
+ const extras = Array.isArray(node.extras) ? node.extras : [];
13
14
  ---
14
15
 
15
16
  {handle && (
@@ -18,5 +19,7 @@ const link = (node.link ?? "").trim();
18
19
  limit={Number.isFinite(limit) ? limit : 12}
19
20
  productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined}
20
21
  showTitle
22
+ browse={extras.includes("browse")}
23
+ quickAdd={extras.includes("quick-add")}
21
24
  />
22
25
  )}
@@ -0,0 +1,14 @@
1
+ ---
2
+ /** The "Product search" block: a search box with Shopify's results, filters, sorting and paging. */
3
+ import ProductSearch from "../components/ProductSearch.astro";
4
+
5
+ interface Props {
6
+ node: { link?: string; label?: string };
7
+ }
8
+
9
+ const { node } = Astro.props;
10
+ const link = (node.link ?? "").trim();
11
+ const label = (node.label ?? "").trim();
12
+ ---
13
+
14
+ <ProductSearch productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined} label={label || undefined} />
@@ -8,6 +8,7 @@ import ContributionBlock from "./ContributionBlock.astro";
8
8
  import PlanBlock from "./PlanBlock.astro";
9
9
  import PreferencesBlock from "./PreferencesBlock.astro";
10
10
  import ProductBlock from "./ProductBlock.astro";
11
+ import SearchBlock from "./SearchBlock.astro";
11
12
 
12
13
  export const blockComponents = {
13
14
  "redeemid-paywall": Marker,
@@ -17,6 +18,7 @@ export const blockComponents = {
17
18
  "redeemid-plan": PlanBlock,
18
19
  "redeemid-product": ProductBlock,
19
20
  "redeemid-collection": CollectionBlock,
21
+ "redeemid-search": SearchBlock,
20
22
  "redeemid-contribution": ContributionBlock,
21
23
  "redeemid-preferences": PreferencesBlock,
22
24
  };
@@ -3,6 +3,7 @@ export const PAYWALL_BLOCK = "redeemid-paywall";
3
3
  export const PLAN_BLOCK = "redeemid-plan";
4
4
  export const PRODUCT_BLOCK = "redeemid-product";
5
5
  export const COLLECTION_BLOCK = "redeemid-collection";
6
+ export const SEARCH_BLOCK = "redeemid-search";
6
7
  export const CONTRIBUTION_BLOCK = "redeemid-contribution";
7
8
  export const PREFERENCES_BLOCK = "redeemid-preferences";
8
9
  /** Starts a members-only part of the body; it runs to the next end block. */
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Cart changes, shared by the form route (cart-edit, which redirects back
3
+ * with a notice) and the JSON route (api/cart, for the browser client and
4
+ * the cart drawer).
5
+ *
6
+ * Prices, discounts and availability come from Shopify; nothing posted is
7
+ * trusted beyond the ids it names, and each is checked against what Shopify
8
+ * says the product or cart holds.
9
+ */
10
+
11
+ import { readCart, saveCart } from "./cart.js";
12
+ import type { RedeemIDConfig } from "./config.js";
13
+ import { memberForUser, stores } from "./storage.js";
14
+ import { addToCart, cartDetails, product, realOptions, removeLines, setDiscountCodes, storeContext, updateLines, variantFor, type Cart, type CartDetails, type Product, type Variant } from "./storefront.js";
15
+
16
+ export const MAX_QUANTITY = 1000;
17
+
18
+ export type CartNotice =
19
+ | "cart_added"
20
+ | "cart_updated"
21
+ | "discount_applied"
22
+ | "discount_not_applicable"
23
+ | "plan_unavailable"
24
+ | "cart_empty"
25
+ | "checkout_failed";
26
+
27
+ export interface CartInput {
28
+ action: string;
29
+ /** add: product handle. */
30
+ product?: string;
31
+ /** add: a variant id of that product. */
32
+ variant?: string;
33
+ /** add: chosen option values by option name, when no variant is given. */
34
+ options?: Record<string, string>;
35
+ /** add: a selling plan id of that product, or "" for one-time. */
36
+ sellingPlan?: string | null;
37
+ quantity?: number | string;
38
+ /** update / remove: the cart line id. */
39
+ line?: string;
40
+ /** discount: the code ("" clears). */
41
+ code?: string;
42
+ /** add: the page the buyer was on, kept as a hidden cart attribute. */
43
+ page?: string;
44
+ }
45
+
46
+ export interface CartContext {
47
+ config: RedeemIDConfig;
48
+ session: { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
49
+ request: Request;
50
+ db?: unknown;
51
+ }
52
+
53
+ export interface CartOutcome {
54
+ notice: CartNotice;
55
+ /** The cart after the change, when it could be read. */
56
+ cart: CartDetails | null;
57
+ /** The cart's id, checkout URL and count after an add (lines aren't read back). */
58
+ base?: Cart;
59
+ /** add: the line's variant, for analytics. */
60
+ added?: { product: string; variant: string; quantity: number; sellingPlan: boolean };
61
+ }
62
+
63
+ const HANDLE = /^[a-z0-9][a-z0-9-]*$/i;
64
+
65
+ /** Pick the variant an add names: by id, by option values, or the first one for sale. */
66
+ export function pickVariant(p: Product, input: Pick<CartInput, "variant" | "options">): Variant | undefined {
67
+ if (input.variant) return p.variants.find((v) => v.id === input.variant);
68
+ const opts = realOptions(p);
69
+ if (opts.length && input.options && Object.keys(input.options).length) {
70
+ const chosen: Record<string, string> = {};
71
+ for (const o of opts) {
72
+ const v = input.options[o.name];
73
+ if (typeof v === "string" && o.values.includes(v)) chosen[o.name] = v;
74
+ }
75
+ if (Object.keys(chosen).length !== opts.length) return undefined;
76
+ return variantFor(p, chosen);
77
+ }
78
+ return p.variants.find((v) => v.availableForSale);
79
+ }
80
+
81
+ /** The selling plan an add uses: one the product offers, the first one when it can only be bought on a plan, or none. */
82
+ export function pickSellingPlan(p: Product, wanted: string | null | undefined): string | null | undefined {
83
+ const plans = p.sellingPlans ?? [];
84
+ if (wanted) return plans.some((sp) => sp.id === wanted) ? wanted : undefined;
85
+ if (p.requiresSellingPlan) return plans[0]?.id ?? p.sellingPlanId ?? undefined;
86
+ return null;
87
+ }
88
+
89
+ export async function editCart(input: CartInput, ctx: CartContext): Promise<CartOutcome> {
90
+ const { config, session, request } = ctx;
91
+ const buyerIp = request.headers.get("cf-connecting-ip");
92
+ const context = storeContext(config, request);
93
+ const save = (c: { id: string; totalQuantity: number }) => saveCart(session, { id: c.id, count: c.totalQuantity });
94
+
95
+ if (input.action === "add") {
96
+ const handle = String(input.product ?? "").trim();
97
+ const quantity = Number.parseInt(String(input.quantity ?? "1"), 10);
98
+ if (!HANDLE.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) return { notice: "plan_unavailable", cart: null };
99
+ const p = await product(config, handle, undefined, context);
100
+ const variant = p ? pickVariant(p, input) : undefined;
101
+ const plan = p ? pickSellingPlan(p, input.sellingPlan) : undefined;
102
+ if (!p || !variant?.availableForSale || plan === undefined) return { notice: "plan_unavailable", cart: null };
103
+ const user = (await session?.get("user")) as { id?: string } | undefined;
104
+ const member = user?.id && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
105
+ const current = await readCart(session);
106
+ const cart = await addToCart(
107
+ config,
108
+ current?.id ?? null,
109
+ { variantId: variant.id, sellingPlanId: plan, quantity },
110
+ { email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context },
111
+ );
112
+ save(cart);
113
+ return { notice: "cart_added", cart: null, base: cart, added: { product: p.handle, variant: variant.id, quantity, sellingPlan: Boolean(plan) } };
114
+ }
115
+
116
+ const ref = await readCart(session);
117
+ if (!ref) return { notice: "cart_empty", cart: null };
118
+ const cart = await cartDetails(config, ref.id, { buyerIp });
119
+ if (!cart) {
120
+ saveCart(session, null);
121
+ return { notice: "cart_empty", cart: null };
122
+ }
123
+ const line = String(input.line ?? "");
124
+ const owns = cart.lines.some((l) => l.id === line);
125
+
126
+ if (input.action === "update" || input.action === "remove") {
127
+ if (!owns) return { notice: "cart_updated", cart };
128
+ const quantity = input.action === "remove" ? 0 : Number.parseInt(String(input.quantity ?? ""), 10);
129
+ if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return { notice: "cart_updated", cart };
130
+ const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
131
+ save(next);
132
+ return { notice: "cart_updated", cart: next };
133
+ }
134
+
135
+ if (input.action === "discount") {
136
+ const code = String(input.code ?? "").trim().slice(0, 100);
137
+ const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
138
+ save(next);
139
+ if (!code) return { notice: "cart_updated", cart: next };
140
+ const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
141
+ return { notice: applied?.applicable ? "discount_applied" : "discount_not_applicable", cart: next };
142
+ }
143
+
144
+ return { notice: "cart_updated", cart };
145
+ }
146
+
147
+ /** Read the add fields from a posted form: option.<Name> fields become `options`. */
148
+ export function cartInputFromForm(form: FormData | null): CartInput {
149
+ const field = (k: string) => String(form?.get(k) ?? "").trim();
150
+ const options: Record<string, string> = {};
151
+ form?.forEach((value, key) => {
152
+ if (key.startsWith("option.") && typeof value === "string") options[key.slice(7)] = value;
153
+ });
154
+ return {
155
+ action: field("action"),
156
+ product: field("product"),
157
+ variant: field("variant"),
158
+ options,
159
+ sellingPlan: form?.has("selling_plan") ? field("selling_plan") : null,
160
+ quantity: field("quantity") || "1",
161
+ line: field("line"),
162
+ code: field("code"),
163
+ };
164
+ }