Skip to content

Commit 1491b48

Browse files
ddyson1claude
andauthored
$45 pricing + simplified Stripe checkout Worker (#11)
* Rewrite README to reflect the PlotFlow brand Lead with what PlotFlow is (pen-plotter art house, one-continuous-line mobile suit editions) and the process, while keeping the local-run, deploy, and structure notes. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Crop plots to bounding box for consistent sizing Compute each suit's bounding box at runtime (getBBox) and crop the viewBox to a padded version, so every edition fills its card and the live-plot stage at a consistent scale instead of being letterboxed in the original canvas. Fix the paper bed to a single 4:3 shape so the canvas is consistent across designs, and reset the animation clock on load so the live plot always starts from a blank sheet. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Fix live plot blank-start flash and rename to PLOTFLOW Hide the path before swapping suit data so the old dash values don't flash the full path for a frame. Rename "PLOTFLOW ART HOUSE" to "PLOTFLOW" in all footer copyright lines. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Fix live plot revealing whole path at once The animated hero path used vector-effect:non-scaling-stroke, which measures stroke-dasharray in screen pixels while getTotalLength returns user units. The mismatch made the full path render as one solid dash (visible immediately) with the trace drawing over it. Drop non-scaling-stroke from the animated path and set its width in user units per-load so it stays ~1.2px. Static shop-card paths are unchanged. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Remove "art house" from brand copy Replace "art house" with "studio" in the hero sub, contact page, README, and design system doc; drop "ART HOUSE" from the README copyright line. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Plot live drawing on a canvas instead of dash-offset The suit paths contain thousands of M-command subpaths, which the CSS stroke-dashoffset reveal renders all at once — so the whole design appeared instantly instead of being traced. Replace the dash technique with progressive canvas stroking: walk the geometry with getPointAtLength, lifting the pen at subpath jumps, so ink only appears where the pen has passed. The SVG path is kept for geometry only (stroke:none) and the pen reticle stays on top. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Replace 'avant-garde' with 'independent' in brand copy https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Add multi-item cart with Stripe checkout + mobile hero layout Cart: client-side cart (localStorage) with a slide-out drawer, nav count badge, and per-edition Acquire buttons. Checkout posts edition keys+quantities to a Cloudflare Worker that resolves prices server-side and creates a Stripe Checkout Session (prices never trusted from the client). Adds success.html, scripts/config.js (public endpoint config), and worker/ (Worker + wrangler.toml + setup README). Secrets stay out of the repo via wrangler secrets. Mobile: taller portrait plot bed and a reordered hero stack (plot › progress › controls › lockup) so controls no longer overlap. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Multi-item cart + Stripe checkout, mobile hero layout, copy tweak - Cart: localStorage cart, slide-out drawer, nav badge, per-edition Acquire buttons; checkout posts keys+qty to a Cloudflare Worker that resolves prices server-side and creates a Stripe Checkout Session. Adds success.html, scripts/config.js, scripts/cart.js, and worker/. - Mobile: taller portrait plot bed and reordered hero stack so the controls no longer overlap. - Copy: 'avant-garde' -> 'independent'. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Site polish: SEO/OG meta, favicon, 404, sitemap, footer link wiring https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Fix emoji play button on mobile by adding text variation selector Appends U+FE0E after ▶ and ▸ characters so mobile Safari renders them as text glyphs instead of colored emoji. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Force text rendering on play buttons + show all 8 editions in shop - Add font-family:var(--sans) to .plotbtn and .replay span CSS to prevent iOS emoji rendering of the ▶ character - Add gm and guntank to shopOrder so all editions appear in the grid https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Add Google Analytics (G-1HJ4VQ4K1B) to all pages https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS * Set price to $45 + simplify checkout Worker to server-side price_data - Bump all editions to $45 (display + build source) - Worker now defines name/price per edition via Stripe price_data (CATALOG), removing the need to create Stripe Products/Price IDs - Add flat $9 shipping fee (SHIPPING config; set to 0 for free) - Rewrite worker README for the simplified setup flow https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7826360 commit 1491b48

4 files changed

Lines changed: 87 additions & 70 deletions

File tree

data/editions.js

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/build_data.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414

1515
# key -> metadata. `file` is the svg basename in assets/plots (without .svg).
1616
META = {
17-
"zaku": {"file":"zaku-ii", "name":"Zaku II", "code":"MS-06", "jp":"ザク", "edition":"ED. 09/25 · A2", "price":"$30"},
18-
"guncannon":{"file":"guncannon","name":"Guncannon","code":"RX-77", "jp":"ガンキャノン", "edition":"ED. 18/25 · A3", "price":"$30"},
19-
"bigzam": {"file":"big-zam", "name":"Big Zam", "code":"MA-08", "jp":"ビグ・ザム", "edition":"ED. 05/25 · A1", "price":"$30"},
20-
"dom": {"file":"dom", "name":"Dom", "code":"MS-09", "jp":"ドム", "edition":"ED. 12/25 · A2", "price":"$30"},
21-
"zgok": {"file":"zgok", "name":"Z'Gok", "code":"MSM-07", "jp":"ズゴック", "edition":"ED. 22/25 · A3", "price":"$30"},
22-
"gp02": {"file":"gp-02", "name":"GP-02A", "code":"RX-78GP02", "jp":"ガンダム試作2号機", "edition":"ED. 15/25 · A2", "price":"$30"},
23-
"gm": {"file":"gm", "name":"GM", "code":"RGM-79", "jp":"ジム", "edition":"ED. 07/25 · A3", "price":"$30"},
24-
"guntank": {"file":"guntank", "name":"Guntank", "code":"RX-75", "jp":"ガンタンク", "edition":"ED. 11/25 · A2", "price":"$30"},
17+
"zaku": {"file":"zaku-ii", "name":"Zaku II", "code":"MS-06", "jp":"ザク", "edition":"ED. 09/25 · A2", "price":"$45"},
18+
"guncannon":{"file":"guncannon","name":"Guncannon","code":"RX-77", "jp":"ガンキャノン", "edition":"ED. 18/25 · A3", "price":"$45"},
19+
"bigzam": {"file":"big-zam", "name":"Big Zam", "code":"MA-08", "jp":"ビグ・ザム", "edition":"ED. 05/25 · A1", "price":"$45"},
20+
"dom": {"file":"dom", "name":"Dom", "code":"MS-09", "jp":"ドム", "edition":"ED. 12/25 · A2", "price":"$45"},
21+
"zgok": {"file":"zgok", "name":"Z'Gok", "code":"MSM-07", "jp":"ズゴック", "edition":"ED. 22/25 · A3", "price":"$45"},
22+
"gp02": {"file":"gp-02", "name":"GP-02A", "code":"RX-78GP02", "jp":"ガンダム試作2号機", "edition":"ED. 15/25 · A2", "price":"$45"},
23+
"gm": {"file":"gm", "name":"GM", "code":"RGM-79", "jp":"ジム", "edition":"ED. 07/25 · A3", "price":"$45"},
24+
"guntank": {"file":"guntank", "name":"Guntank", "code":"RX-75", "jp":"ガンタンク", "edition":"ED. 11/25 · A2", "price":"$45"},
2525
}
2626

2727
# order of the dropdown in the live plotter, and of the cards in the shop grid

worker/README.md

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,59 +2,60 @@
22

33
A tiny Cloudflare Worker that turns the site's cart into a **Stripe Checkout
44
Session** and hands back the hosted-checkout URL. It holds the Stripe **secret
5-
key** (as an encrypted secret, never in this repo) and resolves prices
5+
key** (as an encrypted secret, never in this repo) and defines prices
66
server-side, so the browser can never set its own amount.
77

88
```
99
browser cart ──POST {items:[{key,qty}]}──▶ Worker ──▶ Stripe
1010
◀──────────── { url } (Stripe-hosted checkout) ───────────
1111
```
1212

13-
## One-time setup
13+
Prices and product names live in `src/index.js``CATALOG` as ad-hoc
14+
`price_data` line items, so **there is nothing to create in the Stripe
15+
dashboard** — no products, no Price IDs to paste. Editing a price is a
16+
one-line change to `cents`.
1417

15-
### 1. Create the editions in Stripe (test mode first)
16-
In the Stripe dashboard, toggle **Test mode**, then for each edition create a
17-
**Product** with a one-off **Price**. Copy each Price ID (`price_…`).
18+
## One-time setup
1819

19-
### 2. Map the prices
20-
Edit `src/index.js``PRICES`, replacing every `price_REPLACE_ME` with the
21-
matching Price ID. The keys (`zaku`, `guncannon`, …) must match the keys in
22-
`data/editions.js`.
20+
### 1. Get your Stripe secret key (test mode first)
21+
In the Stripe dashboard, toggle **Test mode** (top right), then go to
22+
**Developers → API keys** and copy the **Secret key** (`sk_test_…`).
2323

24-
### 3. Install + authenticate Wrangler
24+
### 2. Install + authenticate Wrangler
2525
```bash
2626
npm install -g wrangler
27-
wrangler login
27+
wrangler login # opens the browser to authorize your Cloudflare account
2828
```
29+
(If you don't have a Cloudflare account yet, create a free one first.)
2930

30-
### 4. Add the secret key
31+
### 3. Add the secret key
3132
```bash
3233
cd worker
3334
wrangler secret put STRIPE_SECRET_KEY # paste your sk_test_… key
3435
```
3536

36-
### 5. Deploy
37+
### 4. Deploy
3738
```bash
3839
wrangler deploy
3940
```
4041
Wrangler prints a URL like `https://plotflow-checkout.<subdomain>.workers.dev`.
4142

42-
### 6. Point the site at it
43+
### 5. Point the site at it
4344
Put that URL in `scripts/config.js``checkoutEndpoint`, commit, and push.
45+
(Send me the URL and I'll wire it up.)
4446

4547
## Testing
4648
With test-mode keys, use Stripe's test card `4242 4242 4242 4242`, any future
47-
expiry, any CVC/ZIP. A successful payment redirects to `/success.html`.
49+
expiry, any CVC/ZIP. A successful payment redirects to `/success.html`; the
50+
cart is cleared there.
4851

4952
## Going live
50-
Swap to live-mode Price IDs in `PRICES`, set the **live** `sk_live_…` secret
51-
(`wrangler secret put STRIPE_SECRET_KEY`), and redeploy.
52-
53-
## Notes
54-
- `ALLOWED_ORIGINS`, `SHIP_COUNTRIES`, and the success/cancel URLs are at the
55-
top of `src/index.js`.
56-
- Shipping is collected but not charged (standard shipping is included). To
57-
charge shipping or enable tax, add `shipping_options` / `automatic_tax` to
58-
the session.
59-
- For inventory limits on numbered editions, set inventory on the Stripe
60-
Price/Product, or add a webhook later to decrement stock.
53+
Set the **live** `sk_live_…` secret (`wrangler secret put STRIPE_SECRET_KEY`)
54+
and redeploy. No price changes needed — `CATALOG` is the same in both modes.
55+
56+
## Config (top of `src/index.js`)
57+
- `CATALOG` — edition name + price in cents. Keys must match `data/editions.js`.
58+
- `SHIPPING` — flat fee added on top (currently $9). Set `cents: 0` for free.
59+
- `ALLOWED_ORIGINS`, `SHIP_COUNTRIES`, `SUCCESS_URL`, `CANCEL_URL`.
60+
- For numbered-edition inventory limits or order fulfillment hooks, add a
61+
Stripe webhook later.

worker/src/index.js

Lines changed: 44 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
/* ============================================================
22
PLOTFLOW · Checkout Worker (Cloudflare)
3-
Creates a Stripe Checkout Session from a cart of edition keys.
4-
The browser only sends {items:[{key,qty}]}; prices are resolved
5-
here from PRICES so the client can never set its own amount.
3+
Turns the site's cart into a Stripe Checkout Session and returns
4+
the hosted-checkout URL.
65
7-
Secret: wrangler secret put STRIPE_SECRET_KEY
8-
Deploy: wrangler deploy (see ../README.md)
6+
Prices live HERE, server-side (see CATALOG), built as ad-hoc
7+
`price_data` line items — so there is nothing to create in the
8+
Stripe dashboard and nothing for the browser to tamper with. The
9+
client only ever sends {items:[{key,qty}]}.
10+
11+
Secret: wrangler secret put STRIPE_SECRET_KEY (paste sk_test_… / sk_live_…)
12+
Deploy: wrangler deploy (see ../README.md)
913
============================================================ */
1014

1115
// Origins allowed to call this endpoint (the live site + local dev).
@@ -16,19 +20,23 @@ const ALLOWED_ORIGINS = [
1620
"http://127.0.0.1:8000"
1721
];
1822

19-
// edition key -> Stripe Price ID. Create each edition as a Product+Price
20-
// in your Stripe dashboard (test mode first) and paste the price IDs here.
21-
const PRICES = {
22-
zaku: "price_REPLACE_ME",
23-
guncannon: "price_REPLACE_ME",
24-
bigzam: "price_REPLACE_ME",
25-
dom: "price_REPLACE_ME",
26-
zgok: "price_REPLACE_ME",
27-
gp02: "price_REPLACE_ME",
28-
gm: "price_REPLACE_ME",
29-
guntank: "price_REPLACE_ME"
23+
// edition key -> { name shown on Stripe checkout, price in cents }.
24+
// Keys must match data/editions.js. Change a price by editing `cents`.
25+
const CURRENCY = "usd";
26+
const CATALOG = {
27+
zaku: { name: "Zaku II — MS-06", cents: 4500 },
28+
guncannon: { name: "Guncannon — RX-77", cents: 4500 },
29+
bigzam: { name: "Big Zam — MA-08", cents: 4500 },
30+
dom: { name: "Dom — MS-09", cents: 4500 },
31+
zgok: { name: "Z'Gok — MSM-07", cents: 4500 },
32+
gp02: { name: "GP-02A — RX-78GP02", cents: 4500 },
33+
gm: { name: "GM — RGM-79", cents: 4500 },
34+
guntank: { name: "Guntank — RX-75", cents: 4500 }
3035
};
3136

37+
// Flat shipping fee added on top, all countries. Set cents = 0 for free shipping.
38+
const SHIPPING = { label: "Standard shipping", cents: 900 };
39+
3240
const SUCCESS_URL = "https://plotflow.io/success.html?session_id={CHECKOUT_SESSION_ID}";
3341
const CANCEL_URL = "https://plotflow.io/?checkout=cancelled";
3442

@@ -48,28 +56,36 @@ export default {
4856
try { body = await request.json(); } catch (e) { return json({ error: "Invalid JSON" }, 400, cors); }
4957

5058
const items = Array.isArray(body.items) ? body.items : [];
51-
const line_items = [];
59+
const form = new URLSearchParams();
60+
form.set("mode", "payment");
61+
62+
let n = 0;
5263
for (const it of items) {
53-
const key = String(it && it.key || "");
64+
const key = String((it && it.key) || "");
5465
const qty = Math.max(1, Math.min(99, parseInt(it && it.qty, 10) || 0));
55-
const price = PRICES[key];
56-
if (!price || price === "price_REPLACE_ME") return json({ error: "Unavailable item: " + key }, 400, cors);
57-
line_items.push({ price, quantity: qty });
66+
const prod = CATALOG[key];
67+
if (!prod) return json({ error: "Unavailable item: " + key }, 400, cors);
68+
form.set(`line_items[${n}][price_data][currency]`, CURRENCY);
69+
form.set(`line_items[${n}][price_data][unit_amount]`, String(prod.cents));
70+
form.set(`line_items[${n}][price_data][product_data][name]`, prod.name);
71+
form.set(`line_items[${n}][quantity]`, String(qty));
72+
n++;
5873
}
59-
if (!line_items.length) return json({ error: "Cart is empty" }, 400, cors);
74+
if (!n) return json({ error: "Cart is empty" }, 400, cors);
6075

61-
const form = new URLSearchParams();
62-
form.set("mode", "payment");
63-
line_items.forEach((li, i) => {
64-
form.set(`line_items[${i}][price]`, li.price);
65-
form.set(`line_items[${i}][quantity]`, String(li.quantity));
66-
});
6776
form.set("success_url", SUCCESS_URL);
6877
form.set("cancel_url", CANCEL_URL);
6978
form.set("billing_address_collection", "required");
7079
form.set("phone_number_collection[enabled]", "true");
7180
SHIP_COUNTRIES.forEach((c, i) => form.set(`shipping_address_collection[allowed_countries][${i}]`, c));
7281

82+
if (SHIPPING.cents > 0) {
83+
form.set("shipping_options[0][shipping_rate_data][type]", "fixed_amount");
84+
form.set("shipping_options[0][shipping_rate_data][fixed_amount][amount]", String(SHIPPING.cents));
85+
form.set("shipping_options[0][shipping_rate_data][fixed_amount][currency]", CURRENCY);
86+
form.set("shipping_options[0][shipping_rate_data][display_name]", SHIPPING.label);
87+
}
88+
7389
const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
7490
method: "POST",
7591
headers: {

0 commit comments

Comments
 (0)