@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.
- package/README.md +447 -232
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/docs/shopify-setup.md +1 -1
- package/package.json +3 -1
- package/src/access-admin.ts +754 -0
- package/src/access.ts +84 -18
- package/src/admin-api.ts +9 -2
- package/src/agents.ts +62 -3
- package/src/api-version.ts +6 -0
- package/src/blocks/CollectionBlock.astro +4 -1
- package/src/blocks/SearchBlock.astro +14 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/cart-ops.ts +164 -0
- package/src/catalog.ts +287 -0
- package/src/checkout.ts +73 -0
- package/src/client/redeemid.ts +563 -0
- package/src/components/AccessBadge.astro +7 -5
- package/src/components/Account.astro +1 -1
- package/src/components/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +12 -6
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +2 -1
- package/src/components/Collection.astro +23 -18
- package/src/components/Content.astro +10 -5
- package/src/components/Contribution.astro +1 -1
- package/src/components/Gate.astro +4 -2
- package/src/components/LockedPanel.astro +5 -3
- package/src/components/MemberCard.astro +1 -1
- package/src/components/Notices.astro +3 -28
- package/src/components/Plan.astro +1 -1
- package/src/components/Preferences.astro +1 -1
- package/src/components/Product.astro +116 -25
- package/src/components/ProductGrid.astro +52 -0
- package/src/components/ProductSearch.astro +83 -0
- package/src/components/Recommendations.astro +45 -0
- package/src/components/RedeemIDClient.astro +45 -0
- package/src/components/YourData.astro +1 -1
- package/src/config.ts +31 -5
- package/src/contract.ts +50 -0
- package/src/descriptor.ts +7 -0
- package/src/events.ts +3 -0
- package/src/features.ts +1 -1
- package/src/files.ts +1 -1
- package/src/guard.ts +5 -3
- package/src/index.ts +3 -0
- package/src/log.ts +96 -0
- package/src/monitor.ts +560 -0
- package/src/notices.ts +29 -0
- package/src/plugin.ts +234 -88
- package/src/rate-limit.ts +7 -0
- package/src/routes/api/cart.ts +84 -0
- package/src/routes/api/search.ts +49 -0
- package/src/routes/api/shared.ts +4 -0
- package/src/routes/api/state.ts +69 -0
- package/src/routes/buy.ts +4 -3
- package/src/routes/callback.ts +23 -5
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/preferences.ts +2 -2
- package/src/routes/privacy.ts +2 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +24 -11
- package/src/settings.ts +195 -0
- package/src/shopify.ts +37 -1
- package/src/site-config.ts +38 -5
- package/src/staff.ts +12 -23
- package/src/storage.ts +12 -0
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
- 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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
|
@@ -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} />
|
package/src/blocks/index.ts
CHANGED
|
@@ -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
|
};
|
package/src/blocks-names.ts
CHANGED
|
@@ -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. */
|
package/src/cart-ops.ts
ADDED
|
@@ -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
|
+
}
|