@redeemid/emdash 0.0.7 → 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 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.7, 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.
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
 
@@ -19,6 +19,7 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
19
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.
20
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.
21
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.
22
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.
23
24
  - **Keeps members out of EmDash's own APIs**, so the gate is the only way to read locked content.
24
25
 
@@ -168,6 +169,34 @@ import Plan from "@redeemid/emdash/components/Plan.astro";
168
169
  </Plan>
169
170
  ```
170
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
+
171
200
  Or ask directly:
172
201
 
173
202
  ```ts
@@ -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. Refresh membership returns `refreshed` once it has read the member's tags again.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
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",
@@ -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
+ )}
@@ -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" />}
@@ -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
  };
@@ -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
+ )}
@@ -21,6 +21,10 @@ 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.",
24
28
  refreshed: "Your membership is up to date.",
25
29
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
26
30
  };
@@ -0,0 +1,78 @@
1
+ ---
2
+ /**
3
+ * A Shopify product: image, title, price, description, a variant choice
4
+ * when it has several, a quantity, and "Add to cart" and "Buy now".
5
+ * Everything comes from the Storefront API; the default slot shows if the
6
+ * product can't be loaded.
7
+ *
8
+ * <Product handle="harbor-tote" />
9
+ */
10
+ import { siteConfig } from "../site-config.js";
11
+ import { money, product as loadProduct, type Product } from "../storefront.js";
12
+
13
+ interface Props {
14
+ handle: string;
15
+ /** Heading level for the title. */
16
+ heading?: "h1" | "h2" | "h3";
17
+ /** Leave out the description (for cards). */
18
+ compact?: boolean;
19
+ addLabel?: string;
20
+ buyLabel?: string;
21
+ }
22
+
23
+ const { handle, heading = "h2", compact = false, addLabel = "Add to cart", buyLabel = "Buy now" } = Astro.props;
24
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
25
+ let p: Product | null = null;
26
+ if (config) {
27
+ try {
28
+ p = await loadProduct(config, handle);
29
+ } catch {
30
+ p = null;
31
+ }
32
+ }
33
+ const available = p?.variants.filter((v) => v.availableForSale) ?? [];
34
+ const first = available[0] ?? p?.variants[0];
35
+ const here = Astro.url.pathname + Astro.url.search;
36
+ const Heading = heading;
37
+ const id = `redeemid-product-${handle}`;
38
+ ---
39
+
40
+ {p ? (
41
+ <div class="redeemid-product" data-redeemid-product={p.handle}>
42
+ {p.image && <img class="redeemid-product__image" src={p.image.url} alt={p.image.altText ?? ""} loading="lazy" />}
43
+ <Heading class="redeemid-product__title">{p.title}</Heading>
44
+ {first && <p class="redeemid-product__price">{money(first.price)}</p>}
45
+ {!compact && p.description && <p class="redeemid-product__description">{p.description}</p>}
46
+ {available.length > 0 ? (
47
+ <form method="post" action="/_emdash/api/auth/redeemid/cart-edit">
48
+ <input type="hidden" name="action" value="add" />
49
+ <input type="hidden" name="product" value={p.handle} />
50
+ <input type="hidden" name="return" value={here} />
51
+ {p.variants.length > 1 ? (
52
+ <p>
53
+ <label for={`${id}-variant`}>Option</label>
54
+ <select id={`${id}-variant`} name="variant">
55
+ {p.variants.map((v) => (
56
+ <option value={v.id} disabled={!v.availableForSale}>
57
+ {v.title} ({money(v.price)}){!v.availableForSale && " sold out"}
58
+ </option>
59
+ ))}
60
+ </select>
61
+ </p>
62
+ ) : (
63
+ <input type="hidden" name="variant" value={first?.id} />
64
+ )}
65
+ <p>
66
+ <label for={`${id}-quantity`}>Quantity</label>
67
+ <input id={`${id}-quantity`} name="quantity" type="number" min="1" max="1000" value="1" inputmode="numeric" />
68
+ </p>
69
+ <button type="submit" name="then" value="back">{addLabel}</button>
70
+ <button type="submit" name="then" value="checkout">{buyLabel}</button>
71
+ </form>
72
+ ) : (
73
+ <p class="redeemid-product__unavailable">Sold out.</p>
74
+ )}
75
+ </div>
76
+ ) : (
77
+ <slot />
78
+ )}
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
56
56
  { pattern: `${BASE}/webhook`, entrypoint: "@redeemid/emdash/routes/webhook.ts" },
57
57
  { pattern: `${BASE}/buy`, entrypoint: "@redeemid/emdash/routes/buy.ts" },
58
58
  { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
+ { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
59
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
60
61
  ],
61
62
  publicRoutes: [`${BASE}/`],
package/src/plugin.ts CHANGED
@@ -34,9 +34,17 @@ import { stores, type MemberRecord } from "./storage.js";
34
34
  import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
36
 
37
- import { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK } from "./blocks-names.js";
37
+ import {
38
+ COLLECTION_BLOCK,
39
+ CONTRIBUTION_BLOCK,
40
+ MEMBERS_BLOCK,
41
+ MEMBERS_END_BLOCK,
42
+ PAYWALL_BLOCK,
43
+ PLAN_BLOCK,
44
+ PRODUCT_BLOCK,
45
+ } from "./blocks-names.js";
38
46
 
39
- export { MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK };
47
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
40
48
 
41
49
  function blocks(config: RedeemIDConfig) {
42
50
  const levelOptions = [
@@ -97,6 +105,39 @@ function blocks(config: RedeemIDConfig) {
97
105
  },
98
106
  ],
99
107
  },
108
+ {
109
+ type: PRODUCT_BLOCK,
110
+ label: "Product",
111
+ icon: "tag",
112
+ description: "A Shopify product with Add to cart and Buy now",
113
+ placeholder: "Shopify product handle",
114
+ fields: [{ type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "harbor-tote" }],
115
+ },
116
+ {
117
+ type: COLLECTION_BLOCK,
118
+ label: "Collection",
119
+ icon: "grid",
120
+ description: "A Shopify collection's products as cards",
121
+ placeholder: "Shopify collection handle",
122
+ fields: [
123
+ { type: "text_input" as const, action_id: "handle", label: "Shopify collection handle", placeholder: "shop" },
124
+ { type: "text_input" as const, action_id: "limit", label: "How many products (up to 50)", placeholder: "12" },
125
+ { type: "text_input" as const, action_id: "link", label: "Product page link, with {handle}", placeholder: "/shop/{handle}/" },
126
+ ],
127
+ },
128
+ {
129
+ type: CONTRIBUTION_BLOCK,
130
+ label: "Contribution",
131
+ icon: "star",
132
+ description: "Pay what you want, with a Shopify product priced at 1.00",
133
+ placeholder: "Shopify product handle",
134
+ fields: [
135
+ { type: "text_input" as const, action_id: "handle", label: "Shopify product handle (priced at 1.00)", placeholder: "support" },
136
+ { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
137
+ { type: "text_input" as const, action_id: "amounts", label: "Suggested amounts, comma separated", placeholder: "5, 10, 25" },
138
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
139
+ ],
140
+ },
100
141
  ];
101
142
  }
102
143
 
package/src/routes/buy.ts CHANGED
@@ -39,7 +39,7 @@ export const POST: APIRoute = async ({ request, locals, session, redirect }) =>
39
39
  config,
40
40
  current?.id ?? null,
41
41
  { variantId: p.variantId, sellingPlanId: p.sellingPlanId },
42
- { email: member?.email ?? null, buyerIp: request.headers.get("cf-connecting-ip") },
42
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp: request.headers.get("cf-connecting-ip") },
43
43
  );
44
44
  saveCart(session, { id: cart.id, count: cart.totalQuantity });
45
45
  return redirect(cart.checkoutUrl, 303);
@@ -0,0 +1,97 @@
1
+ /**
2
+ * POST /_emdash/api/auth/redeemid/cart-edit: change the visitor's Shopify cart.
3
+ *
4
+ * Fields: `action` (add | update | remove | discount), `return`, and
5
+ * - add: `product` (handle), optional `variant` (must be one of that
6
+ * product's variants, read again from Shopify), `quantity` (1 to 1000),
7
+ * `then` = "checkout" to go straight to checkout;
8
+ * - update: `line` and `quantity` (0 removes the line);
9
+ * - remove: `line`;
10
+ * - discount: `code` (empty clears the codes).
11
+ *
12
+ * Prices, discounts and availability come from Shopify; nothing posted is
13
+ * trusted beyond the ids it names. Back to `return` with a notice:
14
+ * cart_added, cart_updated, discount_applied, discount_not_applicable,
15
+ * plan_unavailable, cart_empty or checkout_failed.
16
+ */
17
+
18
+ import type { APIRoute } from "astro";
19
+
20
+ import { safeReturn } from "../access.js";
21
+ import { readCart, saveCart } from "../cart.js";
22
+ import { readConfig, storeDomain } from "../config.js";
23
+ import { memberForUser, stores } from "../storage.js";
24
+ import { addToCart, cartDetails, product, removeLines, setDiscountCodes, updateLines } from "../storefront.js";
25
+ import { fail } from "./shared.js";
26
+
27
+ export const prerender = false;
28
+
29
+ const MAX_QUANTITY = 1000;
30
+
31
+ export const POST: APIRoute = async ({ request, locals, session, redirect }) => {
32
+ const form = await request.formData().catch(() => null);
33
+ const field = (k: string) => String(form?.get(k) ?? "").trim();
34
+ const returnTo = safeReturn(field("return") || "/");
35
+ const emdash = (locals as { emdash?: { config?: unknown; db?: unknown } }).emdash;
36
+ const config = readConfig(emdash?.config);
37
+ if (!config || !storeDomain(config)) return fail(redirect, returnTo, "not_configured");
38
+ const buyerIp = request.headers.get("cf-connecting-ip");
39
+ const action = field("action");
40
+
41
+ try {
42
+ if (action === "add") {
43
+ const handle = field("product");
44
+ const quantity = Number.parseInt(field("quantity") || "1", 10);
45
+ if (!/^[a-z0-9][a-z0-9-]*$/i.test(handle) || !Number.isFinite(quantity) || quantity < 1 || quantity > MAX_QUANTITY) {
46
+ return fail(redirect, returnTo, "plan_unavailable");
47
+ }
48
+ const p = await product(config, handle);
49
+ const wanted = field("variant");
50
+ const variant = wanted ? p?.variants.find((v) => v.id === wanted) : p?.variants.find((v) => v.availableForSale);
51
+ if (!p || !variant?.availableForSale) return fail(redirect, returnTo, "plan_unavailable");
52
+ const user = (await session?.get("user")) as { id?: string } | undefined;
53
+ const member = user?.id && emdash?.db ? await memberForUser(stores(emdash.db), user.id) : null;
54
+ const current = await readCart(session);
55
+ const cart = await addToCart(
56
+ config,
57
+ current?.id ?? null,
58
+ { variantId: variant.id, sellingPlanId: p.sellingPlanId, quantity },
59
+ { email: member?.email ?? null, attributes: { _redeemid_page: returnTo }, buyerIp },
60
+ );
61
+ saveCart(session, { id: cart.id, count: cart.totalQuantity });
62
+ return field("then") === "checkout" ? redirect(cart.checkoutUrl, 303) : fail(redirect, returnTo, "cart_added");
63
+ }
64
+
65
+ const ref = await readCart(session);
66
+ if (!ref) return fail(redirect, returnTo, "cart_empty");
67
+ const cart = await cartDetails(config, ref.id, { buyerIp });
68
+ if (!cart) {
69
+ saveCart(session, null);
70
+ return fail(redirect, returnTo, "cart_empty");
71
+ }
72
+ const line = field("line");
73
+ const owns = cart.lines.some((l) => l.id === line);
74
+
75
+ if (action === "update" || action === "remove") {
76
+ if (!owns) return fail(redirect, returnTo, "cart_updated");
77
+ const quantity = action === "remove" ? 0 : Number.parseInt(field("quantity"), 10);
78
+ if (!Number.isFinite(quantity) || quantity < 0 || quantity > MAX_QUANTITY) return fail(redirect, returnTo, "cart_updated");
79
+ const next = quantity === 0 ? await removeLines(config, cart.id, [line], { buyerIp }) : await updateLines(config, cart.id, [{ id: line, quantity }], { buyerIp });
80
+ saveCart(session, { id: next.id, count: next.totalQuantity });
81
+ return fail(redirect, returnTo, "cart_updated");
82
+ }
83
+
84
+ if (action === "discount") {
85
+ const code = field("code").slice(0, 100);
86
+ const next = await setDiscountCodes(config, cart.id, code ? [code] : [], { buyerIp });
87
+ saveCart(session, { id: next.id, count: next.totalQuantity });
88
+ if (!code) return fail(redirect, returnTo, "cart_updated");
89
+ const applied = next.discountCodes.find((d) => d.code.toLowerCase() === code.toLowerCase());
90
+ return fail(redirect, returnTo, applied?.applicable ? "discount_applied" : "discount_not_applicable");
91
+ }
92
+
93
+ return fail(redirect, returnTo, "cart_updated");
94
+ } catch {
95
+ return fail(redirect, returnTo, "checkout_failed");
96
+ }
97
+ };
package/src/storefront.ts CHANGED
@@ -27,6 +27,7 @@ const planCache = new Map<string, { at: number; value: Plan | null }>();
27
27
 
28
28
  export function resetStorefrontCache(): void {
29
29
  planCache.clear();
30
+ shopCache.clear();
30
31
  }
31
32
 
32
33
  export async function storefront<T>(
@@ -169,18 +170,304 @@ export async function getCart(
169
170
  export async function addToCart(
170
171
  config: RedeemIDConfig,
171
172
  cartId: string | null,
172
- line: { variantId: string; sellingPlanId?: string | null },
173
- opts: { email?: string | null; buyerIp?: string | null; fetcher?: typeof fetch } = {},
173
+ line: { variantId: string; sellingPlanId?: string | null; quantity?: number },
174
+ opts: {
175
+ email?: string | null;
176
+ /** Cart attributes for a new cart, e.g. where the buyer came from. Keys starting "_" are hidden at checkout. */
177
+ attributes?: Record<string, string>;
178
+ buyerIp?: string | null;
179
+ fetcher?: typeof fetch;
180
+ } = {},
174
181
  ): Promise<Cart> {
175
- const lines = [{ merchandiseId: line.variantId, quantity: 1, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
182
+ const quantity = Math.max(1, Math.floor(line.quantity ?? 1));
183
+ const lines = [{ merchandiseId: line.variantId, quantity, ...(line.sellingPlanId ? { sellingPlanId: line.sellingPlanId } : {}) }];
176
184
  if (cartId) {
177
185
  const data = await storefront<{ cartLinesAdd: CartResult }>(config, CART_ADD, { id: cartId, lines }, opts).catch(() => null);
178
186
  if (data?.cartLinesAdd.cart) return data.cartLinesAdd.cart;
179
187
  }
180
188
  const input: Record<string, unknown> = { lines };
181
189
  if (opts.email) input.buyerIdentity = { email: opts.email };
190
+ const attributes = Object.entries(opts.attributes ?? {}).filter(([, v]) => v);
191
+ if (attributes.length) input.attributes = attributes.map(([key, value]) => ({ key, value }));
182
192
  const data = await storefront<{ cartCreate: CartResult }>(config, CART_NEW, { input }, opts);
183
193
  const cart = data.cartCreate.cart;
184
194
  if (!cart) throw new Error(data.cartCreate.userErrors[0]?.message ?? "Shopify returned no cart.");
185
195
  return cart;
186
196
  }
197
+
198
+ // ---------------------------------------------------------------- shop
199
+
200
+ export interface Money {
201
+ amount: string;
202
+ currencyCode: string;
203
+ }
204
+
205
+ export interface Image {
206
+ url: string;
207
+ altText: string | null;
208
+ }
209
+
210
+ export interface Variant {
211
+ id: string;
212
+ title: string;
213
+ availableForSale: boolean;
214
+ price: Money;
215
+ }
216
+
217
+ export interface Product {
218
+ handle: string;
219
+ title: string;
220
+ description: string;
221
+ availableForSale: boolean;
222
+ image: Image | null;
223
+ variants: Variant[];
224
+ /** The first selling plan, for subscription-only products. */
225
+ sellingPlanId: string | null;
226
+ }
227
+
228
+ export interface ProductCard {
229
+ handle: string;
230
+ title: string;
231
+ availableForSale: boolean;
232
+ image: Image | null;
233
+ /** The lowest variant price. */
234
+ price: Money | null;
235
+ }
236
+
237
+ export interface Collection {
238
+ handle: string;
239
+ title: string;
240
+ description: string;
241
+ products: ProductCard[];
242
+ }
243
+
244
+ const PRODUCT_QUERY = `query Product($handle: String!) {
245
+ product(handle: $handle) {
246
+ handle title description availableForSale requiresSellingPlan
247
+ featuredImage { url altText }
248
+ variants(first: 50) { nodes { id title availableForSale price { amount currencyCode } } }
249
+ sellingPlanGroups(first: 1) { nodes { sellingPlans(first: 1) { nodes { id } } } }
250
+ }
251
+ }`;
252
+
253
+ const COLLECTION_QUERY = `query Collection($handle: String!, $first: Int!) {
254
+ collection(handle: $handle) {
255
+ handle title description
256
+ products(first: $first) {
257
+ nodes { handle title availableForSale featuredImage { url altText } priceRange { minVariantPrice { amount currencyCode } } }
258
+ }
259
+ }
260
+ }`;
261
+
262
+ const shopCache = new Map<string, { at: number; value: unknown }>();
263
+
264
+ async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
265
+ const hit = shopCache.get(key);
266
+ if (hit && Date.now() - hit.at < PLAN_TTL_MS) return hit.value as T;
267
+ const value = await load();
268
+ shopCache.set(key, { at: Date.now(), value });
269
+ return value;
270
+ }
271
+
272
+ /** A product with its variants, cached for five minutes. Null when Shopify has no such product. */
273
+ export async function product(config: RedeemIDConfig, handle: string, fetcher?: typeof fetch): Promise<Product | null> {
274
+ return cached(`${shopOrigin(config)}|product|${handle}`, async () => {
275
+ type Data = {
276
+ product: {
277
+ handle: string;
278
+ title: string;
279
+ description: string;
280
+ availableForSale: boolean;
281
+ requiresSellingPlan: boolean;
282
+ featuredImage: Image | null;
283
+ variants: { nodes: Variant[] };
284
+ sellingPlanGroups: { nodes: Array<{ sellingPlans: { nodes: Array<{ id: string }> } }> };
285
+ } | null;
286
+ };
287
+ const { product: p } = await storefront<Data>(config, PRODUCT_QUERY, { handle }, { fetcher });
288
+ if (!p) return null;
289
+ return {
290
+ handle: p.handle,
291
+ title: p.title,
292
+ description: p.description,
293
+ availableForSale: p.availableForSale,
294
+ image: p.featuredImage ?? null,
295
+ variants: p.variants.nodes,
296
+ sellingPlanId: p.requiresSellingPlan ? (p.sellingPlanGroups.nodes[0]?.sellingPlans.nodes[0]?.id ?? null) : null,
297
+ };
298
+ });
299
+ }
300
+
301
+ /** A collection's first `first` products, cached for five minutes. Null when Shopify has no such collection. */
302
+ export async function collection(config: RedeemIDConfig, handle: string, first = 12, fetcher?: typeof fetch): Promise<Collection | null> {
303
+ const n = Math.max(1, Math.min(50, Math.floor(first)));
304
+ return cached(`${shopOrigin(config)}|collection|${handle}|${n}`, async () => {
305
+ type Data = {
306
+ collection: {
307
+ handle: string;
308
+ title: string;
309
+ description: string;
310
+ products: {
311
+ nodes: Array<{ handle: string; title: string; availableForSale: boolean; featuredImage: Image | null; priceRange: { minVariantPrice: Money } }>;
312
+ };
313
+ } | null;
314
+ };
315
+ const { collection: c } = await storefront<Data>(config, COLLECTION_QUERY, { handle, first: n }, { fetcher });
316
+ if (!c) return null;
317
+ return {
318
+ handle: c.handle,
319
+ title: c.title,
320
+ description: c.description,
321
+ products: c.products.nodes.map((p) => ({
322
+ handle: p.handle,
323
+ title: p.title,
324
+ availableForSale: p.availableForSale,
325
+ image: p.featuredImage ?? null,
326
+ price: p.priceRange?.minVariantPrice ?? null,
327
+ })),
328
+ };
329
+ });
330
+ }
331
+
332
+ /** Format Shopify money for display. */
333
+ export function money(m: Money | null | undefined, locale = "en-US"): string | null {
334
+ if (!m) return null;
335
+ const n = Number(m.amount);
336
+ if (!Number.isFinite(n)) return null;
337
+ return new Intl.NumberFormat(locale, { style: "currency", currency: m.currencyCode }).format(n);
338
+ }
339
+
340
+ // ---------------------------------------------------------------- cart details
341
+
342
+ export interface CartLine {
343
+ id: string;
344
+ quantity: number;
345
+ variantId: string;
346
+ variantTitle: string;
347
+ productTitle: string;
348
+ productHandle: string;
349
+ image: Image | null;
350
+ price: Money | null;
351
+ total: Money | null;
352
+ }
353
+
354
+ export interface CartDetails extends Cart {
355
+ lines: CartLine[];
356
+ subtotal: Money | null;
357
+ total: Money | null;
358
+ discountCodes: Array<{ code: string; applicable: boolean }>;
359
+ }
360
+
361
+ const CART_DETAIL_FIELDS = `${CART_FIELDS}
362
+ cost { subtotalAmount { amount currencyCode } totalAmount { amount currencyCode } }
363
+ discountCodes { code applicable }
364
+ lines(first: 100) { nodes {
365
+ id quantity
366
+ cost { totalAmount { amount currencyCode } }
367
+ merchandise { ... on ProductVariant { id title price { amount currencyCode } image { url altText } product { title handle featuredImage { url altText } } } }
368
+ } }`;
369
+
370
+ type RawCart = Cart & {
371
+ cost?: { subtotalAmount?: Money | null; totalAmount?: Money | null } | null;
372
+ discountCodes?: Array<{ code: string; applicable: boolean }>;
373
+ lines?: {
374
+ nodes: Array<{
375
+ id: string;
376
+ quantity: number;
377
+ cost?: { totalAmount?: Money | null } | null;
378
+ merchandise: {
379
+ id: string;
380
+ title: string;
381
+ price?: Money | null;
382
+ image?: Image | null;
383
+ product: { title: string; handle: string; featuredImage?: Image | null };
384
+ };
385
+ }>;
386
+ };
387
+ };
388
+
389
+ function details(c: RawCart): CartDetails {
390
+ return {
391
+ id: c.id,
392
+ checkoutUrl: c.checkoutUrl,
393
+ totalQuantity: c.totalQuantity,
394
+ subtotal: c.cost?.subtotalAmount ?? null,
395
+ total: c.cost?.totalAmount ?? null,
396
+ discountCodes: c.discountCodes ?? [],
397
+ lines: (c.lines?.nodes ?? []).map((l) => ({
398
+ id: l.id,
399
+ quantity: l.quantity,
400
+ variantId: l.merchandise.id,
401
+ variantTitle: l.merchandise.title,
402
+ productTitle: l.merchandise.product.title,
403
+ productHandle: l.merchandise.product.handle,
404
+ image: l.merchandise.image ?? l.merchandise.product.featuredImage ?? null,
405
+ price: l.merchandise.price ?? null,
406
+ total: l.cost?.totalAmount ?? null,
407
+ })),
408
+ };
409
+ }
410
+
411
+ type DetailResult = { cart: RawCart | null; userErrors: Array<{ message: string }> };
412
+
413
+ /** The cart with its lines, costs and discount codes. Null once Shopify no longer has it. */
414
+ export async function cartDetails(
415
+ config: RedeemIDConfig,
416
+ id: string,
417
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
418
+ ): Promise<CartDetails | null> {
419
+ const data = await storefront<{ cart: RawCart | null }>(config, `query Cart($id: ID!) { cart(id: $id) { ${CART_DETAIL_FIELDS} } }`, { id }, opts);
420
+ return data.cart ? details(data.cart) : null;
421
+ }
422
+
423
+ function result(r: DetailResult): CartDetails {
424
+ if (!r.cart) throw new Error(r.userErrors[0]?.message ?? "Shopify returned no cart.");
425
+ return details(r.cart);
426
+ }
427
+
428
+ /** Set line quantities; a quantity of 0 removes the line. */
429
+ export async function updateLines(
430
+ config: RedeemIDConfig,
431
+ cartId: string,
432
+ lines: Array<{ id: string; quantity: number }>,
433
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
434
+ ): Promise<CartDetails> {
435
+ const data = await storefront<{ cartLinesUpdate: DetailResult }>(
436
+ config,
437
+ `mutation CartUpdate($id: ID!, $lines: [CartLineUpdateInput!]!) { cartLinesUpdate(cartId: $id, lines: $lines) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
438
+ { id: cartId, lines },
439
+ opts,
440
+ );
441
+ return result(data.cartLinesUpdate);
442
+ }
443
+
444
+ export async function removeLines(
445
+ config: RedeemIDConfig,
446
+ cartId: string,
447
+ lineIds: string[],
448
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
449
+ ): Promise<CartDetails> {
450
+ const data = await storefront<{ cartLinesRemove: DetailResult }>(
451
+ config,
452
+ `mutation CartRemove($id: ID!, $lineIds: [ID!]!) { cartLinesRemove(cartId: $id, lineIds: $lineIds) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
453
+ { id: cartId, lineIds },
454
+ opts,
455
+ );
456
+ return result(data.cartLinesRemove);
457
+ }
458
+
459
+ /** Replace the cart's discount codes (an empty list clears them). */
460
+ export async function setDiscountCodes(
461
+ config: RedeemIDConfig,
462
+ cartId: string,
463
+ codes: string[],
464
+ opts: { buyerIp?: string | null; fetcher?: typeof fetch } = {},
465
+ ): Promise<CartDetails> {
466
+ const data = await storefront<{ cartDiscountCodesUpdate: DetailResult }>(
467
+ config,
468
+ `mutation CartDiscount($id: ID!, $codes: [String!]) { cartDiscountCodesUpdate(cartId: $id, discountCodes: $codes) { cart { ${CART_DETAIL_FIELDS} } userErrors { message } } }`,
469
+ { id: cartId, codes },
470
+ opts,
471
+ );
472
+ return result(data.cartDiscountCodesUpdate);
473
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.7";
1
+ export const VERSION = "0.0.8";