Skip to content

Commit 91f4afc

Browse files
committed
Add live stock counter (X/25 remaining + sold-out)
Worker: - New GET /stock endpoint returns remaining count per edition from a STOCK KV namespace (EDITION_SIZE - sold), with GET CORS. - Checkout writes a machine-readable metadata.skus list (key:qty). - Webhook decrements KV per purchased edition after the order email. - wrangler.toml gains the STOCK KV binding (id to be filled after `wrangler kv namespace create STOCK`); README documents setup. Frontend: - scripts/stock.js fetches /stock once and hands counts to listeners, failing silent if the endpoint is unreachable (no badges, no breakage). - Shop cards show "X / 25 left" (red when <=5) and flip to "Sold out" with a disabled Acquire button + dimmed art at zero. - Product pages show "X of 25 remaining" and disable Acquire when sold out. https://claude.ai/code/session_01VhWRYXbuqapZ9YkvksroaS
1 parent db9dd66 commit 91f4afc

9 files changed

Lines changed: 184 additions & 7 deletions

File tree

index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ <h2>Join the drop list<span class="star">*</span></h2>
160160
<!-- data first, then components -->
161161
<script src="data/editions.js"></script>
162162
<script src="scripts/config.js"></script>
163+
<script src="scripts/stock.js"></script>
163164
<script src="scripts/shop.js"></script>
164165
<script src="scripts/plotter.js"></script>
165166
<script src="scripts/newsletter.js"></script>

product.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ <h1 class="pd-name" id="pdName"></h1>
7171
<span class="pd-ed tiny" id="pdEd"></span>
7272
<span class="pd-price" id="pdPrice"></span>
7373
</div>
74+
<div class="pd-stock tiny" id="pdStock" hidden></div>
7475

7576
<p class="pd-lore" id="pdLore"></p>
7677

@@ -120,6 +121,7 @@ <h3>Shipping &amp; returns</h3>
120121
<!-- data first, then components -->
121122
<script src="data/editions.js"></script>
122123
<script src="scripts/config.js"></script>
124+
<script src="scripts/stock.js"></script>
123125
<script src="scripts/product.js"></script>
124126
<script src="scripts/cart.js"></script>
125127
</body>

scripts/product.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,29 @@
7272
$("pdInk").textContent = INKS[color].name + " ink";
7373

7474
// ---- acquire ----
75-
$("pdAcquire").addEventListener("click", function () {
75+
var acqBtn = $("pdAcquire");
76+
acqBtn.addEventListener("click", function () {
77+
if (acqBtn.disabled) return;
7678
if (window.PlotflowCart) window.PlotflowCart.add(key, color);
7779
});
7880

81+
// ---- live stock (remaining count / sold-out) ----
82+
if (window.PlotflowStock) {
83+
window.PlotflowStock.ready(function (counts) {
84+
if (!counts || typeof counts[key] !== "number") return;
85+
var left = counts[key], size = window.PlotflowStock.size, badge = $("pdStock");
86+
if (left <= 0) {
87+
if (badge) { badge.textContent = "Sold out — this edition has closed"; badge.classList.add("low"); badge.hidden = false; }
88+
acqBtn.textContent = "Sold out";
89+
acqBtn.disabled = true;
90+
} else if (badge) {
91+
badge.textContent = left + " of " + size + " remaining";
92+
if (left <= 5) badge.classList.add("low");
93+
badge.hidden = false;
94+
}
95+
});
96+
}
97+
7998
// ---- live-plot preview (self-contained progressive stroker) ----
8099
var plot = makePreview(suit, INKS[color].hex);
81100

scripts/shop.js

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
var s = SUITS[key]; if (!s) return;
1717
var card = document.createElement('article');
1818
card.className = 'card';
19+
card.dataset.key = key;
1920
var no = '№ PF-0' + (10 + i);
2021
var href = 'product.html?id=' + encodeURIComponent(key);
2122
card.innerHTML =
@@ -30,7 +31,7 @@
3031
'<div class="foot"><span class="tiny">' + s.code + '</span><span class="tiny">マシンドロー</span></div>' +
3132
'<button class="plotbtn" data-plot="' + key + '">▶︎ Plot</button>' +
3233
'</a>' +
33-
'<div class="buy"><div><div class="ed">' + s.edition + '</div><div class="t">' + s.code + ' ' + s.name + '</div></div>' +
34+
'<div class="buy"><div><div class="ed">' + s.edition + '</div><div class="t">' + s.code + ' ' + s.name + '</div><div class="stock tiny" data-stock hidden></div></div>' +
3435
'<div style="display:flex;align-items:center"><span class="pr">' + s.price + '</span><button class="acq" data-acq="' + key + '" data-color="black">Acquire</button></div></div>';
3536
grid.appendChild(card);
3637

@@ -48,6 +49,30 @@
4849
} catch (e) { /* getBBox unavailable — keep full-canvas viewBox */ }
4950
});
5051

52+
// Fill in live remaining-count badges once stock loads (fails silent).
53+
if (window.PlotflowStock) {
54+
window.PlotflowStock.ready(function (counts) {
55+
if (!counts) return;
56+
var SIZE = window.PlotflowStock.size;
57+
ORDER.forEach(function (key) {
58+
var card = grid.querySelector('.card[data-key="' + key + '"]');
59+
if (!card || typeof counts[key] !== 'number') return;
60+
var left = counts[key];
61+
var badge = card.querySelector('[data-stock]');
62+
var acq = card.querySelector('.acq');
63+
if (left <= 0) {
64+
card.classList.add('sold-out');
65+
if (badge) { badge.textContent = 'Sold out'; badge.hidden = false; }
66+
if (acq) { acq.textContent = 'Sold out'; acq.disabled = true; }
67+
} else if (badge) {
68+
badge.textContent = left + ' / ' + SIZE + ' left';
69+
if (left <= 5) badge.classList.add('low');
70+
badge.hidden = false;
71+
}
72+
});
73+
});
74+
}
75+
5176
grid.addEventListener('click', function (e) {
5277
var b = e.target.closest('[data-plot]');
5378
if (!b) return;

scripts/stock.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/* ============================================================
2+
PLOTFLOW · Live stock
3+
Fetches remaining-count-per-edition from the checkout Worker's
4+
/stock endpoint and hands it to whoever's listening (shop grid,
5+
product page). Editions are limited runs; this shows how many of
6+
each are left so a sell-out reads as scarcity, not a dead link.
7+
8+
Fails silent: if the endpoint is unreachable or not yet deployed,
9+
no badges render and the site behaves exactly as before.
10+
Depends on: scripts/config.js (PLOTFLOW_CONFIG.checkoutEndpoint)
11+
============================================================ */
12+
(function () {
13+
var CFG = window.PLOTFLOW_CONFIG || {};
14+
var base = (CFG.checkoutEndpoint || "").replace(/\/+$/, "");
15+
var SIZE = 25; // pieces per numbered edition
16+
17+
var data = null; // { zaku: 22, ... } remaining counts, or null on failure
18+
var done = false;
19+
var waiting = [];
20+
21+
function flush() {
22+
var list = waiting; waiting = [];
23+
list.forEach(function (cb) { try { cb(data); } catch (e) {} });
24+
}
25+
26+
if (base) {
27+
fetch(base + "/stock", { method: "GET" })
28+
.then(function (r) { return r.ok ? r.json() : null; })
29+
.then(function (d) { data = d && typeof d === "object" ? d : null; done = true; flush(); })
30+
.catch(function () { data = null; done = true; flush(); });
31+
} else {
32+
done = true;
33+
}
34+
35+
window.PlotflowStock = {
36+
size: SIZE,
37+
// cb receives the counts object (or null if unavailable), once.
38+
ready: function (cb) {
39+
if (typeof cb !== "function") return;
40+
if (done) cb(data); else waiting.push(cb);
41+
}
42+
};
43+
})();

styles/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,11 @@ select{appearance:none;background:rgba(245,244,239,.06);color:var(--white);borde
142142
.card .buy .pr{font-weight:800}
143143
.card .buy .acq{font-weight:800;font-size:9px;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--line);padding:8px 11px;margin-left:10px}
144144
.card .buy .acq:hover{background:var(--red);border-color:var(--red);color:var(--white)}
145+
.card .buy .stock{margin-top:5px;color:var(--dim);letter-spacing:.1em}
146+
.card .buy .stock.low{color:var(--red)}
147+
.card .buy .acq:disabled{color:var(--dim);border-color:var(--line);background:transparent;cursor:not-allowed}
148+
.card.sold-out .art,.card.sold-out .jp,.card.sold-out .name{opacity:.4}
149+
.card.sold-out .plotbtn{display:none}
145150

146151
/* PROCESS */
147152
.proc{background:var(--con2);padding:8px 0 60px;position:relative}
@@ -243,7 +248,11 @@ footer .grain{opacity:.3}
243248
.pd-edrow{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:18px}
244249
.pd-ed{color:var(--dim);letter-spacing:.1em}
245250
.pd-price{font-weight:900;font-size:1.6rem}
251+
.pd-stock{margin:-8px 0 18px;color:var(--dim);letter-spacing:.12em}
252+
.pd-stock.low{color:var(--red)}
246253
.pd-lore{color:var(--mute);font-weight:500;font-size:14px;line-height:1.7;margin-bottom:24px}
254+
.pd-acquire:disabled{background:transparent;color:var(--dim);border-color:var(--line);cursor:not-allowed}
255+
.pd-acquire:disabled:hover{background:transparent;color:var(--dim);border-color:var(--line)}
247256

248257
.pd-field{margin-bottom:20px}
249258
.pd-field-label{display:block;color:var(--dim);letter-spacing:.14em;margin-bottom:10px}

worker/README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ Wrangler prints a URL like `https://plotflow-checkout.<subdomain>.workers.dev`.
4848
Put that URL in `scripts/config.js``checkoutEndpoint`, commit, and push.
4949
(Send me the URL and I'll wire it up.)
5050

51+
### 6. Live stock counter (KV)
52+
53+
Each edition is a limited run of 25. The Worker tracks how many have sold in a
54+
**KV namespace** and exposes the remaining count at `GET /stock`
55+
(`{ "zaku": 22, ... }`). The webhook decrements it on each completed order; the
56+
shop grid and product pages show "X / 25 left" and flip to "Sold out" at zero.
57+
58+
Create the namespace once and paste its id into `wrangler.toml`:
59+
```bash
60+
cd worker
61+
wrangler kv namespace create STOCK # older wrangler: wrangler kv:namespace create STOCK
62+
# copy the printed id into the [[kv_namespaces]] block (replace REPLACE_WITH_KV_NAMESPACE_ID)
63+
wrangler deploy
64+
```
65+
66+
Notes:
67+
- Until the namespace exists and is bound, `/stock` returns full counts (25) and
68+
the frontend simply shows no badges — nothing breaks.
69+
- The sold tally is keyed `sold_<edition>`. To correct a count manually:
70+
`wrangler kv key put --binding=STOCK sold_zaku 3`
71+
- This also requires the Stripe **webhook** to be configured (Stripe Dashboard →
72+
Developers → Webhooks → add `…workers.dev/webhook`, event
73+
`checkout.session.completed`, then `wrangler secret put STRIPE_WEBHOOK_SECRET`).
74+
5175
## Testing
5276
With test-mode keys, use Stripe's test card `4242 4242 4242 4242`, any future
5377
expiry, any CVC/ZIP. A successful payment redirects to `/success.html`; the
@@ -59,7 +83,6 @@ and redeploy. No price changes needed — `CATALOG` is the same in both modes.
5983

6084
## Config (top of `src/index.js`)
6185
- `CATALOG` — edition name + price in cents. Keys must match `data/editions.js`.
62-
- `SHIPPING` — flat fee added on top (currently $9). Set `cents: 0` for free.
86+
- `SHIPPING_OPTIONS` — selectable rates at checkout (free U.S., flat $20 intl).
87+
- `EDITION_SIZE` — pieces per numbered edition (25); drives the `/stock` count.
6388
- `ALLOWED_ORIGINS`, `SHIP_COUNTRIES`, `SUCCESS_URL`, `CANCEL_URL`.
64-
- For numbered-edition inventory limits or order fulfillment hooks, add a
65-
Stripe webhook later.

worker/src/index.js

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const SHIP_COUNTRIES = ["US", "CA", "GB", "AU", "DE", "FR", "JP"];
4949
const NOTIFY_EMAIL = "devin@plotflow.io";
5050
const FROM_EMAIL = "orders@plotflow.io";
5151

52+
// Every edition is limited to 25 numbered pieces. Remaining = SIZE - sold,
53+
// where `sold` lives in the STOCK KV namespace (incremented by the webhook).
54+
const EDITION_SIZE = 25;
55+
5256
export default {
5357
async fetch(request, env) {
5458
const url = new URL(request.url);
@@ -58,6 +62,15 @@ export default {
5862
return handleWebhook(request, env);
5963
}
6064

65+
// ---- stock endpoint (remaining count per edition) ----
66+
if (url.pathname === "/stock") {
67+
const sOrigin = request.headers.get("Origin") || "";
68+
const sCors = corsHeaders(sOrigin, "GET, OPTIONS");
69+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: sCors });
70+
if (request.method === "GET") return handleStock(env, sCors);
71+
return json({ error: "Method not allowed" }, 405, sCors);
72+
}
73+
6174
// ---- checkout endpoint ----
6275
const origin = request.headers.get("Origin") || "";
6376
const cors = corsHeaders(origin);
@@ -74,6 +87,7 @@ export default {
7487
form.set("mode", "payment");
7588

7689
const orderSummary = [];
90+
const skus = [];
7791
let n = 0;
7892
for (const it of items) {
7993
const key = String((it && it.key) || "");
@@ -88,6 +102,7 @@ export default {
88102
form.set(`line_items[${n}][price_data][product_data][name]`, lineName);
89103
form.set(`line_items[${n}][quantity]`, String(qty));
90104
orderSummary.push(`${qty}× ${lineName}`);
105+
skus.push(`${key}:${qty}`);
91106
n++;
92107
}
93108
if (!n) return json({ error: "Cart is empty" }, 400, cors);
@@ -106,6 +121,8 @@ export default {
106121
});
107122

108123
form.set("metadata[editions]", orderSummary.join(", "));
124+
// Machine-readable list so the webhook can decrement stock by edition key.
125+
form.set("metadata[skus]", skus.join(","));
109126

110127
const resp = await fetch("https://api.stripe.com/v1/checkout/sessions", {
111128
method: "POST",
@@ -222,11 +239,42 @@ async function handleWebhook(request, env) {
222239
})
223240
});
224241
}
242+
243+
// Decrement remaining stock per purchased edition. metadata.skus looks
244+
// like "zaku:1,dom:2". Pen color does not affect stock (same edition run).
245+
if (env.STOCK && meta.skus) {
246+
for (const part of String(meta.skus).split(",")) {
247+
const [key, qtyStr] = part.split(":");
248+
const qty = parseInt(qtyStr, 10) || 0;
249+
if (CATALOG[key] && qty > 0) await bumpStock(env, key, qty);
250+
}
251+
}
225252
}
226253

227254
return new Response("ok", { status: 200 });
228255
}
229256

257+
// ---- Stock (KV-backed) ----
258+
259+
// Returns remaining count per edition: SIZE minus the recorded sold tally.
260+
async function handleStock(env, cors) {
261+
const out = {};
262+
for (const key of Object.keys(CATALOG)) {
263+
let sold = 0;
264+
if (env.STOCK) sold = parseInt(await env.STOCK.get("sold_" + key), 10) || 0;
265+
out[key] = Math.max(0, EDITION_SIZE - sold);
266+
}
267+
return json(out, 200, cors);
268+
}
269+
270+
// Increment the sold tally for an edition. KV is eventually consistent and this
271+
// read-modify-write is not atomic, but at edition-of-25 volumes the race window
272+
// is negligible; the worst case is a slightly stale public count.
273+
async function bumpStock(env, key, qty) {
274+
const cur = parseInt(await env.STOCK.get("sold_" + key), 10) || 0;
275+
await env.STOCK.put("sold_" + key, String(cur + qty));
276+
}
277+
230278
// ---- Stripe signature verification (HMAC-SHA256) ----
231279

232280
async function verifyStripeSignature(payload, header, secret) {
@@ -254,11 +302,11 @@ async function verifyStripeSignature(payload, header, secret) {
254302

255303
function esc(s) { return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;"); }
256304

257-
function corsHeaders(origin) {
305+
function corsHeaders(origin, methods) {
258306
const allow = ALLOWED_ORIGINS.indexOf(origin) !== -1 ? origin : ALLOWED_ORIGINS[0];
259307
return {
260308
"Access-Control-Allow-Origin": allow,
261-
"Access-Control-Allow-Methods": "POST, OPTIONS",
309+
"Access-Control-Allow-Methods": methods || "POST, OPTIONS",
262310
"Access-Control-Allow-Headers": "Content-Type",
263311
"Vary": "Origin"
264312
};

worker/wrangler.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ compatibility_date = "2024-11-01"
44

55
# STRIPE_SECRET_KEY is NOT stored here. Set it as an encrypted secret:
66
# wrangler secret put STRIPE_SECRET_KEY
7+
8+
# Stock counter store. Create the namespace once, then paste its id below:
9+
# wrangler kv namespace create STOCK
10+
# (older wrangler: `wrangler kv:namespace create STOCK`)
11+
[[kv_namespaces]]
12+
binding = "STOCK"
13+
id = "REPLACE_WITH_KV_NAMESPACE_ID"

0 commit comments

Comments
 (0)