@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.
- package/README.md +35 -3
- package/docs/shopify-setup.md +1 -1
- package/package.json +1 -1
- package/src/access.ts +60 -0
- package/src/blocks/CollectionBlock.astro +22 -0
- package/src/blocks/ContributionBlock.astro +24 -0
- package/src/blocks/PlanBlock.astro +2 -2
- package/src/blocks/ProductBlock.astro +12 -0
- package/src/blocks/index.ts +6 -0
- package/src/blocks-names.ts +3 -0
- package/src/components/CartView.astro +97 -0
- package/src/components/Collection.astro +55 -0
- package/src/components/Contribution.astro +74 -0
- package/src/components/MemberCard.astro +95 -0
- package/src/components/Notices.astro +5 -0
- package/src/components/Plan.astro +14 -4
- package/src/components/Product.astro +78 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +49 -2
- package/src/routes/buy.ts +1 -1
- package/src/routes/callback.ts +6 -2
- package/src/routes/cart-edit.ts +97 -0
- package/src/routes/login.ts +6 -0
- package/src/routes/shared.ts +2 -0
- package/src/shopify.ts +11 -1
- package/src/storage.ts +19 -1
- package/src/storefront.ts +290 -3
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
|
|
4
4
|
|
|
5
|
-
**Status: 0.0.
|
|
5
|
+
**Status: 0.0.8, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
|
|
6
6
|
|
|
7
7
|
## What it does
|
|
8
8
|
|
|
@@ -13,11 +13,13 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
|
|
|
13
13
|
- **Meter and drip.** Signed-in readers may open a few locked entries free each month or week. A rule can also drip: members read its entries once they've held their level for so many days.
|
|
14
14
|
- **Teasers and badges.** Locked entries can show their first paragraphs, up to a word count. `<AccessBadge>` names the level an entry needs and shows a lock to readers it keeps out.
|
|
15
15
|
- **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
|
|
16
|
+
- **Member account card.** `<MemberCard>` shows members their levels with when each started, the levels they held before, the meter, and Refresh membership, which reads their tags from the store again.
|
|
16
17
|
- **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
|
|
17
18
|
- **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
|
|
18
19
|
- **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
|
|
19
20
|
- **The wall.** Readers kept out see why and what to do next: sign in, or join, with each level's own wall text. `<Notices>` shows the messages sign-in, Buy, Cart and Account send readers back with.
|
|
20
21
|
- **Plan block.** Editors add a Shopify product to any page with the "Plan" block: title, price and a buy button.
|
|
22
|
+
- **Shop.** `<Product>`, `<Collection>` and `<CartView>` (with the Product and Collection blocks) give the site product pages, collection grids and a cart page with quantities and discount codes. `<Contribution>` (and its block) takes pay-what-you-want amounts. Carts note the page each sale started on.
|
|
21
23
|
- **In the admin.** RedeemID shows under Plugins with a Members page (Shopify customers, tags, levels), a Status page (secrets, store discovery, the URLs to give Shopify, levels and rules) and a dashboard widget.
|
|
22
24
|
- **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
|
|
23
25
|
|
|
@@ -154,17 +156,47 @@ import Cart from "@redeemid/emdash/components/Cart.astro";
|
|
|
154
156
|
<Cart />
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
|
|
159
|
+
For an account page, `<MemberCard />` shows the member's levels (with when each started), levels they held before, how much of the meter they've used, and links to their store account, Refresh membership and Sign out. Visitors get a sign-in link. Refresh membership (`refreshUrl(returnTo)` in `@redeemid/emdash/access`) sends the member to the store with no sign-in form; a member still signed in there comes straight back with their tags read again and the `refreshed` notice, and anyone else goes through the usual sign-in. Use it after a purchase, when Shopify's tag webhook hasn't arrived yet.
|
|
160
|
+
|
|
161
|
+
Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag. With `levels`, members who already hold one of them see "You're on this plan." (`heldLabel`) in place of the button; the Plan block has the same choice:
|
|
158
162
|
|
|
159
163
|
```astro
|
|
160
164
|
---
|
|
161
165
|
import Plan from "@redeemid/emdash/components/Plan.astro";
|
|
162
166
|
---
|
|
163
|
-
<Plan handle="gold-membership" label="Join Gold">
|
|
167
|
+
<Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
|
|
164
168
|
<p>Shown if the product can't be loaded.</p>
|
|
165
169
|
</Plan>
|
|
166
170
|
```
|
|
167
171
|
|
|
172
|
+
### Shop
|
|
173
|
+
|
|
174
|
+
Products, collections and the cart come from the Storefront API; Shopify works out prices, discounts, taxes and stock.
|
|
175
|
+
|
|
176
|
+
```astro
|
|
177
|
+
---
|
|
178
|
+
import Product from "@redeemid/emdash/components/Product.astro";
|
|
179
|
+
import Collection from "@redeemid/emdash/components/Collection.astro";
|
|
180
|
+
import CartView from "@redeemid/emdash/components/CartView.astro";
|
|
181
|
+
import Contribution from "@redeemid/emdash/components/Contribution.astro";
|
|
182
|
+
---
|
|
183
|
+
<!-- /shop/ -->
|
|
184
|
+
<Collection handle="shop" productHref="/shop/{handle}/" showTitle />
|
|
185
|
+
<!-- /shop/[handle]/ -->
|
|
186
|
+
<Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
|
|
187
|
+
<!-- /cart/ -->
|
|
188
|
+
<CartView productHref="/shop/{handle}/" />
|
|
189
|
+
<!-- anywhere -->
|
|
190
|
+
<Contribution handle="support" amounts={[5, 10, 25]} />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `<Product>`: image, title, price, description, the variants (sold-out ones shown but not choosable), a quantity, Add to cart (back to the page with the `cart_added` notice) and Buy now (straight to checkout).
|
|
194
|
+
- `<Collection>`: the collection's products (`limit`, up to 50) as cards linking to `productHref`.
|
|
195
|
+
- `<CartView>`: each line with its price, a quantity to change (0 removes it) and Remove, a discount code field, the subtotal and total, and Checkout. Pages with it are sent `Cache-Control: private, no-store`.
|
|
196
|
+
- `<Contribution>`: pay what you want. Make a Shopify product priced at 1.00; the amount the reader picks becomes the quantity, so Shopify charges exactly that.
|
|
197
|
+
|
|
198
|
+
The forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product and variant from Shopify again and only changes lines in the visitor's own cart. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin; Shopify hides attributes starting with `_` at checkout. Editors get the same with the Product, Collection and Contribution blocks.
|
|
199
|
+
|
|
168
200
|
Or ask directly:
|
|
169
201
|
|
|
170
202
|
```ts
|
package/docs/shopify-setup.md
CHANGED
|
@@ -53,4 +53,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
|
|
|
53
53
|
3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
|
|
54
54
|
4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
|
|
55
55
|
|
|
56
|
-
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails.
|
|
56
|
+
If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Cart changes return `cart_added`, `cart_updated`, `discount_applied` or `discount_not_applicable`. Refresh membership returns `refreshed` once it has read the member's tags again.
|
package/package.json
CHANGED
package/src/access.ts
CHANGED
|
@@ -187,6 +187,61 @@ export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): s
|
|
|
187
187
|
return starts[0] ?? member.linkedAt ?? null;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
export interface HeldLevel {
|
|
191
|
+
slug: string;
|
|
192
|
+
name: string;
|
|
193
|
+
/** When the level started (see memberSince), ISO, or null when unknown. */
|
|
194
|
+
since: string | null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface PastLevel {
|
|
198
|
+
slug: string;
|
|
199
|
+
name: string;
|
|
200
|
+
from: string | null;
|
|
201
|
+
to: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The member's levels now (with when each started) and the levels they held
|
|
206
|
+
* before (from the tags Shopify has since removed), newest first.
|
|
207
|
+
*/
|
|
208
|
+
export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
|
|
209
|
+
const held = levelsFor(member.tags, levels);
|
|
210
|
+
const current = held.map((slug) => {
|
|
211
|
+
const level = levels.find((l) => l.slug === slug)!;
|
|
212
|
+
return { slug, name: level.name, since: memberSince({ ...member, linkedAt: null }, [level]) };
|
|
213
|
+
});
|
|
214
|
+
const pastTags = member.pastTags ?? [];
|
|
215
|
+
const past: PastLevel[] = [];
|
|
216
|
+
for (const level of levels) {
|
|
217
|
+
if (held.includes(level.slug)) continue;
|
|
218
|
+
const wanted = new Set(level.tags.map((t) => t.toLowerCase()));
|
|
219
|
+
const spans = pastTags.filter((p) => wanted.has(p.tag));
|
|
220
|
+
if (spans.length === 0) continue;
|
|
221
|
+
const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
|
|
222
|
+
const to = spans.map((p) => p.to).sort().at(-1)!;
|
|
223
|
+
past.push({ slug: level.slug, name: level.name, from: froms[0] ?? null, to });
|
|
224
|
+
}
|
|
225
|
+
past.sort((a, b) => (a.to < b.to ? 1 : a.to > b.to ? -1 : 0));
|
|
226
|
+
return { current, past };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** How much of this period's meter the signed-in reader has used, or null when no meter runs. */
|
|
230
|
+
export async function meterUsage(astro: AstroLike): Promise<{ used: number; limit: number; period: "month" | "week" } | null> {
|
|
231
|
+
const locals = astro.locals as Locals;
|
|
232
|
+
const meter = siteConfig(locals.emdash?.config)?.meter;
|
|
233
|
+
const user = locals.user;
|
|
234
|
+
const db = locals.emdash?.db;
|
|
235
|
+
if (!meter || !(meter.limit > 0) || !user?.id || !db) return null;
|
|
236
|
+
const period = meter.period ?? "month";
|
|
237
|
+
try {
|
|
238
|
+
const record = await stores(db).meter.get(`${user.id}:${periodKey(new Date(), period)}`);
|
|
239
|
+
return { used: Math.min(record?.items.length ?? 0, meter.limit), limit: meter.limit, period };
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
190
245
|
/** When drip opens an entry for this member: `since` plus `days`. */
|
|
191
246
|
export function dripOpensAt(since: string | null, days: number): Date | null {
|
|
192
247
|
if (!since || days <= 0) return null;
|
|
@@ -355,6 +410,11 @@ export function accountUrl(returnTo = "/"): string {
|
|
|
355
410
|
return `/_emdash/api/auth/redeemid/account?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
356
411
|
}
|
|
357
412
|
|
|
413
|
+
/** Re-read the member's tags from the store without a sign-in form when their store session is still open. */
|
|
414
|
+
export function refreshUrl(returnTo = "/"): string {
|
|
415
|
+
return `/_emdash/api/auth/redeemid/login?refresh=1&return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
416
|
+
}
|
|
417
|
+
|
|
358
418
|
export function signOutUrl(returnTo = "/"): string {
|
|
359
419
|
return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
|
|
360
420
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The "Collection" block: a Shopify collection's products as cards. */
|
|
3
|
+
import Collection from "../components/Collection.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: { handle?: string; limit?: string; link?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { node } = Astro.props;
|
|
10
|
+
const handle = (node.handle ?? "").trim();
|
|
11
|
+
const limit = Number.parseInt(node.limit ?? "", 10);
|
|
12
|
+
const link = (node.link ?? "").trim();
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{handle && (
|
|
16
|
+
<Collection
|
|
17
|
+
handle={handle}
|
|
18
|
+
limit={Number.isFinite(limit) ? limit : 12}
|
|
19
|
+
productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined}
|
|
20
|
+
showTitle
|
|
21
|
+
/>
|
|
22
|
+
)}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The "Contribution" block: pay what you want with a 1.00 Shopify product. */
|
|
3
|
+
import Contribution from "../components/Contribution.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: { handle?: string; title?: string; label?: string; amounts?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { node } = Astro.props;
|
|
10
|
+
const handle = (node.handle ?? "").trim();
|
|
11
|
+
const amounts = (node.amounts ?? "")
|
|
12
|
+
.split(",")
|
|
13
|
+
.map((a) => Number.parseInt(a.trim(), 10))
|
|
14
|
+
.filter((a) => Number.isFinite(a) && a > 0 && a <= 1000);
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
{handle && (
|
|
18
|
+
<Contribution
|
|
19
|
+
handle={handle}
|
|
20
|
+
title={node.title?.trim() || undefined}
|
|
21
|
+
label={node.label?.trim() || "Give"}
|
|
22
|
+
amounts={amounts.length ? amounts : undefined}
|
|
23
|
+
/>
|
|
24
|
+
)}
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import Plan from "../components/Plan.astro";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
-
node: { handle?: string; title?: string; label?: string };
|
|
6
|
+
node: { handle?: string; title?: string; label?: string; levels?: string[] };
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const { node } = Astro.props;
|
|
10
10
|
const handle = (node.handle ?? "").trim();
|
|
11
11
|
---
|
|
12
12
|
|
|
13
|
-
{handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} />}
|
|
13
|
+
{handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} levels={Array.isArray(node.levels) ? node.levels : []} />}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The "Product" block: a Shopify product with Add to cart and Buy now. */
|
|
3
|
+
import Product from "../components/Product.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: { handle?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const handle = (Astro.props.node.handle ?? "").trim();
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
{handle && <Product handle={handle} heading="h3" />}
|
package/src/blocks/index.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
* EmDash from the plugin's `componentsEntry`.
|
|
4
4
|
*/
|
|
5
5
|
import Marker from "./PaywallBreak.astro";
|
|
6
|
+
import CollectionBlock from "./CollectionBlock.astro";
|
|
7
|
+
import ContributionBlock from "./ContributionBlock.astro";
|
|
6
8
|
import PlanBlock from "./PlanBlock.astro";
|
|
9
|
+
import ProductBlock from "./ProductBlock.astro";
|
|
7
10
|
|
|
8
11
|
export const blockComponents = {
|
|
9
12
|
"redeemid-paywall": Marker,
|
|
10
13
|
"redeemid-members": Marker,
|
|
11
14
|
"redeemid-members-end": Marker,
|
|
12
15
|
"redeemid-plan": PlanBlock,
|
|
16
|
+
"redeemid-product": ProductBlock,
|
|
17
|
+
"redeemid-collection": CollectionBlock,
|
|
18
|
+
"redeemid-contribution": ContributionBlock,
|
|
13
19
|
};
|
package/src/blocks-names.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/** Portable Text block types RedeemID adds to the editor. */
|
|
2
2
|
export const PAYWALL_BLOCK = "redeemid-paywall";
|
|
3
3
|
export const PLAN_BLOCK = "redeemid-plan";
|
|
4
|
+
export const PRODUCT_BLOCK = "redeemid-product";
|
|
5
|
+
export const COLLECTION_BLOCK = "redeemid-collection";
|
|
6
|
+
export const CONTRIBUTION_BLOCK = "redeemid-contribution";
|
|
4
7
|
/** Starts a members-only part of the body; it runs to the next end block. */
|
|
5
8
|
export const MEMBERS_BLOCK = "redeemid-members";
|
|
6
9
|
export const MEMBERS_END_BLOCK = "redeemid-members-end";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The visitor's Shopify cart for a cart page: each line with its price and
|
|
4
|
+
* a quantity to change or remove, a discount code, the totals, and
|
|
5
|
+
* Checkout. Shopify works out prices and discounts; changes go through
|
|
6
|
+
* the cart-edit route and come back here with a notice.
|
|
7
|
+
*
|
|
8
|
+
* <CartView />
|
|
9
|
+
*/
|
|
10
|
+
import { cartUrl } from "../access.js";
|
|
11
|
+
import { readCart, saveCart } from "../cart.js";
|
|
12
|
+
import { siteConfig } from "../site-config.js";
|
|
13
|
+
import { cartDetails, money, type CartDetails } from "../storefront.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Link for each line's product; `{handle}` is replaced. */
|
|
17
|
+
productHref?: string;
|
|
18
|
+
/** Shown when the cart is empty. */
|
|
19
|
+
empty?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { productHref, empty = "Your cart is empty." } = Astro.props;
|
|
23
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
24
|
+
const ref = await readCart(Astro.session);
|
|
25
|
+
let cart: CartDetails | null = null;
|
|
26
|
+
let failed = false;
|
|
27
|
+
if (config && ref) {
|
|
28
|
+
try {
|
|
29
|
+
cart = await cartDetails(config, ref.id, { buyerIp: Astro.request.headers.get("cf-connecting-ip") });
|
|
30
|
+
if (!cart) saveCart(Astro.session, null);
|
|
31
|
+
else if (cart.totalQuantity !== ref.count) saveCart(Astro.session, { id: cart.id, count: cart.totalQuantity });
|
|
32
|
+
} catch {
|
|
33
|
+
failed = true;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// A cart page is the visitor's own.
|
|
37
|
+
Astro.response.headers.set("Cache-Control", "private, no-store");
|
|
38
|
+
const here = Astro.url.pathname;
|
|
39
|
+
const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
|
|
40
|
+
const action = "/_emdash/api/auth/redeemid/cart-edit";
|
|
41
|
+
const code = cart?.discountCodes[0];
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
<section class="redeemid-cartview" data-redeemid-cart-count={cart?.totalQuantity ?? 0}>
|
|
45
|
+
{failed ? (
|
|
46
|
+
<p>Your cart isn't available right now. Try again in a few minutes.</p>
|
|
47
|
+
) : !cart || cart.lines.length === 0 ? (
|
|
48
|
+
<p class="redeemid-cartview__empty">{empty}</p>
|
|
49
|
+
) : (
|
|
50
|
+
<>
|
|
51
|
+
<ul class="redeemid-cartview__lines">
|
|
52
|
+
{cart.lines.map((l) => (
|
|
53
|
+
<li class="redeemid-cartview__line" data-redeemid-line={l.id}>
|
|
54
|
+
{l.image && <img src={l.image.url} alt={l.image.altText ?? ""} loading="lazy" />}
|
|
55
|
+
<div class="redeemid-cartview__item">
|
|
56
|
+
<p class="redeemid-cartview__name">
|
|
57
|
+
{href(l.productHandle) ? <a href={href(l.productHandle)}>{l.productTitle}</a> : l.productTitle}
|
|
58
|
+
{l.variantTitle && l.variantTitle !== "Default Title" && <span> · {l.variantTitle}</span>}
|
|
59
|
+
</p>
|
|
60
|
+
<p class="redeemid-cartview__price">{money(l.price)} each · {money(l.total)}</p>
|
|
61
|
+
<form method="post" action={action} class="redeemid-cartview__quantity">
|
|
62
|
+
<input type="hidden" name="action" value="update" />
|
|
63
|
+
<input type="hidden" name="line" value={l.id} />
|
|
64
|
+
<input type="hidden" name="return" value={here} />
|
|
65
|
+
<label for={`q-${l.id}`}>Quantity</label>
|
|
66
|
+
<input id={`q-${l.id}`} name="quantity" type="number" min="0" max="1000" value={l.quantity} inputmode="numeric" />
|
|
67
|
+
<button type="submit">Update</button>
|
|
68
|
+
</form>
|
|
69
|
+
<form method="post" action={action}>
|
|
70
|
+
<input type="hidden" name="action" value="remove" />
|
|
71
|
+
<input type="hidden" name="line" value={l.id} />
|
|
72
|
+
<input type="hidden" name="return" value={here} />
|
|
73
|
+
<button type="submit" class="redeemid-cartview__remove">Remove<span style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap"> {l.productTitle}</span></button>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
76
|
+
</li>
|
|
77
|
+
))}
|
|
78
|
+
</ul>
|
|
79
|
+
<form method="post" action={action} class="redeemid-cartview__discount">
|
|
80
|
+
<input type="hidden" name="action" value="discount" />
|
|
81
|
+
<input type="hidden" name="return" value={here} />
|
|
82
|
+
<label for="redeemid-discount">Discount code</label>
|
|
83
|
+
<input id="redeemid-discount" name="code" type="text" value={code?.code ?? ""} autocomplete="off" />
|
|
84
|
+
<button type="submit">Apply</button>
|
|
85
|
+
{code && !code.applicable && <p class="redeemid-cartview__code-note">{code.code} doesn't apply to this cart.</p>}
|
|
86
|
+
</form>
|
|
87
|
+
<dl class="redeemid-cartview__totals">
|
|
88
|
+
<dt>Subtotal</dt>
|
|
89
|
+
<dd>{money(cart.subtotal)}</dd>
|
|
90
|
+
<dt>Total</dt>
|
|
91
|
+
<dd>{money(cart.total)}</dd>
|
|
92
|
+
</dl>
|
|
93
|
+
<p class="redeemid-cartview__note">Taxes and shipping are worked out at checkout.</p>
|
|
94
|
+
<a class="redeemid-cartview__checkout" href={cartUrl(here)}>Checkout</a>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</section>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A Shopify collection as a grid of product cards: image, title, lowest
|
|
4
|
+
* price. Each card links to `productHref` with `{handle}` filled in, so the
|
|
5
|
+
* site can give every product its own page with <Product>.
|
|
6
|
+
*
|
|
7
|
+
* <Collection handle="shop" productHref="/shop/{handle}/" />
|
|
8
|
+
*/
|
|
9
|
+
import { siteConfig } from "../site-config.js";
|
|
10
|
+
import { collection as loadCollection, money, type Collection } from "../storefront.js";
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
handle: string;
|
|
14
|
+
/** How many products, 1 to 50. */
|
|
15
|
+
limit?: number;
|
|
16
|
+
/** Link for each product; `{handle}` is replaced. Leave out for cards without links. */
|
|
17
|
+
productHref?: string;
|
|
18
|
+
/** Show the collection's title and description above the grid. */
|
|
19
|
+
showTitle?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { handle, limit = 12, productHref, showTitle = false } = Astro.props;
|
|
23
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
24
|
+
let c: Collection | null = null;
|
|
25
|
+
if (config) {
|
|
26
|
+
try {
|
|
27
|
+
c = await loadCollection(config, handle, limit);
|
|
28
|
+
} catch {
|
|
29
|
+
c = null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const href = (h: string) => productHref?.replaceAll("{handle}", encodeURIComponent(h));
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
{c && c.products.length > 0 ? (
|
|
36
|
+
<section class="redeemid-collection" data-redeemid-collection={c.handle}>
|
|
37
|
+
{showTitle && (
|
|
38
|
+
<>
|
|
39
|
+
<h2 class="redeemid-collection__title">{c.title}</h2>
|
|
40
|
+
{c.description && <p class="redeemid-collection__description">{c.description}</p>}
|
|
41
|
+
</>
|
|
42
|
+
)}
|
|
43
|
+
<ul class="redeemid-collection__grid">
|
|
44
|
+
{c.products.map((p) => (
|
|
45
|
+
<li class="redeemid-collection__item" data-redeemid-product={p.handle}>
|
|
46
|
+
{p.image && <img src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
|
|
47
|
+
<h3>{href(p.handle) ? <a href={href(p.handle)}>{p.title}</a> : p.title}</h3>
|
|
48
|
+
<p class="redeemid-collection__price">{p.availableForSale ? (p.price ? `From ${money(p.price)}` : "") : "Sold out"}</p>
|
|
49
|
+
</li>
|
|
50
|
+
))}
|
|
51
|
+
</ul>
|
|
52
|
+
</section>
|
|
53
|
+
) : (
|
|
54
|
+
<slot />
|
|
55
|
+
)}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Pay what you want: the reader picks an amount and checks out. Set up a
|
|
4
|
+
* Shopify product priced at 1.00 (one unit of the currency); the amount
|
|
5
|
+
* becomes the line's quantity, so Shopify charges exactly what they chose.
|
|
6
|
+
* With another price the field asks for a quantity instead.
|
|
7
|
+
*
|
|
8
|
+
* <Contribution handle="support-the-newsroom" amounts={[5, 10, 25]} />
|
|
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
|
+
/** Suggested amounts, as buttons. */
|
|
16
|
+
amounts?: number[];
|
|
17
|
+
/** The amount filled in to start with. */
|
|
18
|
+
initial?: number;
|
|
19
|
+
label?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { handle, amounts = [5, 10, 25], initial = 10, label = "Give", title } = 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 variant = p?.variants.find((v) => v.availableForSale);
|
|
34
|
+
const unit = variant ? Number(variant.price.amount) === 1 : false;
|
|
35
|
+
const currency = variant?.price.currencyCode ?? "USD";
|
|
36
|
+
const symbol = new Intl.NumberFormat("en-US", { style: "currency", currency }).formatToParts(0).find((x) => x.type === "currency")?.value ?? "";
|
|
37
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
38
|
+
const id = `redeemid-give-${handle}`;
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
{p && variant ? (
|
|
42
|
+
<form class="redeemid-contribution" method="post" action="/_emdash/api/auth/redeemid/cart-edit" data-redeemid-product={p.handle}>
|
|
43
|
+
<h2 class="redeemid-contribution__title">{title ?? p.title}</h2>
|
|
44
|
+
{p.description && <p>{p.description}</p>}
|
|
45
|
+
<input type="hidden" name="action" value="add" />
|
|
46
|
+
<input type="hidden" name="product" value={p.handle} />
|
|
47
|
+
<input type="hidden" name="variant" value={variant.id} />
|
|
48
|
+
<input type="hidden" name="return" value={here} />
|
|
49
|
+
<input type="hidden" name="then" value="checkout" />
|
|
50
|
+
{unit ? (
|
|
51
|
+
<>
|
|
52
|
+
{amounts.length > 0 && (
|
|
53
|
+
<p class="redeemid-contribution__amounts">
|
|
54
|
+
{amounts.map((a) => (
|
|
55
|
+
<button type="submit" name="quantity" value={String(a)}>{symbol}{a}</button>
|
|
56
|
+
))}
|
|
57
|
+
</p>
|
|
58
|
+
)}
|
|
59
|
+
<p>
|
|
60
|
+
<label for={`${id}-amount`}>Another amount ({currency})</label>
|
|
61
|
+
<input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" step="1" value={initial} inputmode="numeric" />
|
|
62
|
+
</p>
|
|
63
|
+
</>
|
|
64
|
+
) : (
|
|
65
|
+
<p>
|
|
66
|
+
<label for={`${id}-amount`}>Quantity ({money(variant.price)} each)</label>
|
|
67
|
+
<input id={`${id}-amount`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
|
|
68
|
+
</p>
|
|
69
|
+
)}
|
|
70
|
+
<button type="submit">{label}</button>
|
|
71
|
+
</form>
|
|
72
|
+
) : (
|
|
73
|
+
<slot />
|
|
74
|
+
)}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* The member's own account card, for an account page: their levels with
|
|
4
|
+
* when each started, levels they held before, the meter, and links to the
|
|
5
|
+
* store account, Refresh membership and Sign out. Visitors get a sign-in link.
|
|
6
|
+
*
|
|
7
|
+
* <MemberCard />
|
|
8
|
+
*
|
|
9
|
+
* Everything shown comes from the mirrored Shopify customer; Refresh
|
|
10
|
+
* membership reads it from the store again.
|
|
11
|
+
*/
|
|
12
|
+
import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
|
|
13
|
+
import { siteConfig } from "../site-config.js";
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Heading text; the member's first name is added after it. */
|
|
17
|
+
title?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { title = "Your membership" } = Astro.props;
|
|
21
|
+
const v = await viewer(Astro);
|
|
22
|
+
const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
|
|
23
|
+
const here = Astro.url.pathname + Astro.url.search;
|
|
24
|
+
const m = v.member;
|
|
25
|
+
const disabled = m?.status === "disabled";
|
|
26
|
+
const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
|
|
27
|
+
const meter = v.signedIn ? await meterUsage(Astro) : null;
|
|
28
|
+
const date = (iso: string | null) => {
|
|
29
|
+
if (!iso) return null;
|
|
30
|
+
const t = Date.parse(iso);
|
|
31
|
+
return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
|
|
32
|
+
};
|
|
33
|
+
const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
|
|
34
|
+
const join = config?.join;
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
<section class="redeemid-member" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
|
|
38
|
+
{v.signedIn ? (
|
|
39
|
+
<>
|
|
40
|
+
<h2 class="redeemid-member__title">{title}</h2>
|
|
41
|
+
{(name || m?.email) && (
|
|
42
|
+
<p class="redeemid-member__who">
|
|
43
|
+
{name && <strong>{name}</strong>}
|
|
44
|
+
{m?.email && <span>{m.email}</span>}
|
|
45
|
+
</p>
|
|
46
|
+
)}
|
|
47
|
+
{disabled ? (
|
|
48
|
+
<p class="redeemid-member__disabled">Your account is turned off. Contact the store for help.</p>
|
|
49
|
+
) : current.length > 0 ? (
|
|
50
|
+
<ul class="redeemid-member__levels">
|
|
51
|
+
{current.map((l) => (
|
|
52
|
+
<li data-redeemid-level={l.slug}>
|
|
53
|
+
<strong>{l.name}</strong>
|
|
54
|
+
{date(l.since) && <> since <time datetime={l.since ?? undefined}>{date(l.since)}</time></>}
|
|
55
|
+
</li>
|
|
56
|
+
))}
|
|
57
|
+
</ul>
|
|
58
|
+
) : (
|
|
59
|
+
<p class="redeemid-member__none">
|
|
60
|
+
You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
|
|
61
|
+
</p>
|
|
62
|
+
)}
|
|
63
|
+
{past.length > 0 && (
|
|
64
|
+
<>
|
|
65
|
+
<h3 class="redeemid-member__subtitle">Before</h3>
|
|
66
|
+
<ul class="redeemid-member__past">
|
|
67
|
+
{past.map((l) => (
|
|
68
|
+
<li data-redeemid-level={l.slug}>
|
|
69
|
+
{l.name}
|
|
70
|
+
{date(l.from) ? `, ${date(l.from)} to ${date(l.to)}` : `, until ${date(l.to)}`}
|
|
71
|
+
</li>
|
|
72
|
+
))}
|
|
73
|
+
</ul>
|
|
74
|
+
</>
|
|
75
|
+
)}
|
|
76
|
+
{meter && current.length === 0 && !disabled && (
|
|
77
|
+
<p class="redeemid-member__meter" data-redeemid-used={meter.used}>
|
|
78
|
+
Free reads {meter.period === "week" ? "this week" : "this month"}: {meter.used} of {meter.limit} used.
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
<p class="redeemid-member__actions">
|
|
82
|
+
<a href={accountUrl(here)}>Orders and details</a>
|
|
83
|
+
<a href={refreshUrl(here)}>Refresh membership</a>
|
|
84
|
+
</p>
|
|
85
|
+
<form method="post" action={signOutUrl("/")}>
|
|
86
|
+
<button type="submit">Sign out</button>
|
|
87
|
+
</form>
|
|
88
|
+
<p class="redeemid-member__note">Just bought a plan? It can take a minute to show here. Refresh membership checks again.</p>
|
|
89
|
+
</>
|
|
90
|
+
) : (
|
|
91
|
+
<p>
|
|
92
|
+
<a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a> to see your membership.
|
|
93
|
+
</p>
|
|
94
|
+
)}
|
|
95
|
+
</section>
|
|
@@ -21,6 +21,11 @@ const DEFAULTS: Record<string, string> = {
|
|
|
21
21
|
plan_unavailable: "That plan isn't available right now.",
|
|
22
22
|
checkout_failed: "Checkout didn't open. Try again.",
|
|
23
23
|
cart_empty: "Your cart is empty.",
|
|
24
|
+
cart_added: "Added to your cart.",
|
|
25
|
+
cart_updated: "Your cart is updated.",
|
|
26
|
+
discount_applied: "Discount applied.",
|
|
27
|
+
discount_not_applicable: "That discount code doesn't apply to this cart.",
|
|
28
|
+
refreshed: "Your membership is up to date.",
|
|
24
29
|
account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
|
|
25
30
|
};
|
|
26
31
|
|