@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
|
@@ -1,72 +1,163 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* A Shopify product:
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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(
|
|
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
|
-
{
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
{
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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(
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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.
|
package/src/contract.ts
ADDED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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;
|