@redeemid/emdash 0.0.6 → 0.0.8

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.
@@ -4,8 +4,12 @@
4
4
  * checkout. Prices come from the Storefront API; the default slot shows if
5
5
  * the product can't be loaded.
6
6
  *
7
- * <Plan handle="gold-membership" />
7
+ * <Plan handle="gold-membership" levels={["gold"]} />
8
+ *
9
+ * With `levels`, members who hold one of them see "You're on this plan"
10
+ * in place of the button.
8
11
  */
12
+ import { viewer } from "../access.js";
9
13
  import { siteConfig } from "../site-config.js";
10
14
  import { plan as loadPlan, type Plan } from "../storefront.js";
11
15
 
@@ -15,9 +19,12 @@ interface Props {
15
19
  label?: string;
16
20
  /** Heading override; defaults to the Shopify product title. */
17
21
  title?: string;
22
+ /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
23
+ levels?: string[];
24
+ heldLabel?: string;
18
25
  }
19
26
 
20
- const { handle, label = "Join", title } = Astro.props;
27
+ const { handle, label = "Join", title, levels = [], heldLabel = "You're on this plan." } = Astro.props;
21
28
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
22
29
  let p: Plan | null = null;
23
30
  if (config) {
@@ -31,14 +38,17 @@ const price = p?.price
31
38
  ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
32
39
  : null;
33
40
  const here = Astro.url.pathname + Astro.url.search;
41
+ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
34
42
  ---
35
43
 
36
44
  {p ? (
37
- <div class="redeemid-plan" data-redeemid-plan={p.handle}>
45
+ <div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
38
46
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
39
47
  {price && <p class="redeemid-plan__price">{price}</p>}
40
48
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
41
- {p.availableForSale ? (
49
+ {held ? (
50
+ <p class="redeemid-plan__held">{heldLabel}</p>
51
+ ) : p.availableForSale ? (
42
52
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
43
53
  <input type="hidden" name="plan" value={p.handle} />
44
54
  <input type="hidden" name="return" value={here} />
@@ -0,0 +1,78 @@
1
+ ---
2
+ /**
3
+ * A Shopify product: image, title, price, description, a variant choice
4
+ * when it has several, a quantity, and "Add to cart" and "Buy now".
5
+ * Everything comes from the Storefront API; the default slot shows if the
6
+ * product can't be loaded.
7
+ *
8
+ * <Product handle="harbor-tote" />
9
+ */
10
+ import { siteConfig } from "../site-config.js";
11
+ import { money, product as loadProduct, type Product } from "../storefront.js";
12
+
13
+ interface Props {
14
+ handle: string;
15
+ /** Heading level for the title. */
16
+ heading?: "h1" | "h2" | "h3";
17
+ /** Leave out the description (for cards). */
18
+ compact?: boolean;
19
+ addLabel?: string;
20
+ buyLabel?: string;
21
+ }
22
+
23
+ const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
24
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ let p: Product | null = null;
26
+ if (config) {
27
+ try {
28
+ p = await loadProduct(config, handle);
29
+ } catch {
30
+ p = null;
31
+ }
32
+ }
33
+ const available = p?.variants.filter((v) => v.availableForSale) ?? [];
34
+ const first = available[0] ?? p?.variants[0];
35
+ const here = Astro.url.pathname + Astro.url.search;
36
+ const Heading = heading;
37
+ const id = `redeemid-product-${handle}`;
38
+ ---
39
+
40
+ {p ? (
41
+ <div class="redeemid-product" data-redeemid-product={p.handle}>
42
+ {p.image && <img class="redeemid-product__image" src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
43
+ <Heading class="redeemid-product__title">{p.title}</Heading>
44
+ {first && <p class="redeemid-product__price">{money(first.price)}</p>}
45
+ {!compact && p.description && <p class="redeemid-product__description">{p.description}</p>}
46
+ {available.length > 0 ? (
47
+ <form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
48
+ <input type="hidden" name="action" value="add" />
49
+ <input type="hidden" name="product" value={p.handle} />
50
+ <input type="hidden" name="return" value={here} />
51
+ {p.variants.length > 1 ? (
52
+ <p>
53
+ <label for={`${id}-variant`}>Option</label>
54
+ <select id={`${id}-variant`} name="variant">
55
+ {p.variants.map((v) => (
56
+ <option value={v.id} disabled={!v.availableForSale}>
57
+ {v.title} ({money(v.price)}){!v.availableForSale && " sold out"}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </p>
62
+ ) : (
63
+ <input type="hidden" name="variant" value={first?.id} />
64
+ )}
65
+ <p>
66
+ <label for={`${id}-quantity`}>Quantity</label>
67
+ <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
+ </p>
69
+ <button type="submit" name="then" value="back">{addLabel}</button>
70
+ <button type="submit" name="then" value="checkout">{buyLabel}</button>
71
+ </form>
72
+ ) : (
73
+ <p class="redeemid-product__unavailable">Sold out.</p>
74
+ )}
75
+ </div>
76
+ ) : (
77
+ <slot />
78
+ )}
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
56
56
  { pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
57
57
  { pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
58
58
  { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
+ { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
59
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
60
61
  ],
61
62
  publicRoutes: [`${BASE}/`],
package/src/plugin.ts CHANGED
@@ -34,9 +34,17 @@ import { stores, type MemberRecord } from "./storage.js";
34
34
  import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
36
 
37
- import { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
37
+ import {
38
+ COLLECTION_BLOCK,
39
+ CONTRIBUTION_BLOCK,
40
+ MEMBERS_BLOCK,
41
+ MEMBERS_END_BLOCK,
42
+ PAYWALL_BLOCK,
43
+ PLAN_BLOCK,
44
+ PRODUCT_BLOCK,
45
+ } from "./blocks-names.js";
38
46
 
39
- export { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK };
47
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
40
48
 
41
49
  function blocks(config: RedeemIDConfig) {
42
50
  const levelOptions = [
@@ -89,6 +97,45 @@ function blocks(config: RedeemIDConfig) {
89
97
  { type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "gold-membership" },
90
98
  { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
91
99
  { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Join" },
100
+ {
101
+ type: "checkbox" as const,
102
+ action_id: "levels",
103
+ label: "Levels this plan gives (members on them see \"You're on this plan\")",
104
+ options: (config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
105
+ },
106
+ ],
107
+ },
108
+ {
109
+ type: PRODUCT_BLOCK,
110
+ label: "Product",
111
+ icon: "tag",
112
+ description: "A Shopify product with Add to cart and Buy now",
113
+ placeholder: "Shopify product handle",
114
+ fields: [{ type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "harbor-tote" }],
115
+ },
116
+ {
117
+ type: COLLECTION_BLOCK,
118
+ label: "Collection",
119
+ icon: "grid",
120
+ description: "A Shopify collection's products as cards",
121
+ placeholder: "Shopify collection handle",
122
+ fields: [
123
+ { type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
124
+ { type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
125
+ { type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
126
+ ],
127
+ },
128
+ {
129
+ type: CONTRIBUTION_BLOCK,
130
+ label: "Contribution",
131
+ icon: "star",
132
+ description: "Pay what you want, with a Shopify product priced at 1.00",
133
+ placeholder: "Shopify product handle",
134
+ fields: [
135
+ { type: "text_input" as const, action_id: "handle", label: "Shopify product handle (priced at 1.00)", placeholder: "support" },
136
+ { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
137
+ { type: "text_input" as const, action_id: "amounts", label: "Suggested amounts, comma separated", placeholder: "5, 10, 25" },
138
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
92
139
  ],
93
140
  },
94
141
  ];
package/src/routes/buy.ts CHANGED
@@ -39,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
39
39
  config,
40
40
  current?.id ?? null,
41
41
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
42
- { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
42
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip") },
43
43
  );
44
44
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
45
  return redirect(cart.checkoutUrl, 303);
@@ -33,7 +33,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
33
33
  const clientId = env(ENV.clientId);
34
34
  if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
35
35
 
36
- if (url.searchParams.get("error")) return fail(redirect, returnTo, "sign_in_cancelled");
36
+ if (url.searchParams.get("error")) {
37
+ // Refresh found no open store session: sign in as usual.
38
+ if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
39
+ return fail(redirect, returnTo, "sign_in_cancelled");
40
+ }
37
41
 
38
42
  const state = url.searchParams.get("state");
39
43
  const code = url.searchParams.get("code");
@@ -105,7 +109,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
105
109
  session.regenerate?.();
106
110
  session.set("user", { id: user.id });
107
111
  session.set(SESSION_ID_TOKEN, tokens.idToken);
108
- return redirect(returnTo);
112
+ return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
109
113
  } catch (e) {
110
114
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
111
115
  console.error("[redeemid] sign-in failed:", (e as Error).message);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
3
+ *
4
+ * Fields: `action` (add | update | remove | discount), `return`, and
5
+ * - add: `product` (handle), optional `variant` (must be one of that
6
+ * product's variants, read again from Shopify), `quantity` (1 to 1000),
7
+ * `then` = "checkout" to go straight to checkout;
8
+ * - update: `line` and `quantity` (0 removes the line);
9
+ * - remove: `line`;
10
+ * - discount: `code` (empty clears the codes).
11
+ *
12
+ * Prices, discounts and availability come from Shopify; nothing posted is
13
+ * trusted beyond the ids it names. Back to `return` with a notice:
14
+ * cart_added, cart_updated, discount_applied, discount_not_applicable,
15
+ * plan_unavailable, cart_empty or checkout_failed.
16
+ */
17
+
18
+ import type { APIRoute } from "astro";
19
+
20
+ import { safeReturn } from "../access.js";
21
+ import { readCart, saveCart } from "../cart.js";
22
+ import { readConfig, storeDomain } from "../config.js";
23
+ import { memberForUser, stores } from "../storage.js";
24
+ import { addToCart, cartDetails, product, removeLines, setDiscountCodes, updateLines } from "../storefront.js";
25
+ import { fail } from "./shared.js";
26
+
27
+ export const prerender = false;
28
+
29
+ const MAX_QUANTITY = 1000;
30
+
31
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
32
+ const form = await request.formData().catch(() => null);
33
+ const field = (k: string) => String(form?.get(k) ?? "").trim();
34
+ const returnTo = safeReturn(field("return") || "/");
35
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
36
+ const config = readConfig(emdash?.config);
37
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
38
+ const buyerIp = request.headers.get("cf-connecting-ip");
39
+ const action = field("action");
40
+
41
+ try {
42
+ if (action === "add") {
43
+ const handle = field("product");
44
+ const quantity = Number.parseInt(field("quantity") || "1", 10);
45
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
46
+ return fail(redirect, returnTo, "plan_unavailable");
47
+ }
48
+ const p = await product(config, handle);
49
+ const wanted = field("variant");
50
+ const variant = wanted ? p?.variants.find((v) => v.id === wanted) : p?.variants.find((v) => v.availableForSale);
51
+ if (!p || !variant?.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
52
+ const user = (await session?.get("user")) as { id?: string } | undefined;
53
+ const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
54
+ const current = await readCart(session);
55
+ const cart = await addToCart(
56
+ config,
57
+ current?.id ?? null,
58
+ { variantId: variant.id, sellingPlanId: p.sellingPlanId, quantity },
59
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp },
60
+ );
61
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
62
+ return field("then") === "checkout" ? redirect(cart.checkoutUrl, 303) : fail(redirect, returnTo, "cart_added");
63
+ }
64
+
65
+ const ref = await readCart(session);
66
+ if (!ref) return fail(redirect, returnTo, "cart_empty");
67
+ const cart = await cartDetails(config, ref.id, { buyerIp });
68
+ if (!cart) {
69
+ saveCart(session, null);
70
+ return fail(redirect, returnTo, "cart_empty");
71
+ }
72
+ const line = field("line");
73
+ const owns = cart.lines.some((l) => l.id === line);
74
+
75
+ if (action === "update" || action === "remove") {
76
+ if (!owns) return fail(redirect, returnTo, "cart_updated");
77
+ const quantity = action === "remove" ? 0 : Number.parseInt(field("quantity"), 10);
78
+ if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return fail(redirect, returnTo, "cart_updated");
79
+ const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
80
+ saveCart(session, { id: next.id, count: next.totalQuantity });
81
+ return fail(redirect, returnTo, "cart_updated");
82
+ }
83
+
84
+ if (action === "discount") {
85
+ const code = field("code").slice(0, 100);
86
+ const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
87
+ saveCart(session, { id: next.id, count: next.totalQuantity });
88
+ if (!code) return fail(redirect, returnTo, "cart_updated");
89
+ const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
90
+ return fail(redirect, returnTo, applied?.applicable ? "discount_applied" : "discount_not_applicable");
91
+ }
92
+
93
+ return fail(redirect, returnTo, "cart_updated");
94
+ } catch {
95
+ return fail(redirect, returnTo, "checkout_failed");
96
+ }
97
+ };
@@ -2,6 +2,9 @@
2
2
  * GET /_emdash/api/auth/redeemid/login?return=/path
3
3
  *
4
4
  * Starts Shopify customer account sign-in (authorization code + PKCE).
5
+ * With `refresh=1` it asks the store not to show a form (`prompt=none`):
6
+ * a member whose store session is still open comes straight back with their
7
+ * tags read again; anyone else is sent through the usual sign-in.
5
8
  */
6
9
 
7
10
  import type { APIRoute } from "astro";
@@ -16,6 +19,7 @@ export const prerender = false;
16
19
  export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
17
20
  const url = new URL(request.url);
18
21
  const returnTo = safeReturn(url.searchParams.get("return"));
22
+ const refresh = url.searchParams.get("refresh") === "1";
19
23
  const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
20
24
  const config = readConfig(emdash?.config);
21
25
  const clientId = env(ENV.clientId);
@@ -30,6 +34,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
30
34
  verifier: randomToken(48),
31
35
  returnTo,
32
36
  at: Date.now(),
37
+ ...(refresh ? { refresh: true } : {}),
33
38
  };
34
39
  session.set(SESSION_FLOW, flow);
35
40
  return redirect(
@@ -40,6 +45,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
40
45
  nonce: flow.nonce,
41
46
  challenge: await challengeFor(flow.verifier),
42
47
  locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
48
+ ...(refresh ? { prompt: "none" as const } : {}),
43
49
  }),
44
50
  );
45
51
  } catch (e) {
@@ -11,6 +11,8 @@ export interface Flow {
11
11
  verifier: string;
12
12
  returnTo: string;
13
13
  at: number;
14
+ /** Started by Refresh membership (prompt=none). */
15
+ refresh?: boolean;
14
16
  }
15
17
 
16
18
  export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
package/src/shopify.ts CHANGED
@@ -97,7 +97,16 @@ export function base64url(bytes: Uint8Array): string {
97
97
 
98
98
  export function authorizeUrl(
99
99
  d: Discovery,
100
- args: { clientId: string; redirectUri: string; state: string; nonce: string; challenge: string; locale?: string },
100
+ args: {
101
+ clientId: string;
102
+ redirectUri: string;
103
+ state: string;
104
+ nonce: string;
105
+ challenge: string;
106
+ locale?: string;
107
+ /** "none": no sign-in form; the store answers with an error when the customer isn't signed in there. */
108
+ prompt?: "none";
109
+ },
101
110
  ): string {
102
111
  const url = new URL(d.authorizationEndpoint);
103
112
  url.searchParams.set("scope", SCOPES);
@@ -109,6 +118,7 @@ export function authorizeUrl(
109
118
  url.searchParams.set("code_challenge", args.challenge);
110
119
  url.searchParams.set("code_challenge_method", "S256");
111
120
  if (args.locale) url.searchParams.set("ui_locales", args.locale);
121
+ if (args.prompt) url.searchParams.set("prompt", args.prompt);
112
122
  return url.toString();
113
123
  }
114
124
 
package/src/storage.ts CHANGED
@@ -35,8 +35,19 @@ export interface MemberRecord {
35
35
  tagsSince?: Record<string, string>;
36
36
  /** When the customer first signed in here, ISO. */
37
37
  linkedAt?: string | null;
38
+ /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
39
+ pastTags?: PastTag[];
38
40
  }
39
41
 
42
+ /** A tag the customer held from `from` (when known) until `to`. */
43
+ export interface PastTag {
44
+ tag: string;
45
+ from: string | null;
46
+ to: string;
47
+ }
48
+
49
+ const PAST_TAGS_KEPT = 50;
50
+
40
51
  /** A signed-in reader's meter for one period: the locked items they opened free. */
41
52
  export interface MeterRecord {
42
53
  userId: string;
@@ -45,11 +56,18 @@ export interface MeterRecord {
45
56
  items: string[];
46
57
  }
47
58
 
48
- /** Update `tagsSince` to the record's current tags: new tags start now, removed tags are dropped. */
59
+ /**
60
+ * Update `tagsSince` to the record's current tags: new tags start now;
61
+ * removed tags move to `pastTags` with the dates they were held.
62
+ */
49
63
  export function stampTags(m: MemberRecord, now = new Date().toISOString()): MemberRecord {
50
64
  const before = m.tagsSince ?? {};
51
65
  const since: Record<string, string> = {};
52
66
  for (const t of m.tags.map((t) => t.toLowerCase())) since[t] = before[t] ?? now;
67
+ const removed = Object.keys(before).filter((t) => !(t in since));
68
+ if (removed.length) {
69
+ m.pastTags = [...(m.pastTags ?? []), ...removed.map((tag) => ({ tag, from: before[tag] ?? null, to: now }))].slice(-PAST_TAGS_KEPT);
70
+ }
53
71
  m.tagsSince = since;
54
72
  return m;
55
73
  }