@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
@@ -1,72 +1,163 @@
1
1
  ---
2
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.
3
+ * A Shopify product: images, title, price (with the sale price when there
4
+ * is one), description, a picker per option (Size, Color…), a choice of
5
+ * one-time or subscription when the product has selling plans, a quantity,
6
+ * and "Add to cart" and "Buy now". Everything comes from the Storefront
7
+ * API; the default slot shows if the product can't be loaded.
7
8
  *
8
- * <Product handle="harbor-tote" />
9
+ * <Product handle="harbor-tote" heading="h1" />
10
+ *
11
+ * The form works without JavaScript (the server picks the variant from the
12
+ * options). With <RedeemIDClient /> on the page the price, image and stock
13
+ * follow the choice as it changes, and with <CartDrawer /> Add to cart opens
14
+ * the drawer in place.
15
+ *
16
+ * On a product's own page (heading "h1") it prints Product structured data
17
+ * for search engines; `schema={false}` turns that off.
9
18
  */
10
19
  import { siteConfig } from "../site-config.js";
11
- import { money, product as loadProduct, type Product } from "../storefront.js";
20
+ import { money, product as loadProduct, realOptions, storeContext, type Product } from "../storefront.js";
12
21
  import { text } from "../text.js";
13
22
 
14
23
  interface Props {
15
24
  handle: string;
16
25
  /** Heading level for the title. */
17
26
  heading?: "h1" | "h2" | "h3";
18
- /** Leave out the description (for cards). */
27
+ /** Leave out the description and gallery (for cards). */
19
28
  compact?: boolean;
20
29
  addLabel?: string;
21
30
  buyLabel?: string;
31
+ /** Show Shopify's HTML description (as the merchant wrote it) instead of the plain text. */
32
+ html?: boolean;
33
+ /** Product structured data. Defaults to on for heading "h1". */
34
+ schema?: boolean;
22
35
  }
23
36
 
24
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
- const { handle, heading = "h2", compact = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
37
+ const config = await siteConfig(Astro.locals);
38
+ const { handle, heading = "h2", compact = false, html = false, addLabel = text(config, "Add to cart"), buyLabel = text(config, "Buy now") } = Astro.props;
39
+ const context = storeContext(config, Astro.request);
26
40
  let p: Product | null = null;
27
41
  if (config) {
28
42
  try {
29
- p = await loadProduct(config, handle);
43
+ p = await loadProduct(config, handle, undefined, context);
30
44
  } catch {
31
45
  p = null;
32
46
  }
33
47
  }
48
+ if (context.country && config?.market?.country === "visitor") Astro.response.headers.set("Vary", "CF-IPCountry");
49
+ const options = p ? realOptions(p) : [];
34
50
  const available = p?.variants.filter((v) => v.availableForSale) ?? [];
35
51
  const first = available[0] ?? p?.variants[0];
52
+ const chosen = Object.fromEntries((first?.selectedOptions ?? []).map((o) => [o.name, o.value]));
53
+ const plans = p?.sellingPlans ?? [];
54
+ const sale = first?.compareAtPrice && Number(first.compareAtPrice.amount) > Number(first.price.amount) ? first.compareAtPrice : null;
55
+ const image = first?.image ?? p?.image ?? null;
56
+ const images = p?.images ?? (p?.image ? [p.image] : []);
36
57
  const here = Astro.url.pathname + Astro.url.search;
37
58
  const Heading = heading;
38
59
  const id = `redeemid-product-${handle}`;
60
+ const variants = (p?.variants ?? []).map((v) => ({
61
+ id: v.id,
62
+ available: v.availableForSale,
63
+ price: money(v.price) ?? "",
64
+ compareAt: v.compareAtPrice && Number(v.compareAtPrice.amount) > Number(v.price.amount) ? (money(v.compareAtPrice) ?? "") : "",
65
+ image: v.image?.url ?? null,
66
+ imageAlt: v.image?.altText ?? "",
67
+ options: Object.fromEntries((v.selectedOptions ?? []).map((o) => [o.name, o.value])),
68
+ }));
69
+ const json = (v: unknown) => JSON.stringify(v).replace(/</g, "\\u003c");
70
+ const showSchema = (Astro.props.schema ?? heading === "h1") && config?.schema !== false && p;
71
+ const schema =
72
+ showSchema && p
73
+ ? json({
74
+ "@context": "https://schema.org",
75
+ "@type": "Product",
76
+ name: p.title,
77
+ description: p.description || undefined,
78
+ image: images.map((i) => i.url),
79
+ brand: p.vendor ? { "@type": "Brand", name: p.vendor } : undefined,
80
+ url: new URL(Astro.url.pathname, Astro.url).href,
81
+ offers: p.variants.map((v) => ({
82
+ "@type": "Offer",
83
+ price: v.price.amount,
84
+ priceCurrency: v.price.currencyCode,
85
+ availability: v.availableForSale ? "https://schema.org/InStock" : "https://schema.org/OutOfStock",
86
+ sku: v.sku || undefined,
87
+ name: v.title !== "Default Title" ? v.title : undefined,
88
+ })),
89
+ })
90
+ : null;
39
91
  ---
40
92
 
41
93
  {p ? (
42
94
  <div class="redeemid-product" data-redeemid-product={p.handle}>
43
- {p.image && <img class="redeemid-product__image" src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
95
+ {schema && <script type="application/ld+json" set:html={schema} />}
96
+ {image && <img class="redeemid-product__image" src={image.url} alt={image.altText ?? ""} loading={heading === "h1" ? "eager" : "lazy"} data-redeemid-product-image />}
97
+ {!compact && images.length > 1 && (
98
+ <ul class="redeemid-product__thumbs">
99
+ {images.map((img, i) => (
100
+ <li>
101
+ <button type="button" data-redeemid-thumb={img.url} data-redeemid-alt={img.altText ?? ""} aria-pressed={String(i === 0)}>
102
+ <img src={img.url} alt={img.altText || text(config, "Image {number} of {count}", { number: i + 1, count: images.length })} loading="lazy" />
103
+ </button>
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ )}
44
108
  <Heading class="redeemid-product__title">{p.title}</Heading>
45
- {first && <p class="redeemid-product__price">{money(first.price)}</p>}
46
- {!compact && p.description && <p class="redeemid-product__description">{p.description}</p>}
109
+ <p class="redeemid-product__price">
110
+ <span data-redeemid-price>{first && money(first.price)}</span>
111
+ <s class="redeemid-product__compare-at" data-redeemid-compare-at hidden={!sale}>{sale && money(sale)}</s>
112
+ </p>
113
+ {!compact && (html && p.descriptionHtml ? <div class="redeemid-product__description" set:html={p.descriptionHtml} /> : p.description && <p class="redeemid-product__description">{p.description}</p>)}
47
114
  {available.length > 0 ? (
48
- <form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
115
+ <form method="post" action="/_emdash/api/auth/redeemid/cart-edit" data-redeemid-product-form>
49
116
  <input type="hidden" name="action" value="add" />
50
117
  <input type="hidden" name="product" value={p.handle} />
51
118
  <input type="hidden" name="return" value={here} />
52
- {p.variants.length > 1 ? (
53
- <p>
54
- <label for={`${id}-variant`}>{text(config, "Option")}</label>
55
- <select id={`${id}-variant`} name="variant">
56
- {p.variants.map((v) => (
57
- <option value={v.id} disabled={!v.availableForSale}>
58
- {text(config, v.availableForSale ? "{option} ({price})" : "{option} ({price}) sold out", { option: v.title, price: money(v.price) })}
59
- </option>
60
- ))}
61
- </select>
62
- </p>
119
+ {options.length > 0 ? (
120
+ <>
121
+ <script type="application/json" data-redeemid-variants set:html={json(variants)} />
122
+ {options.map((o, i) => (
123
+ <p class="redeemid-product__option">
124
+ <label for={`${id}-option-${i}`}>{o.name}</label>
125
+ <select id={`${id}-option-${i}`} name={`option.${o.name}`}>
126
+ {o.values.map((value) => (
127
+ <option value={value} selected={chosen[o.name] === value} disabled={options.length === 1 && !p.variants.some((v) => v.availableForSale && v.selectedOptions?.some((x) => x.name === o.name && x.value === value))}>
128
+ {value}
129
+ </option>
130
+ ))}
131
+ </select>
132
+ </p>
133
+ ))}
134
+ </>
63
135
  ) : (
64
136
  <input type="hidden" name="variant" value={first?.id} />
65
137
  )}
138
+ {plans.length > 0 && (
139
+ <fieldset class="redeemid-product__plans">
140
+ <legend>{text(config, "Purchase options")}</legend>
141
+ {!p.requiresSellingPlan && (
142
+ <p>
143
+ <input type="radio" id={`${id}-plan-none`} name="selling_plan" value="" checked />
144
+ <label for={`${id}-plan-none`}>{text(config, "One-time purchase")}</label>
145
+ </p>
146
+ )}
147
+ {plans.map((sp, i) => (
148
+ <p>
149
+ <input type="radio" id={`${id}-plan-${i}`} name="selling_plan" value={sp.id} checked={p.requiresSellingPlan && i === 0} />
150
+ <label for={`${id}-plan-${i}`}>{sp.group && sp.group !== sp.name ? text(config, "{group}: {plan}", { group: sp.group, plan: sp.name }) : sp.name}</label>
151
+ </p>
152
+ ))}
153
+ </fieldset>
154
+ )}
66
155
  <p>
67
156
  <label for={`${id}-quantity`}>{text(config, "Quantity")}</label>
68
157
  <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
69
158
  </p>
159
+ <p class="redeemid-product__availability" role="status" data-redeemid-availability></p>
160
+ <p class="redeemid-product__form-status" role="status" data-redeemid-form-status></p>
70
161
  <button type="submit" name="then" value="back">{addLabel}</button>
71
162
  <button type="submit" name="then" value="checkout">{buyLabel}</button>
72
163
  </form>
@@ -0,0 +1,52 @@
1
+ ---
2
+ /**
3
+ * Product cards in a grid: image, title, lowest price (with the sale price
4
+ * when there is one). Used by <Collection>, <ProductSearch> and
5
+ * <Recommendations>; use it for your own lists of Storefront products.
6
+ *
7
+ * `quickAdd` puts Add to cart on cards whose product has one variant and
8
+ * needs no choice.
9
+ */
10
+ import { siteConfig } from "../site-config.js";
11
+ import { money, type ProductCard } from "../storefront.js";
12
+ import { text } from "../text.js";
13
+
14
+ interface Props {
15
+ products: ProductCard[];
16
+ /** Link for each product; `{handle}` is replaced. */
17
+ productHref?: string;
18
+ /** Heading level of each card's title. */
19
+ heading?: "h2" | "h3" | "h4";
20
+ quickAdd?: boolean;
21
+ }
22
+
23
+ const { products, productHref, heading = "h3", quickAdd = false } = Astro.props;
24
+ const config = await siteConfig(Astro.locals);
25
+ const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
26
+ const here = Astro.url.pathname + Astro.url.search;
27
+ const Heading = heading;
28
+ ---
29
+
30
+ <ul class="redeemid-collection__grid">
31
+ {products.map((p) => (
32
+ <li class="redeemid-collection__item" data-redeemid-product={p.handle}>
33
+ {p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
34
+ <Heading>{href(p.handle) ? <a href={href(p.handle)}>{p.title}</a> : p.title}</Heading>
35
+ <p class="redeemid-collection__price">
36
+ {p.availableForSale ? (p.price ? text(config, "From {price}", { price: money(p.price) }) : "") : text(config, "Sold out")}
37
+ {p.availableForSale && p.compareAtPrice && <s class="redeemid-collection__compare-at">{money(p.compareAtPrice)}</s>}
38
+ </p>
39
+ {quickAdd && p.quickAddVariantId && (
40
+ <form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
41
+ <input type="hidden" name="action" value="add" />
42
+ <input type="hidden" name="product" value={p.handle} />
43
+ <input type="hidden" name="variant" value={p.quickAddVariantId} />
44
+ <input type="hidden" name="return" value={here} />
45
+ <button type="submit" name="then" value="back">
46
+ {text(config, "Add to cart")}<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> {p.title}</span>
47
+ </button>
48
+ </form>
49
+ )}
50
+ </li>
51
+ ))}
52
+ </ul>
@@ -0,0 +1,83 @@
1
+ ---
2
+ /**
3
+ * Product search for a search page: a search box, Shopify's matching
4
+ * products with its filters, sort orders and paging, all from the page URL
5
+ * (?q=…), so the page must be rendered on demand. With <RedeemIDClient />
6
+ * the box also suggests products as the visitor types.
7
+ *
8
+ * <ProductSearch productHref="/shop/{handle}/" />
9
+ *
10
+ * `boxOnly` renders just the search box (for a header), sending searches to
11
+ * `action`, the search page's path.
12
+ */
13
+ import { browseFromUrl, searchProducts, type ProductPage } from "../catalog.js";
14
+ import { siteConfig } from "../site-config.js";
15
+ import { storeContext } from "../storefront.js";
16
+ import { text } from "../text.js";
17
+ import BrowseControls from "./BrowseControls.astro";
18
+ import ProductGrid from "./ProductGrid.astro";
19
+
20
+ interface Props {
21
+ productHref?: string;
22
+ /** Products per page, 1 to 50. */
23
+ limit?: number;
24
+ /** Only the search box. */
25
+ boxOnly?: boolean;
26
+ /** Where searches go. Defaults to this page. */
27
+ action?: string;
28
+ label?: string;
29
+ quickAdd?: boolean;
30
+ }
31
+
32
+ const config = await siteConfig(Astro.locals);
33
+ const { productHref, limit = 24, boxOnly = false, action = Astro.url.pathname, label = text(config, "Search products"), quickAdd = false } = Astro.props;
34
+ const browse = browseFromUrl(Astro.url.searchParams, limit);
35
+ let page: ProductPage | null = null;
36
+ let failed = false;
37
+ if (!boxOnly && config && browse.query) {
38
+ try {
39
+ page = await searchProducts(config, browse.query, browse, { context: storeContext(config, Astro.request) });
40
+ } catch {
41
+ failed = true;
42
+ }
43
+ }
44
+ const id = boxOnly ? "redeemid-search-box" : "redeemid-search";
45
+ ---
46
+
47
+ <section class="redeemid-search" role="search">
48
+ <form method="get" action={action} class="redeemid-search__form">
49
+ <label for={`${id}-q`}>{label}</label>
50
+ <input
51
+ id={`${id}-q`}
52
+ name="q"
53
+ type="search"
54
+ value={boxOnly ? "" : browse.query}
55
+ autocomplete="off"
56
+ maxlength="200"
57
+ data-redeemid-suggest
58
+ data-redeemid-product-href={productHref}
59
+ aria-controls={`${id}-suggestions`}
60
+ aria-expanded="false"
61
+ />
62
+ <button type="submit">{text(config, "Search")}</button>
63
+ <ul id={`${id}-suggestions`} class="redeemid-search__suggestions" hidden></ul>
64
+ <p style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap" role="status" data-redeemid-suggest-status></p>
65
+ </form>
66
+ {!boxOnly && browse.query && (
67
+ failed ? (
68
+ <p>{text(config, "Search isn't available right now. Try again in a few minutes.")}</p>
69
+ ) : page && (
70
+ <>
71
+ <p class="redeemid-search__count" role="status">
72
+ {page.products.length === 0
73
+ ? text(config, "No products match “{query}”.", { query: browse.query })
74
+ : (page.total ?? page.products.length) === 1
75
+ ? text(config, "1 result for “{query}”", { query: browse.query })
76
+ : text(config, "{count} results for “{query}”", { count: page.total ?? page.products.length, query: browse.query })}
77
+ </p>
78
+ {(page.products.length > 0 || page.filters.length > 0) && <BrowseControls filters={page.filters} pageInfo={page.pageInfo} search query={browse.query} id={id} />}
79
+ <ProductGrid products={page.products} productHref={productHref} heading="h2" quickAdd={quickAdd} />
80
+ </>
81
+ )
82
+ )}
83
+ </section>
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * Shopify's product recommendations under a product: "related" (You may also
4
+ * like) or "complementary" (Pair it with, set in the Search & Discovery
5
+ * app). Renders nothing when Shopify has none.
6
+ *
7
+ * <Recommendations handle="harbor-tote" productHref="/shop/{handle}/" />
8
+ */
9
+ import { recommendations } from "../catalog.js";
10
+ import { siteConfig } from "../site-config.js";
11
+ import { product, storeContext, type ProductCard } from "../storefront.js";
12
+ import { text } from "../text.js";
13
+ import ProductGrid from "./ProductGrid.astro";
14
+
15
+ interface Props {
16
+ handle: string;
17
+ intent?: "related" | "complementary";
18
+ /** Up to 10. */
19
+ limit?: number;
20
+ productHref?: string;
21
+ title?: string;
22
+ quickAdd?: boolean;
23
+ }
24
+
25
+ const config = await siteConfig(Astro.locals);
26
+ const { handle, intent = "related", limit = 4, productHref, quickAdd = false } = Astro.props;
27
+ const title = Astro.props.title ?? text(config, intent === "complementary" ? "Pair it with" : "You may also like");
28
+ let cards: ProductCard[] = [];
29
+ if (config) {
30
+ try {
31
+ const context = storeContext(config, Astro.request);
32
+ const p = await product(config, handle, undefined, context);
33
+ if (p?.id) cards = (await recommendations(config, p.id, intent, { context })).slice(0, Math.max(1, Math.min(10, limit)));
34
+ } catch {
35
+ cards = [];
36
+ }
37
+ }
38
+ ---
39
+
40
+ {cards.length > 0 && (
41
+ <section class="redeemid-recommendations" aria-labelledby={`redeemid-recs-${handle}`}>
42
+ <h2 id={`redeemid-recs-${handle}`}>{title}</h2>
43
+ <ProductGrid products={cards} productHref={productHref} quickAdd={quickAdd} />
44
+ </section>
45
+ )}
@@ -0,0 +1,45 @@
1
+ ---
2
+ /**
3
+ * The browser client, window.redeemid. Put it once in the site layout
4
+ * (before </body>). See docs/developer-api.md for what it offers.
5
+ *
6
+ * <RedeemIDClient />
7
+ */
8
+ import { API_VERSION } from "../api-version.js";
9
+ import { NOTICES } from "../notices.js";
10
+ import { siteConfig } from "../site-config.js";
11
+ import { text } from "../text.js";
12
+ import { VERSION } from "../version.js";
13
+
14
+ const config = await siteConfig(Astro.locals);
15
+ const boot = {
16
+ version: VERSION,
17
+ apiVersion: API_VERSION,
18
+ base: "/_emdash/api/auth/redeemid",
19
+ locale: "en-US",
20
+ notices: Object.fromEntries(Object.entries(NOTICES).map(([code, message]) => [code, text(config, message)])),
21
+ strings: {
22
+ "Your cart is empty.": text(config, "Your cart is empty."),
23
+ "Loading your cart…": text(config, "Loading your cart…"),
24
+ "{price} each · {total}": text(config, "{price} each · {total}"),
25
+ Quantity: text(config, "Quantity"),
26
+ Update: text(config, "Update"),
27
+ Remove: text(config, "Remove"),
28
+ "Discount code": text(config, "Discount code"),
29
+ Apply: text(config, "Apply"),
30
+ "{code} doesn't apply to this cart.": text(config, "{code} doesn't apply to this cart."),
31
+ Subtotal: text(config, "Subtotal"),
32
+ Total: text(config, "Total"),
33
+ "Taxes and shipping are worked out at checkout.": text(config, "Taxes and shipping are worked out at checkout."),
34
+ Checkout: text(config, "Checkout"),
35
+ "Sold out.": text(config, "Sold out."),
36
+ "Not available in this combination.": text(config, "Not available in this combination."),
37
+ "{count} suggestions": text(config, "{count} suggestions"),
38
+ },
39
+ };
40
+ ---
41
+
42
+ <script type="application/json" id="redeemid-client-config" set:html={JSON.stringify(boot).replace(/</g, "\\u003c")} />
43
+ <script>
44
+ import "../client/redeemid.ts";
45
+ </script>
@@ -16,7 +16,7 @@ interface Props {
16
16
  heading?: "h2" | "h3" | "h4";
17
17
  }
18
18
 
19
- const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
19
+ const config = await siteConfig(Astro.locals);
20
20
  const { title = text(config, "Your data"), heading = "h2" } = Astro.props;
21
21
  const v = await viewer(Astro);
22
22
  const route = "/_emdash/api/auth/redeemid/privacy";
package/src/config.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  /**
2
2
  * RedeemID configuration.
3
3
  *
4
- * Levels and rules live in code (astro.config.mjs), so they are versioned with
5
- * the site. Secrets come from the environment and never from config.
4
+ * Levels and rules can be set in code (astro.config.mjs) as the site's
5
+ * defaults, and changed in the admin (Plugins → RedeemID → Access), which
6
+ * wins for each setting it saves (see settings.ts). Secrets come from the
7
+ * environment and never from config.
6
8
  */
7
9
 
10
+ import type { EntryRule } from "./settings.js";
11
+
8
12
  /** A member level, held by Shopify customers with its tags. */
9
13
  export interface LevelConfig {
10
14
  /** Stable slug, used in rules (e.g. "member"). */
@@ -26,9 +30,11 @@ export interface LevelConfig {
26
30
  *
27
31
  * - A rule with `taxonomy` and `term` covers entries that carry that term, or
28
32
  * a child of it in a hierarchical taxonomy (a child's own rule wins over its
29
- * parent's).
33
+ * parent's). With `collection: "*"` it covers that term in every
34
+ * collection; a rule for the same term and a named collection wins there.
30
35
  * - A rule with neither covers the whole collection.
31
- * - `collection: "*"` is the site default, for collections with no rule.
36
+ * - `collection: "*"` with neither is the site default, for collections with
37
+ * no rule.
32
38
  *
33
39
  * Term rules beat the collection rule, which beats the site default. When
34
40
  * several term rules cover one entry, a reader must pass every one of them
@@ -44,7 +50,8 @@ export interface RuleConfig {
44
50
  /** Level slugs, any of which may read. An empty list means any signed-in member. */
45
51
  levels: string[];
46
52
  /**
47
- * A term rule that opens its entries to everyone, whatever else covers them
53
+ * Opens the entries to everyone: a term rule whatever else covers them, a
54
+ * collection rule or the site default unless a term rule locks them.
48
55
  * `levels` is ignored.
49
56
  */
50
57
  open?: boolean;
@@ -137,6 +144,20 @@ export interface RedeemIDConfig {
137
144
  * { "Sign in": "Log in" }. One string at a time; see text.ts for the list.
138
145
  */
139
146
  text?: Record<string, string>;
147
+ /**
148
+ * Local prices and currency through Shopify Markets: `country` is a fixed
149
+ * two-letter code, or "visitor" for the country Cloudflare reports for
150
+ * each request (CF-IPCountry). `language` is a Shopify language code such
151
+ * as "FR". Prices are always Shopify's; leave this out for the store's
152
+ * default market.
153
+ */
154
+ market?: { country?: string; language?: string };
155
+ /**
156
+ * Checkout. `signIn: false` stops RedeemID passing a signed-in member's
157
+ * store sign-in to the cart, so they check out as a guest (their email is
158
+ * still filled in).
159
+ */
160
+ checkout?: { signIn?: false };
140
161
  /** The wall's join link: where readers become members, e.g. { href: "/join/", label: "Become a member" }. */
141
162
  join?: { href: string; label?: string };
142
163
  /**
@@ -161,6 +182,11 @@ export interface RedeemIDConfig {
161
182
  * under /_emdash is refused to Subscribers (see guard.ts).
162
183
  */
163
184
  memberPaths?: string[];
185
+ /**
186
+ * Entries' own rules by "collection:id" (or "collection:slug"). Usually set
187
+ * in the editor's Access panel rather than here.
188
+ */
189
+ entries?: Record<string, EntryRule>;
164
190
  /**
165
191
  * Only for local tests: lets `shop` be a full http:// origin of a fake store.
166
192
  * Never set this on a live site.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The developer API, as a list: what site code, other plugins and agents may
3
+ * rely on. docs/developer-api.md is the readable version, and
4
+ * tests/contract.test.ts fails when the code, this list and that page
5
+ * disagree.
6
+ *
7
+ * - Everything here is stable: it changes only with a changelog entry and an
8
+ * API_VERSION bump (minor when added, major when changed or removed).
9
+ * Before 1.0.0 of the package a breaking change can still happen, but
10
+ * never silently.
11
+ * - Anything not listed is internal, even when a module exports it.
12
+ */
13
+
14
+ export { API_VERSION } from "./api-version.js";
15
+
16
+ /** Stable exports, by import path. */
17
+ export const MODULES = {
18
+ "@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
19
+ "@redeemid/emdash/access": ["viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
20
+ "@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
21
+ "@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
22
+ "@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
23
+ "@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
24
+ "@redeemid/emdash/events": ["signBody"],
25
+ "@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
26
+ } as const;
27
+
28
+ /** Public JSON routes under /_emdash/api/auth/redeemid/. */
29
+ export const JSON_ROUTES = ["api/state", "api/cart", "api/search", "preferences", "privacy"] as const;
30
+
31
+ /** Form routes under /_emdash/api/auth/redeemid/ (each redirects back with a notice). */
32
+ export const FORM_ROUTES = ["login", "callback", "logout", "webhook", "buy", "cart", "cart-edit", "account"] as const;
33
+
34
+ /** window.redeemid members. */
35
+ export const CLIENT = ["version", "apiVersion", "events", "subscribe", "emit", "state", "refresh", "signIn", "signOut", "cart", "openCart", "closeCart", "search", "suggest", "collection"] as const;
36
+
37
+ /** window.redeemid.cart members. */
38
+ export const CLIENT_CART = ["get", "add", "update", "remove", "discount", "checkout"] as const;
39
+
40
+ /** Browser events, in the order of window.redeemid.events. */
41
+ export const CLIENT_EVENTS = ["ready", "state", "wall_shown", "metered_view", "sign_in_click", "checkout_click", "cart_changed", "cart_opened", "search"] as const;
42
+
43
+ /** Signed outbound server events (events.ts). */
44
+ export const SERVER_EVENTS = ["member.signed_in", "member.levels_changed", "meter.used", "meter.exhausted", "preferences.saved", "member.erased"] as const;
45
+
46
+ /** MCP tools, always offered (read-only). */
47
+ export const MCP_TOOLS = ["explain_access", "list_levels", "get_rules", "get_member", "site_health", "level_changes", "find_products", "get_product", "get_collection"] as const;
48
+
49
+ /** MCP tools offered only with config agents.actions. */
50
+ export const MCP_ACTIONS = ["resync_member", "resync_batch", "edit_member_tags"] as const;
package/src/descriptor.ts CHANGED
@@ -10,10 +10,16 @@ import { VERSION } from "./version.js";
10
10
 
11
11
  export const PAGES = [
12
12
  { path: "/", label: "Members", icon: "users" },
13
+ { path: "/access", label: "Access", icon: "lock" },
14
+ { path: "/webhooks", label: "Webhooks", icon: "pulse" },
15
+ { path: "/events", label: "App events", icon: "list" },
16
+ { path: "/reports", label: "Reports", icon: "chart" },
13
17
  { path: "/status", label: "Status", icon: "pulse" },
14
18
  { path: "/test", label: "Access test", icon: "search" },
15
19
  { path: "/privacy", label: "Privacy", icon: "lock" },
16
20
  ];
21
+ /** The Access panel on every saved entry, in every collection (new ones too). */
22
+ export const EDITOR_PANELS = [{ id: "access", title: "Access", route: "access-panel", order: 10 }];
17
23
  export const WIDGETS = [{ id: "status", title: "RedeemID", size: "half" as const }];
18
24
 
19
25
  /** The descriptor for `emdash({ plugins: [...] })`. Pass the same config as `redeemid()`. */
@@ -26,6 +32,7 @@ export function redeemidPlugin(config: RedeemIDConfig): PluginDescriptor<RedeemI
26
32
  options: config,
27
33
  adminPages: PAGES,
28
34
  adminWidgets: WIDGETS,
35
+ editorPanels: EDITOR_PANELS,
29
36
  };
30
37
  }
31
38
 
package/src/events.ts CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { env, ENV, type RedeemIDConfig } from "./config.js";
16
16
  import { USER_AGENT } from "./shopify.js";
17
+ import { record } from "./log.js";
17
18
 
18
19
  export type EventName = "member.signed_in" | "member.levels_changed" | "meter.used" | "meter.exhausted" | "preferences.saved" | "member.erased";
19
20
 
@@ -46,9 +47,11 @@ export async function emit(config: RedeemIDConfig | null, name: EventName, data:
46
47
  body,
47
48
  signal: AbortSignal.timeout(TIMEOUT_MS),
48
49
  });
50
+ if (!res.ok) await record({ event: "events.failed", label: name, status: `HTTP ${res.status}`, level: "warn", origin: "site" });
49
51
  return res.ok;
50
52
  } catch (e) {
51
53
  console.error("[redeemid] event not sent:", name, (e as Error).message);
54
+ await record({ event: "events.failed", label: name, status: "error", level: "error", origin: "site", note: String((e as Error).message).slice(0, 200) });
52
55
  return false;
53
56
  }
54
57
  }
package/src/features.ts CHANGED
@@ -32,7 +32,7 @@ export async function hasFeature(astro: AstroLike, slug: string): Promise<boolea
32
32
 
33
33
  /** The visitor's features. */
34
34
  export async function viewerFeatures(astro: AstroLike): Promise<string[]> {
35
- const config = siteConfig((astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
35
+ const config = await siteConfig(astro.locals);
36
36
  if (!config?.features?.length) return [];
37
37
  const v = await viewer(astro);
38
38
  return featuresFor(v.member, config.features, config.levels);
package/src/files.ts CHANGED
@@ -101,7 +101,7 @@ async function readsWhole(astro: AstroLike, entry: { collection: string; id: str
101
101
  const m = (await viewer(astro)).member;
102
102
  if (m && m.status !== "disabled" && holdsItem(item, m.tags)) return true;
103
103
  }
104
- const verdict = await readEntry(astro, { collection: entry.collection, terms });
104
+ const verdict = await readEntry(astro, { collection: entry.collection, terms, ruleId: entry.id });
105
105
  if (!verdict.allowed) return false;
106
106
  const clauses = bodyClauses(body);
107
107
  return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
package/src/guard.ts CHANGED
@@ -25,7 +25,7 @@ import { signInUrl } from "./access.js";
25
25
  import { readCart } from "./cart.js";
26
26
  import { keysFromPath, pathAccess, refusal } from "./files.js";
27
27
  import { AFTER_LOGOUT_COOKIE, readAfterLogout } from "./routes/shared.js";
28
- import { siteConfig } from "./site-config.js";
28
+ import { shareConfig, siteConfig } from "./site-config.js";
29
29
 
30
30
  const SUBSCRIBER = 10;
31
31
 
@@ -55,7 +55,9 @@ async function withUser(context: { locals: unknown; session?: { get(k: string):
55
55
  const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
56
56
  const user = await createKyselyAdapter(locals.emdash.db as never).getUserById(sessionUser.id);
57
57
  if (!user || user.disabled) return context;
58
- return { ...context, locals: { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } } };
58
+ const next = { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } };
59
+ shareConfig(locals, next);
60
+ return { ...context, locals: next };
59
61
  }
60
62
 
61
63
  /** Keep one visitor's response out of every shared cache, the route cache included. */
@@ -75,7 +77,7 @@ export function keepPrivate(context: { cache?: { set(options: false): void } },
75
77
  export const onRequest = defineMiddleware(async (context, next) => {
76
78
  const { locals, url } = context;
77
79
  const user = (locals as { user?: { role?: number } | null }).user;
78
- const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
80
+ const config = await siteConfig(locals);
79
81
 
80
82
  // Protected files: checked for everyone, on every download.
81
83
  const keys = config?.files?.length ? keysFromPath(url.pathname) : null;