@redeemid/emdash 0.0.17 → 0.0.19
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 +50 -18
- package/bin/redeemid-emdash.mjs +13 -1
- package/docs/developer-api.md +126 -0
- package/package.json +3 -1
- package/src/agents.ts +55 -0
- 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/BrowseControls.astro +87 -0
- package/src/components/Cart.astro +11 -5
- package/src/components/CartDrawer.astro +59 -0
- package/src/components/CartView.astro +1 -0
- package/src/components/Collection.astro +22 -17
- package/src/components/Content.astro +3 -2
- package/src/components/LockedPanel.astro +4 -2
- package/src/components/Notices.astro +2 -27
- package/src/components/Product.astro +115 -24
- 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/config.ts +14 -0
- package/src/contract.ts +50 -0
- package/src/files.ts +39 -1
- package/src/guard.ts +10 -3
- package/src/index.ts +3 -0
- package/src/log.ts +10 -3
- package/src/notices.ts +29 -0
- package/src/plugin.ts +32 -2
- 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 +4 -2
- package/src/routes/cart-edit.ts +15 -66
- package/src/routes/cart.ts +4 -2
- package/src/routes/shared.ts +11 -0
- package/src/routes/webhook.ts +20 -2
- package/src/shopify.ts +37 -1
- package/src/storefront.ts +231 -59
- package/src/text.ts +43 -2
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) site
|
|
|
7
7
|
|
|
8
8
|
Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
|
|
9
9
|
|
|
10
|
-
**Status: 0.0.
|
|
10
|
+
**Status: 0.0.19, early.** Runs on Node and Cloudflare Workers. Every feature below is covered by unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store. Expect changes before 1.0.
|
|
11
11
|
|
|
12
12
|
## Contents
|
|
13
13
|
|
|
@@ -41,7 +41,7 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
|
|
|
41
41
|
- Download or delete what the site keeps about them.
|
|
42
42
|
|
|
43
43
|
**For editors**
|
|
44
|
-
- Paywall break, Members only, Sold on its own, Plan, Product, Collection, Contribution and Preferences blocks in the EmDash editor.
|
|
44
|
+
- Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks in the EmDash editor.
|
|
45
45
|
- An Access panel on every entry: follow the site rules, free to everyone, only some levels, or the site rules plus a level, with drip, meter and date windows.
|
|
46
46
|
- Rules by content type, term (with child terms) or site default, plus terms that open entries to everyone.
|
|
47
47
|
- Teasers, a monthly or weekly meter, drip by days of membership, and badges that show what an entry needs.
|
|
@@ -58,8 +58,9 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
|
|
|
58
58
|
|
|
59
59
|
**For developers**
|
|
60
60
|
- Astro components for every piece, plus `canRead()`, `readEntry()` and `hasFeature()` for your own code.
|
|
61
|
-
-
|
|
62
|
-
-
|
|
61
|
+
- A storefront from the Storefront API: product pages with options, subscriptions and galleries, collection grids with Shopify's filters, sorting and pages, product search, recommendations, a cart page and a cart drawer, pay-what-you-want, local prices, and a checkout that opens signed in for members.
|
|
62
|
+
- A browser client, `window.redeemid`, with `subscribe()` events for any analytics or CRM tool, cart methods and search, plus JSON routes for state, the cart and the catalog. See [docs/developer-api.md](docs/developer-api.md).
|
|
63
|
+
- Signed outbound events for a CRM or email tool, MCP tools for AI agents (members, access and the catalog), and a `redeemid-emdash` command.
|
|
63
64
|
- Replacement text for every string readers see, Google's paywalled-content markup, cache safety and optional rate limits.
|
|
64
65
|
|
|
65
66
|
## Requirements
|
|
@@ -297,26 +298,42 @@ import { hasFeature } from "@redeemid/emdash/features";
|
|
|
297
298
|
|
|
298
299
|
### Shop
|
|
299
300
|
|
|
300
|
-
Products, collections and the cart come from the Storefront API. Shopify works out prices, discounts, taxes and stock.
|
|
301
|
+
Products, collections, search and the cart come from the Storefront API. Shopify works out prices, discounts, taxes and stock, and runs checkout. EmDash has no Shopify plugin underneath, so these components are the storefront.
|
|
301
302
|
|
|
302
303
|
```astro
|
|
303
|
-
<!--
|
|
304
|
-
<
|
|
304
|
+
<!-- layout, once -->
|
|
305
|
+
<Cart drawer />
|
|
306
|
+
<CartDrawer />
|
|
307
|
+
<RedeemIDClient />
|
|
308
|
+
<!-- /shop/ (rendered on demand for filters and pages) -->
|
|
309
|
+
<Collection handle="shop" productHref="/shop/{handle}/" showTitle browse quickAdd />
|
|
305
310
|
<!-- /shop/[handle]/ -->
|
|
306
311
|
<Product handle={Astro.params.handle} heading="h1"><p>No such product.</p></Product>
|
|
312
|
+
<Recommendations handle={Astro.params.handle} productHref="/shop/{handle}/" />
|
|
313
|
+
<!-- /search/ -->
|
|
314
|
+
<ProductSearch productHref="/shop/{handle}/" />
|
|
307
315
|
<!-- /cart/ -->
|
|
308
316
|
<CartView productHref="/shop/{handle}/" />
|
|
309
317
|
<!-- anywhere -->
|
|
310
318
|
<Contribution handle="support" amounts={[5, 10, 25]} />
|
|
311
319
|
```
|
|
312
320
|
|
|
313
|
-
- `<Product>`:
|
|
314
|
-
- `<Collection>`: up to 50 products as cards linking to `productHref`.
|
|
321
|
+
- `<Product>`: images (with thumbnails), title, price and sale price, description (`html` for Shopify's formatted one), a picker per option (Size, Color…), one-time or subscription when the product has selling plans, quantity, Add to cart and Buy now. On a product's own page (`heading="h1"`) it prints Product structured data.
|
|
322
|
+
- `<Collection>`: up to 50 products as cards linking to `productHref`. `browse` adds Shopify's filters (set up in the Search & Discovery app), sort orders and pages, read from the page URL. `quickAdd` adds Add to cart on cards that need no choice.
|
|
323
|
+
- `<ProductSearch>`: a search box with Shopify's matches, filters, sorting and pages. `boxOnly` gives just the box, for a header, sending searches to `action`.
|
|
324
|
+
- `<Recommendations>`: Shopify's "You may also like" (`intent="related"`) or "Pair it with" (`intent="complementary"`) for a product.
|
|
315
325
|
- `<CartView>`: each line with a quantity to change (0 removes it) and Remove, a discount code field, the totals and Checkout.
|
|
326
|
+
- `<CartDrawer>`: the same cart in a modal dialog that slides in. With `<RedeemIDClient />` on the page, Add to cart opens it in place and `<Cart drawer />` opens it instead of going to checkout.
|
|
316
327
|
- `<Contribution>`: pay what you want. Price a product at 1.00, and the amount the reader picks becomes the quantity.
|
|
317
|
-
- `<Cart>`: the item count, linking to checkout
|
|
328
|
+
- `<Cart>`: the item count, linking to checkout, or to `href` (a cart page), or opening the drawer with `drawer`.
|
|
318
329
|
|
|
319
|
-
|
|
330
|
+
Everything works without JavaScript: the forms post to `/_emdash/api/auth/redeemid/cart-edit`, which reads every product, variant, option and selling plan from Shopify again and only changes the visitor's own cart. `<RedeemIDClient />` adds the drawer, option pickers that follow the choice, and search suggestions. A new cart carries the `_redeemid_page` attribute (the page the first item was added on), which shows on the order in Shopify admin. Editors get the same with the Product, Collection, Product search and Contribution blocks.
|
|
331
|
+
|
|
332
|
+
**Signed-in checkout.** When a signed-in member checks out, RedeemID puts their store sign-in (the Customer Account API access token, kept only in the server-side session and renewed when it's about to expire) on the cart, so Shopify's checkout opens signed in and the order lands on their customer record, where your Flow or apps tag it. If that can't be done, they check out as a guest with their email filled in. Set `checkout: { signIn: false }` to always use a guest checkout.
|
|
333
|
+
|
|
334
|
+
**Local prices.** Set `market: { country: "visitor" }` to show each visitor prices in their own market (from Cloudflare's `CF-IPCountry`), or a fixed `country` and `language`. Markets are set up in Shopify.
|
|
335
|
+
|
|
336
|
+
**Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
|
|
320
337
|
|
|
321
338
|
### Preferences
|
|
322
339
|
|
|
@@ -413,6 +430,9 @@ Staff questions as data, for AI agents and scripts. Every one needs an administr
|
|
|
413
430
|
| `get_member` | `member <id or email>` | One member: status, tags, levels with start dates, past levels, features, last sync |
|
|
414
431
|
| `site_health` | `health` | Each setting (set or not, never a value), the last webhook, open privacy requests, ignored text replacements |
|
|
415
432
|
| `level_changes` | `changes --since <date>` | Who joined or left a level since a date |
|
|
433
|
+
| `find_products` | `products <words>` | Search the Shopify catalog: handles, titles, prices, stock |
|
|
434
|
+
| `get_product` | `product <handle>` | One product: options, variants, prices, selling plans |
|
|
435
|
+
| `get_collection` | `collection <handle>` | One collection: its products and the filters Shopify offers |
|
|
416
436
|
|
|
417
437
|
With `agents: { actions: true }`, three actions are offered too: `resync_member` (`resync <customer id>`), `resync_batch` (`resync-batch`, 25 members) and `edit_member_tags` (`tags <customer id> --add a,b --remove c`, marked destructive for agents).
|
|
418
438
|
|
|
@@ -498,6 +518,8 @@ Pass one object to both `redeemid(config)` and `redeemidPlugin(config)`. `levels
|
|
|
498
518
|
| `text` | `{ [original]: replacement }` | Replacement text, one string at a time. |
|
|
499
519
|
| `events` | `{ url, only? }` | Signed outbound events. |
|
|
500
520
|
| `agents` | `{ actions?: boolean }` | Also offer resync and tag edits to agents and the command line. |
|
|
521
|
+
| `market` | `{ country?, language? }` | Local prices: a fixed two-letter `country`, or `"visitor"` for each visitor's own. |
|
|
522
|
+
| `checkout` | `{ signIn?: false }` | `signIn: false` stops passing a member's store sign-in to checkout. |
|
|
501
523
|
| `schema` | `false` | Turn off the paywalled-content JSON-LD. |
|
|
502
524
|
| `memberPaths` | `string[]` | Extra `/_emdash` path prefixes signed-in members may use. Everything else there is refused to them. |
|
|
503
525
|
| `entries` | `{ ["collection:id"]: EntryRule }` | Entries' own rules. Usually set in the editor's Access panel. |
|
|
@@ -537,10 +559,15 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
537
559
|
| `Feature` | `slug`; slot `else` | Shows its content to members holding the feature |
|
|
538
560
|
| `Notices` | `messages` | The message for `?redeemid_notice=` |
|
|
539
561
|
| `Plan` | `handle`, `label`, `title`, `levels`, `heldLabel` | A product with a buy button |
|
|
540
|
-
| `Product` | `handle`, `heading`, `compact`, `addLabel`, `buyLabel` | A product page |
|
|
541
|
-
| `Collection` | `handle`, `limit`, `productHref`, `showTitle` | A grid of products |
|
|
542
|
-
| `
|
|
562
|
+
| `Product` | `handle`, `heading`, `compact`, `html`, `schema`, `addLabel`, `buyLabel` | A product page |
|
|
563
|
+
| `Collection` | `handle`, `limit`, `productHref`, `showTitle`, `browse`, `quickAdd` | A grid of products, with filters and pages |
|
|
564
|
+
| `ProductSearch` | `productHref`, `limit`, `boxOnly`, `action`, `label`, `quickAdd` | Product search |
|
|
565
|
+
| `Recommendations` | `handle`, `intent`, `limit`, `productHref`, `title`, `quickAdd` | Shopify's recommendations |
|
|
566
|
+
| `ProductGrid` | `products`, `productHref`, `heading`, `quickAdd` | Product cards, for your own lists |
|
|
567
|
+
| `Cart` | `label`, `href`, `drawer` | The cart count, linking to checkout or opening the drawer |
|
|
543
568
|
| `CartView` | `productHref`, `empty` | A cart page |
|
|
569
|
+
| `CartDrawer` | `title` | The cart in a slide-in dialog |
|
|
570
|
+
| `RedeemIDClient` | | The browser client, `window.redeemid` |
|
|
544
571
|
| `Contribution` | `handle`, `amounts`, `initial`, `label`, `title` | Pay what you want |
|
|
545
572
|
|
|
546
573
|
### Editor blocks
|
|
@@ -554,7 +581,8 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
554
581
|
| Sold on its own | `redeemid-item` | Sells the entry by itself to holders of a tag |
|
|
555
582
|
| Plan | `redeemid-plan` | A product with a buy button |
|
|
556
583
|
| Product | `redeemid-product` | A product with variants and Add to cart |
|
|
557
|
-
| Collection | `redeemid-collection` | A grid of products |
|
|
584
|
+
| Collection | `redeemid-collection` | A grid of products, optionally with filters, pages and Add to cart |
|
|
585
|
+
| Product search | `redeemid-search` | A search box with Shopify's results |
|
|
558
586
|
| Contribution | `redeemid-contribution` | Pay what you want |
|
|
559
587
|
| Preferences | `redeemid-preferences` | The member's preference form |
|
|
560
588
|
|
|
@@ -563,16 +591,20 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
|
|
|
563
591
|
| Import | Gives |
|
|
564
592
|
| --- | --- |
|
|
565
593
|
| `@redeemid/emdash` | `redeemid()`, `redeemidPlugin()`, `redeemidGuard()`, config types |
|
|
566
|
-
| `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
594
|
+
| `@redeemid/emdash/access` | `canRead()`, `readEntry()`, `entryRequirement()`, `viewer()`, and links: `signInUrl()`, `signOutUrl()`, `accountUrl()`, `cartUrl()`, `refreshUrl()` |
|
|
567
595
|
| `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
|
|
568
596
|
| `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
|
|
569
|
-
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls
|
|
597
|
+
| `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
|
|
598
|
+
| `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
|
|
570
599
|
| `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
|
|
571
600
|
| `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
|
|
601
|
+
| `@redeemid/emdash/contract` | The stable API as a list, and `API_VERSION` |
|
|
602
|
+
|
|
603
|
+
The full developer API (stable exports, JSON routes, the browser client and its events, MCP tools) is in [docs/developer-api.md](docs/developer-api.md).
|
|
572
604
|
|
|
573
605
|
### Routes
|
|
574
606
|
|
|
575
|
-
Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
|
|
607
|
+
Under `/_emdash/api/auth/redeemid/`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`, `preferences` and `privacy`, and the JSON routes `api/state`, `api/cart` and `api/search`. Staff-only routes for the admin, agents and the command line are under `/_emdash/api/plugins/redeemid/`.
|
|
576
608
|
|
|
577
609
|
In the Customer Account API client, set the callback URI to `https://<site>/_emdash/api/auth/redeemid/callback`, the logout URI to `https://<site>/`, and (public clients) the JavaScript origin to `https://<site>`. Point webhooks at `https://<site>/_emdash/api/auth/redeemid/webhook`: `customers/create`, `customers/update`, `customers/delete`, `customers/disable`, `customers/enable`, `customers/merge`, `customer.tags_added`, `customer.tags_removed` and `customers/redact`.
|
|
578
610
|
|
package/bin/redeemid-emdash.mjs
CHANGED
|
@@ -27,6 +27,9 @@ Commands:
|
|
|
27
27
|
member <id or email> One synced member
|
|
28
28
|
health What's set up (never the values)
|
|
29
29
|
changes --since <date> Who joined or left a level [--level <slug>]
|
|
30
|
+
products <words> Search the Shopify catalog for handles and prices
|
|
31
|
+
product <handle> One product: options, variants, selling plans
|
|
32
|
+
collection <handle> One collection: its products and filters
|
|
30
33
|
resync <customer id> Read one member from Shopify again *
|
|
31
34
|
resync-batch Read the 25 members synced longest ago *
|
|
32
35
|
tags <customer id> Edit tags in Shopify: --add a,b --remove c *
|
|
@@ -82,6 +85,15 @@ function request() {
|
|
|
82
85
|
case "changes":
|
|
83
86
|
if (!o.since) throw new Error("changes needs --since <date>");
|
|
84
87
|
return ["level-changes", { since: o.since, level: o.level }];
|
|
88
|
+
case "products":
|
|
89
|
+
if (!arg) throw new Error("products needs search words");
|
|
90
|
+
return ["find-products", { query: positionals.slice(1).join(" ") }];
|
|
91
|
+
case "product":
|
|
92
|
+
if (!arg) throw new Error("product needs a handle");
|
|
93
|
+
return ["get-product", { handle: arg }];
|
|
94
|
+
case "collection":
|
|
95
|
+
if (!arg) throw new Error("collection needs a handle");
|
|
96
|
+
return ["get-collection", { handle: arg }];
|
|
85
97
|
case "resync":
|
|
86
98
|
if (!arg) throw new Error("resync needs a customer id");
|
|
87
99
|
return ["resync-member", { customerId: arg }];
|
|
@@ -131,7 +143,7 @@ async function main() {
|
|
|
131
143
|
} catch {
|
|
132
144
|
throw new Error(`${res.status} from the site: ${text.slice(0, 200)}`);
|
|
133
145
|
}
|
|
134
|
-
if (res.status === 404) throw new Error(`The site has no "${route}" route. Actions need agents: { actions: true }, and every command needs @redeemid/emdash 0.0.11 or later.`);
|
|
146
|
+
if (res.status === 404) throw new Error(`The site has no "${route}" route. Actions need agents: { actions: true }, and every command needs @redeemid/emdash 0.0.11 or later (products, product and collection need 0.0.18).`);
|
|
135
147
|
if (!res.ok) throw new Error(json?.error?.message ?? `${res.status} from the site`);
|
|
136
148
|
const out = json?.data ?? json;
|
|
137
149
|
if (out && out.ok === false) throw new Error(out.error);
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Developer API
|
|
2
|
+
|
|
3
|
+
What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.0.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
|
|
4
|
+
|
|
5
|
+
Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
|
|
6
|
+
|
|
7
|
+
Three rules hold everywhere:
|
|
8
|
+
|
|
9
|
+
- **Shopify decides.** Levels and features come only from the member's Shopify tags; prices, stock, discounts and checkout come only from Shopify. Nothing here grants access or sets a price.
|
|
10
|
+
- **Nothing personal leaves the server.** Browser-facing answers and events never carry an email, a name, a customer id or a Shopify tag. Match people by `customerId` only in signed server events.
|
|
11
|
+
- **Vendor-agnostic.** RedeemID never sends anything to an analytics tool, CRM or tag manager. You subscribe and forward what you choose.
|
|
12
|
+
|
|
13
|
+
## Server modules
|
|
14
|
+
|
|
15
|
+
Import these in Astro pages, components and endpoints.
|
|
16
|
+
|
|
17
|
+
| Import | Stable exports |
|
|
18
|
+
| --- | --- |
|
|
19
|
+
| `@redeemid/emdash` | `redeemid`, `redeemidGuard`, `redeemidPlugin` |
|
|
20
|
+
| `@redeemid/emdash/access` | `viewer`, `canRead`, `readEntry`, `entryRequirement`, `meterUsage`, `membership`, `levelsFor`, `signInUrl`, `signOutUrl`, `accountUrl`, `cartUrl`, `refreshUrl`, `safeReturn` |
|
|
21
|
+
| `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
|
|
22
|
+
| `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
|
|
23
|
+
| `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
|
|
24
|
+
| `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
|
|
25
|
+
| `@redeemid/emdash/events` | `signBody` |
|
|
26
|
+
| `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
|
|
27
|
+
|
|
28
|
+
The site's config for a request comes from `siteConfig(Astro.locals)` (code config with the admin's saved settings laid over it). A few examples:
|
|
29
|
+
|
|
30
|
+
```astro
|
|
31
|
+
---
|
|
32
|
+
import { canRead, viewer } from "@redeemid/emdash/access";
|
|
33
|
+
import { hasFeature } from "@redeemid/emdash/features";
|
|
34
|
+
import { searchProducts } from "@redeemid/emdash/catalog";
|
|
35
|
+
|
|
36
|
+
const verdict = await canRead(Astro, { collection: "posts", terms: post.data.terms, id: post.id });
|
|
37
|
+
const adFree = await hasFeature(Astro, "ad-free");
|
|
38
|
+
---
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Catalog reads (`product`, `collection`, `searchProducts`, `collectionPage`, `suggest`, `recommendations`) are cached for five minutes per Worker isolate. Pass `storeContext(config, Astro.request)` as `context` for local prices when config `market` is set.
|
|
42
|
+
|
|
43
|
+
## JSON routes
|
|
44
|
+
|
|
45
|
+
Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on every POST, and the browser client also sends `X-EmDash-Request: 1`. Member answers are `Cache-Control: private, no-store`.
|
|
46
|
+
|
|
47
|
+
- `api/state` (GET): the visitor's own state.
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{ "apiVersion": "1.0.0", "version": "0.0.19", "signedIn": true, "linked": true,
|
|
51
|
+
"levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
|
|
52
|
+
"meter": { "used": 1, "limit": 3, "period": "month" } }
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
- `api/cart` (GET, POST): the Shopify cart. GET answers `{ cart }` (null without one). POST takes `{ action, ... }`:
|
|
56
|
+
- `add`: `product` (handle), then `variant` (an id of that product's) or `options` (`{ "Size": "M" }`), optional `sellingPlan` (one of the product's, or `""` for one-time), `quantity` (1 to 1000);
|
|
57
|
+
- `update`: `line`, `quantity` (0 removes); `remove`: `line`; `discount`: `code` (`""` clears);
|
|
58
|
+
- `checkout`: answers `{ checkoutUrl }`, signed in for members.
|
|
59
|
+
|
|
60
|
+
Other actions answer `{ notice, cart, added? }`. An add Shopify can't fill answers 422 with `notice: "plan_unavailable"`. Over the rate limit (with `redeemidGuard({ rateLimit: true })`) POST answers 429.
|
|
61
|
+
- `api/search` (GET): public catalog data, `Cache-Control: public, max-age=60` (private when `market.country` is `"visitor"`).
|
|
62
|
+
- `?q=tote&suggest=1[&limit=6]`: `{ queries, products, collections }`, for search-as-you-type;
|
|
63
|
+
- `?q=tote`: `{ products, filters, pageInfo, total }`;
|
|
64
|
+
- `?collection=shop`: the collection's page, `{ handle, title, description, products, filters, pageInfo }`.
|
|
65
|
+
|
|
66
|
+
Both take `first` (1 to 50), `sort`, `after` or `before` (cursors from `pageInfo`), `filter` (repeatable: a filter value's `input` exactly as Shopify gave it), `price_min` and `price_max`. Sorts are `relevance`, `best-selling`, `newest`, `price-asc`, `price-desc`, `title-asc`, `title-desc` (search: relevance and price only).
|
|
67
|
+
- `preferences` (GET, POST) and `privacy` (GET export, POST erase): see the README.
|
|
68
|
+
|
|
69
|
+
Form routes, which redirect back with `?redeemid_notice=<code>`: `login`, `callback`, `logout`, `webhook`, `buy`, `cart`, `cart-edit`, `account`.
|
|
70
|
+
|
|
71
|
+
## Browser client
|
|
72
|
+
|
|
73
|
+
Add `<RedeemIDClient />` once to the site layout (from `@redeemid/emdash/components/RedeemIDClient.astro`). It defines `window.redeemid` and sends nothing anywhere by itself.
|
|
74
|
+
|
|
75
|
+
```js
|
|
76
|
+
const stop = window.redeemid.subscribe((name, props, event) => {
|
|
77
|
+
// event: { name, version, time, props }, props are scalars only
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A subscriber also gets the events that fired before it subscribed (`{ replay: false }` skips them), so it can wait for consent. Each event is also dispatched on `document` as a `CustomEvent` named `redeemid:<name>`.
|
|
82
|
+
|
|
83
|
+
| Member | What it does |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `version`, `apiVersion` | Package and API versions |
|
|
86
|
+
| `events` | The event names below |
|
|
87
|
+
| `subscribe(fn, { replay })` | Listen; returns an unsubscribe function |
|
|
88
|
+
| `emit(name, props)` | Send your own event; names must start `site_` |
|
|
89
|
+
| `state()`, `refresh()` | The visitor's `api/state`, read once (or again) |
|
|
90
|
+
| `signIn(returnTo?)`, `signOut(returnTo?)` | Go to sign-in; sign out (a POST) |
|
|
91
|
+
| `cart` | `get()`, `add({ product, variant?, options?, sellingPlan?, quantity? })`, `update(line, quantity)`, `remove(line)`, `discount(code)`, `checkout()` |
|
|
92
|
+
| `openCart()`, `closeCart()` | The cart drawer, when `<CartDrawer />` is on the page |
|
|
93
|
+
| `search(q, { first, after, sort })`, `suggest(q, limit)`, `collection(handle, { first, after, sort })` | The `api/search` answers |
|
|
94
|
+
|
|
95
|
+
Events:
|
|
96
|
+
|
|
97
|
+
| Event | Props |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `ready` | `api_version` |
|
|
100
|
+
| `state` | `signedIn`, `linked`, `levels` and `features` (comma-separated slugs), `cartCount` |
|
|
101
|
+
| `wall_shown` | `entry`, `level`, `reason` |
|
|
102
|
+
| `metered_view` | `left` |
|
|
103
|
+
| `sign_in_click` | `source` (`page`, `wall`, `drawer`) |
|
|
104
|
+
| `checkout_click` | `source`, `product` |
|
|
105
|
+
| `cart_changed` | `action`, `notice`, `count`, `product`, `quantity`, `subscription` |
|
|
106
|
+
| `cart_opened` | `source` (`link`, `add`, `api`) |
|
|
107
|
+
| `search` | `query_length` (never the words) |
|
|
108
|
+
|
|
109
|
+
The client also enhances RedeemID's markup: with `<CartDrawer />` on the page, Add to cart updates the cart in place and opens the drawer, and `<Cart drawer />` opens it. Product option pickers keep the price, sale price, image and stock in step, and `<ProductSearch>` suggests as you type. Without JavaScript every form still works.
|
|
110
|
+
|
|
111
|
+
## Server events
|
|
112
|
+
|
|
113
|
+
Signed outbound webhooks to config `events.url` (see the README): `member.signed_in`, `member.levels_changed`, `meter.used`, `meter.exhausted`, `preferences.saved`, `member.erased`. Verify `X-RedeemID-Signature` with `signBody(rawBody, secret)`.
|
|
114
|
+
|
|
115
|
+
## MCP and the command line
|
|
116
|
+
|
|
117
|
+
RedeemID's tools join EmDash's MCP server once staff turn them on for RedeemID under Plugins. They need a staff token (permission `users:read`). Always offered, read-only:
|
|
118
|
+
|
|
119
|
+
- `explain_access`, `list_levels`, `get_rules`, `get_member`, `site_health`, `level_changes`
|
|
120
|
+
- `find_products`, `get_product`, `get_collection`: the Shopify catalog the site sells, so an agent writing content can find the right product and collection handles.
|
|
121
|
+
|
|
122
|
+
Only with config `agents: { actions: true }`: `resync_member`, `resync_batch`, `edit_member_tags`.
|
|
123
|
+
|
|
124
|
+
The `redeemid-emdash` command line calls the same routes (`npx redeemid-emdash --help`).
|
|
125
|
+
|
|
126
|
+
For shoppers' own agents (browsing products, building a cart, checking out), point them at Shopify's Storefront MCP server for the store, `https://<store domain>/api/mcp`. It runs on Shopify, where checkout and prices already live, so RedeemID doesn't repeat it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@redeemid/emdash",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.19",
|
|
4
4
|
"description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,10 +16,12 @@
|
|
|
16
16
|
"./guard.ts": "./src/guard.ts",
|
|
17
17
|
"./rate-limit-cloudflare.ts": "./src/rate-limit-cloudflare.ts",
|
|
18
18
|
"./storefront": "./src/storefront.ts",
|
|
19
|
+
"./catalog": "./src/catalog.ts",
|
|
19
20
|
"./features": "./src/features.ts",
|
|
20
21
|
"./events": "./src/events.ts",
|
|
21
22
|
"./preferences": "./src/preferences.ts",
|
|
22
23
|
"./text": "./src/text.ts",
|
|
24
|
+
"./contract": "./src/contract.ts",
|
|
23
25
|
"./plugin": "./src/plugin.ts",
|
|
24
26
|
"./blocks": "./src/blocks/index.ts",
|
|
25
27
|
"./components/*": "./src/components/*",
|
package/src/agents.ts
CHANGED
|
@@ -182,3 +182,58 @@ export async function levelChanges(config: RedeemIDConfig, s: Stores, input: { s
|
|
|
182
182
|
} while (cursor);
|
|
183
183
|
return out.sort((a, b) => String(a.at).localeCompare(String(b.at)));
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------- catalog
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Catalog lookups for agents and editors: product and collection handles,
|
|
190
|
+
* prices and options, read from the Storefront API (public data). Use the
|
|
191
|
+
* handles in Product, Collection and Plan blocks.
|
|
192
|
+
*/
|
|
193
|
+
export async function findProducts(config: RedeemIDConfig, input: { query: string; first?: number }): Promise<Record<string, unknown>> {
|
|
194
|
+
const { searchProducts } = await import("./catalog.js");
|
|
195
|
+
const q = String(input.query ?? "").trim();
|
|
196
|
+
if (!q) throw new AgentError("query is required");
|
|
197
|
+
const page = await searchProducts(config, q, { first: Math.max(1, Math.min(25, input.first ?? 10)) });
|
|
198
|
+
return {
|
|
199
|
+
total: page.total ?? page.products.length,
|
|
200
|
+
products: page.products.map((p) => ({ handle: p.handle, title: p.title, availableForSale: p.availableForSale, price: p.price, compareAtPrice: p.compareAtPrice ?? null })),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function getProduct(config: RedeemIDConfig, input: { handle: string }): Promise<Record<string, unknown>> {
|
|
205
|
+
const { product, realOptions } = await import("./storefront.js");
|
|
206
|
+
const handle = String(input.handle ?? "").trim();
|
|
207
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) throw new AgentError("handle must be a Shopify product handle");
|
|
208
|
+
const p = await product(config, handle);
|
|
209
|
+
if (!p) throw new AgentError(`Shopify has no product "${handle}" on this storefront`);
|
|
210
|
+
return {
|
|
211
|
+
handle: p.handle,
|
|
212
|
+
title: p.title,
|
|
213
|
+
vendor: p.vendor ?? null,
|
|
214
|
+
productType: p.productType ?? null,
|
|
215
|
+
description: p.description,
|
|
216
|
+
availableForSale: p.availableForSale,
|
|
217
|
+
options: realOptions(p),
|
|
218
|
+
variants: p.variants.map((v) => ({ id: v.id, title: v.title, availableForSale: v.availableForSale, price: v.price, compareAtPrice: v.compareAtPrice ?? null, sku: v.sku ?? null, options: v.selectedOptions ?? [] })),
|
|
219
|
+
requiresSellingPlan: Boolean(p.requiresSellingPlan),
|
|
220
|
+
sellingPlans: p.sellingPlans ?? [],
|
|
221
|
+
images: (p.images ?? []).length,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function getCollection(config: RedeemIDConfig, input: { handle: string; first?: number }): Promise<Record<string, unknown>> {
|
|
226
|
+
const { collectionPage } = await import("./catalog.js");
|
|
227
|
+
const handle = String(input.handle ?? "").trim();
|
|
228
|
+
if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle)) throw new AgentError("handle must be a Shopify collection handle");
|
|
229
|
+
const c = await collectionPage(config, handle, { first: Math.max(1, Math.min(50, input.first ?? 25)) });
|
|
230
|
+
if (!c) throw new AgentError(`Shopify has no collection "${handle}" on this storefront`);
|
|
231
|
+
return {
|
|
232
|
+
handle: c.handle,
|
|
233
|
+
title: c.title,
|
|
234
|
+
description: c.description,
|
|
235
|
+
filters: c.filters.map((f) => ({ label: f.label, type: f.type, values: f.values.map((v) => v.label) })),
|
|
236
|
+
products: c.products.map((p) => ({ handle: p.handle, title: p.title, availableForSale: p.availableForSale, price: p.price })),
|
|
237
|
+
more: c.pageInfo.hasNextPage,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
import Collection from "../components/Collection.astro";
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
-
node: { handle?: string; limit?: string; link?: string };
|
|
6
|
+
node: { handle?: string; limit?: string; link?: string; extras?: string[] };
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
const { node } = Astro.props;
|
|
10
10
|
const handle = (node.handle ?? "").trim();
|
|
11
11
|
const limit = Number.parseInt(node.limit ?? "", 10);
|
|
12
12
|
const link = (node.link ?? "").trim();
|
|
13
|
+
const extras = Array.isArray(node.extras) ? node.extras : [];
|
|
13
14
|
---
|
|
14
15
|
|
|
15
16
|
{handle && (
|
|
@@ -18,5 +19,7 @@ const link = (node.link ?? "").trim();
|
|
|
18
19
|
limit={Number.isFinite(limit) ? limit : 12}
|
|
19
20
|
productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined}
|
|
20
21
|
showTitle
|
|
22
|
+
browse={extras.includes("browse")}
|
|
23
|
+
quickAdd={extras.includes("quick-add")}
|
|
21
24
|
/>
|
|
22
25
|
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
/** The "Product search" block: a search box with Shopify's results, filters, sorting and paging. */
|
|
3
|
+
import ProductSearch from "../components/ProductSearch.astro";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
node: { link?: string; label?: string };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { node } = Astro.props;
|
|
10
|
+
const link = (node.link ?? "").trim();
|
|
11
|
+
const label = (node.label ?? "").trim();
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<ProductSearch productHref={link.startsWith("/") && link.includes("{handle}") ? link : undefined} label={label || undefined} />
|
package/src/blocks/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import ContributionBlock from "./ContributionBlock.astro";
|
|
|
8
8
|
import PlanBlock from "./PlanBlock.astro";
|
|
9
9
|
import PreferencesBlock from "./PreferencesBlock.astro";
|
|
10
10
|
import ProductBlock from "./ProductBlock.astro";
|
|
11
|
+
import SearchBlock from "./SearchBlock.astro";
|
|
11
12
|
|
|
12
13
|
export const blockComponents = {
|
|
13
14
|
"redeemid-paywall": Marker,
|
|
@@ -17,6 +18,7 @@ export const blockComponents = {
|
|
|
17
18
|
"redeemid-plan": PlanBlock,
|
|
18
19
|
"redeemid-product": ProductBlock,
|
|
19
20
|
"redeemid-collection": CollectionBlock,
|
|
21
|
+
"redeemid-search": SearchBlock,
|
|
20
22
|
"redeemid-contribution": ContributionBlock,
|
|
21
23
|
"redeemid-preferences": PreferencesBlock,
|
|
22
24
|
};
|
package/src/blocks-names.ts
CHANGED
|
@@ -3,6 +3,7 @@ export const PAYWALL_BLOCK = "redeemid-paywall";
|
|
|
3
3
|
export const PLAN_BLOCK = "redeemid-plan";
|
|
4
4
|
export const PRODUCT_BLOCK = "redeemid-product";
|
|
5
5
|
export const COLLECTION_BLOCK = "redeemid-collection";
|
|
6
|
+
export const SEARCH_BLOCK = "redeemid-search";
|
|
6
7
|
export const CONTRIBUTION_BLOCK = "redeemid-contribution";
|
|
7
8
|
export const PREFERENCES_BLOCK = "redeemid-preferences";
|
|
8
9
|
/** Starts a members-only part of the body; it runs to the next end block. */
|
package/src/cart-ops.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cart changes, shared by the form route (cart-edit, which redirects back
|
|
3
|
+
* with a notice) and the JSON route (api/cart, for the browser client and
|
|
4
|
+
* the cart drawer).
|
|
5
|
+
*
|
|
6
|
+
* Prices, discounts and availability come from Shopify; nothing posted is
|
|
7
|
+
* trusted beyond the ids it names, and each is checked against what Shopify
|
|
8
|
+
* says the product or cart holds.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readCart, saveCart } from "./cart.js";
|
|
12
|
+
import type { RedeemIDConfig } from "./config.js";
|
|
13
|
+
import { memberForUser, stores } from "./storage.js";
|
|
14
|
+
import { addToCart, cartDetails, product, realOptions, removeLines, setDiscountCodes, storeContext, updateLines, variantFor, type Cart, type CartDetails, type Product, type Variant } from "./storefront.js";
|
|
15
|
+
|
|
16
|
+
export const MAX_QUANTITY = 1000;
|
|
17
|
+
|
|
18
|
+
export type CartNotice =
|
|
19
|
+
| "cart_added"
|
|
20
|
+
| "cart_updated"
|
|
21
|
+
| "discount_applied"
|
|
22
|
+
| "discount_not_applicable"
|
|
23
|
+
| "plan_unavailable"
|
|
24
|
+
| "cart_empty"
|
|
25
|
+
| "checkout_failed";
|
|
26
|
+
|
|
27
|
+
export interface CartInput {
|
|
28
|
+
action: string;
|
|
29
|
+
/** add: product handle. */
|
|
30
|
+
product?: string;
|
|
31
|
+
/** add: a variant id of that product. */
|
|
32
|
+
variant?: string;
|
|
33
|
+
/** add: chosen option values by option name, when no variant is given. */
|
|
34
|
+
options?: Record<string, string>;
|
|
35
|
+
/** add: a selling plan id of that product, or "" for one-time. */
|
|
36
|
+
sellingPlan?: string | null;
|
|
37
|
+
quantity?: number | string;
|
|
38
|
+
/** update / remove: the cart line id. */
|
|
39
|
+
line?: string;
|
|
40
|
+
/** discount: the code ("" clears). */
|
|
41
|
+
code?: string;
|
|
42
|
+
/** add: the page the buyer was on, kept as a hidden cart attribute. */
|
|
43
|
+
page?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface CartContext {
|
|
47
|
+
config: RedeemIDConfig;
|
|
48
|
+
session: { get(key: string): unknown; set(key: string, value: unknown): void; delete?(key: string): void } | undefined;
|
|
49
|
+
request: Request;
|
|
50
|
+
db?: unknown;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CartOutcome {
|
|
54
|
+
notice: CartNotice;
|
|
55
|
+
/** The cart after the change, when it could be read. */
|
|
56
|
+
cart: CartDetails | null;
|
|
57
|
+
/** The cart's id, checkout URL and count after an add (lines aren't read back). */
|
|
58
|
+
base?: Cart;
|
|
59
|
+
/** add: the line's variant, for analytics. */
|
|
60
|
+
added?: { product: string; variant: string; quantity: number; sellingPlan: boolean };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const HANDLE = /^[a-z0-9][a-z0-9-]*$/i;
|
|
64
|
+
|
|
65
|
+
/** Pick the variant an add names: by id, by option values, or the first one for sale. */
|
|
66
|
+
export function pickVariant(p: Product, input: Pick<CartInput, "variant" | "options">): Variant | undefined {
|
|
67
|
+
if (input.variant) return p.variants.find((v) => v.id === input.variant);
|
|
68
|
+
const opts = realOptions(p);
|
|
69
|
+
if (opts.length && input.options && Object.keys(input.options).length) {
|
|
70
|
+
const chosen: Record<string, string> = {};
|
|
71
|
+
for (const o of opts) {
|
|
72
|
+
const v = input.options[o.name];
|
|
73
|
+
if (typeof v === "string" && o.values.includes(v)) chosen[o.name] = v;
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(chosen).length !== opts.length) return undefined;
|
|
76
|
+
return variantFor(p, chosen);
|
|
77
|
+
}
|
|
78
|
+
return p.variants.find((v) => v.availableForSale);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The selling plan an add uses: one the product offers, the first one when it can only be bought on a plan, or none. */
|
|
82
|
+
export function pickSellingPlan(p: Product, wanted: string | null | undefined): string | null | undefined {
|
|
83
|
+
const plans = p.sellingPlans ?? [];
|
|
84
|
+
if (wanted) return plans.some((sp) => sp.id === wanted) ? wanted : undefined;
|
|
85
|
+
if (p.requiresSellingPlan) return plans[0]?.id ?? p.sellingPlanId ?? undefined;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function editCart(input: CartInput, ctx: CartContext): Promise<CartOutcome> {
|
|
90
|
+
const { config, session, request } = ctx;
|
|
91
|
+
const buyerIp = request.headers.get("cf-connecting-ip");
|
|
92
|
+
const context = storeContext(config, request);
|
|
93
|
+
const save = (c: { id: string; totalQuantity: number }) => saveCart(session, { id: c.id, count: c.totalQuantity });
|
|
94
|
+
|
|
95
|
+
if (input.action === "add") {
|
|
96
|
+
const handle = String(input.product ?? "").trim();
|
|
97
|
+
const quantity = Number.parseInt(String(input.quantity ?? "1"), 10);
|
|
98
|
+
if (!HANDLE.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) return { notice: "plan_unavailable", cart: null };
|
|
99
|
+
const p = await product(config, handle, undefined, context);
|
|
100
|
+
const variant = p ? pickVariant(p, input) : undefined;
|
|
101
|
+
const plan = p ? pickSellingPlan(p, input.sellingPlan) : undefined;
|
|
102
|
+
if (!p || !variant?.availableForSale || plan === undefined) return { notice: "plan_unavailable", cart: null };
|
|
103
|
+
const user = (await session?.get("user")) as { id?: string } | undefined;
|
|
104
|
+
const member = user?.id && ctx.db ? await memberForUser(stores(ctx.db), user.id) : null;
|
|
105
|
+
const current = await readCart(session);
|
|
106
|
+
const cart = await addToCart(
|
|
107
|
+
config,
|
|
108
|
+
current?.id ?? null,
|
|
109
|
+
{ variantId: variant.id, sellingPlanId: plan, quantity },
|
|
110
|
+
{ email: member?.email ?? null, attributes: input.page ? { _redeemid_page: input.page } : {}, buyerIp, context },
|
|
111
|
+
);
|
|
112
|
+
save(cart);
|
|
113
|
+
return { notice: "cart_added", cart: null, base: cart, added: { product: p.handle, variant: variant.id, quantity, sellingPlan: Boolean(plan) } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ref = await readCart(session);
|
|
117
|
+
if (!ref) return { notice: "cart_empty", cart: null };
|
|
118
|
+
const cart = await cartDetails(config, ref.id, { buyerIp });
|
|
119
|
+
if (!cart) {
|
|
120
|
+
saveCart(session, null);
|
|
121
|
+
return { notice: "cart_empty", cart: null };
|
|
122
|
+
}
|
|
123
|
+
const line = String(input.line ?? "");
|
|
124
|
+
const owns = cart.lines.some((l) => l.id === line);
|
|
125
|
+
|
|
126
|
+
if (input.action === "update" || input.action === "remove") {
|
|
127
|
+
if (!owns) return { notice: "cart_updated", cart };
|
|
128
|
+
const quantity = input.action === "remove" ? 0 : Number.parseInt(String(input.quantity ?? ""), 10);
|
|
129
|
+
if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return { notice: "cart_updated", cart };
|
|
130
|
+
const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
|
|
131
|
+
save(next);
|
|
132
|
+
return { notice: "cart_updated", cart: next };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (input.action === "discount") {
|
|
136
|
+
const code = String(input.code ?? "").trim().slice(0, 100);
|
|
137
|
+
const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
|
|
138
|
+
save(next);
|
|
139
|
+
if (!code) return { notice: "cart_updated", cart: next };
|
|
140
|
+
const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
|
|
141
|
+
return { notice: applied?.applicable ? "discount_applied" : "discount_not_applicable", cart: next };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { notice: "cart_updated", cart };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read the add fields from a posted form: option.<Name> fields become `options`. */
|
|
148
|
+
export function cartInputFromForm(form: FormData | null): CartInput {
|
|
149
|
+
const field = (k: string) => String(form?.get(k) ?? "").trim();
|
|
150
|
+
const options: Record<string, string> = {};
|
|
151
|
+
form?.forEach((value, key) => {
|
|
152
|
+
if (key.startsWith("option.") && typeof value === "string") options[key.slice(7)] = value;
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
action: field("action"),
|
|
156
|
+
product: field("product"),
|
|
157
|
+
variant: field("variant"),
|
|
158
|
+
options,
|
|
159
|
+
sellingPlan: form?.has("selling_plan") ? field("selling_plan") : null,
|
|
160
|
+
quantity: field("quantity") || "1",
|
|
161
|
+
line: field("line"),
|
|
162
|
+
code: field("code"),
|
|
163
|
+
};
|
|
164
|
+
}
|