← back to CelebritySignatures
Stripe Checkout for mural orders (Steve APPROVED, TEST-mode): zero-dep /api/mural-checkout creates a Stripe Checkout Session via REST (price = custom sqft × $10), /order-success verifies payment + marks the order paid + confirms; murals form gets 'Pay & order →' (with 'Request a quote' fallback). Reads STRIPE_TEST_SECRET_KEY ONLY — live key deliberately never touched (go-live = separate gate). Degrades gracefully w/o key (503). NOT deployed until a real test checkout verifies (TK-10181)
fc549c4eb0e456b92f3b885e2b22db605252d9b9 · 2026-08-04 07:24:40 -0700 · Steve Abrams
Files touched
M .gitignoreM public/murals.htmlM server.js
Diff
commit fc549c4eb0e456b92f3b885e2b22db605252d9b9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 4 07:24:40 2026 -0700
Stripe Checkout for mural orders (Steve APPROVED, TEST-mode): zero-dep /api/mural-checkout creates a Stripe Checkout Session via REST (price = custom sqft × $10), /order-success verifies payment + marks the order paid + confirms; murals form gets 'Pay & order →' (with 'Request a quote' fallback). Reads STRIPE_TEST_SECRET_KEY ONLY — live key deliberately never touched (go-live = separate gate). Degrades gracefully w/o key (503). NOT deployed until a real test checkout verifies (TK-10181)
---
.gitignore | 1 +
public/murals.html | 21 +++++++++++-
server.js | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 115 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 21df3b6..44c588f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,4 @@ data/uploads-private/
data/download-ledger.jsonl
data/admin-token.txt
data/leaderboard.json
+.env
diff --git a/public/murals.html b/public/murals.html
index 51edef1..3235a06 100644
--- a/public/murals.html
+++ b/public/murals.html
@@ -235,7 +235,10 @@
<label>Wall height (ft)<input name="wall_h" type="number" min="1" step="0.5" required></label>
<label class="wide">Notes (room, install date, anything)<textarea name="notes"></textarea></label>
<div class="summary" id="orderSummary"></div>
- <div style="grid-column:1/3"><button class="btn" type="submit">Request this mural</button></div>
+ <div style="grid-column:1/3;display:flex;gap:10px;flex-wrap:wrap">
+ <button class="btn" type="button" id="payBtn">Pay & order →</button>
+ <button class="btn ghost" type="submit">Request a quote instead</button>
+ </div>
<div class="msg" id="orderMsg"></div>
</form>
</section>
@@ -630,6 +633,22 @@ function wireOrder(){
else { msg.className='msg err'; msg.textContent = j.error || 'Something went wrong.'; }
}catch(err){ msg.className='msg err'; msg.textContent='Network error — please email steve@designerwallcoverings.com.'; }
});
+ // Pay & order → Stripe Checkout (custom mural size drives the price)
+ $('#payBtn').addEventListener('click', async () => {
+ const f = $('#orderForm'), msg = $('#orderMsg');
+ if(!f.name.value || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(f.email.value)){ msg.className='msg err'; msg.textContent='Enter your name and a valid email first.'; return; }
+ msg.className='msg'; msg.textContent='Opening secure checkout…';
+ const {w,h}=murDims();
+ try{
+ const r = await fetch('/api/mural-checkout',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
+ mural:cur?.slug, mural_title:cur?.title, name:f.name.value, email:f.email.value,
+ widthFt:w, heightFt:h, placement:{ from_left_ft:+pos.x.toFixed(2), off_floor_ft:+pos.y.toFixed(2) } })});
+ const j = await r.json();
+ if(j.ok && j.url){ location.href = j.url; }
+ else if(r.status===503){ msg.className='msg err'; msg.textContent='Card checkout is being set up — use “Request a quote” for now.'; }
+ else { msg.className='msg err'; msg.textContent = j.error || 'Could not start checkout.'; }
+ }catch(err){ msg.className='msg err'; msg.textContent='Network error starting checkout.'; }
+ });
}
boot();
diff --git a/server.js b/server.js
index 9bd3e07..c6a9511 100644
--- a/server.js
+++ b/server.js
@@ -7,6 +7,7 @@ import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
+import { readFileSync } from 'node:fs';
const ROOT = fileURLToPath(new URL('.', import.meta.url));
const PORT = process.env.PORT || 9920;
@@ -46,6 +47,17 @@ function readBodyBig(req) {
// which appends a commission entry to data/download-ledger.jsonl per download.
const UPLOADS_DIR = join(DATA, 'uploads-private');
const LB_HITS = new Map(); // per-IP leaderboard POST timestamps (rate limit)
+// Stripe TEST key ONLY (sk_test_…). We deliberately never read a live key here —
+// going live is a separate Steve-gated switch. Load from env or the project .env.
+function stripeTestKey() {
+ if (process.env.STRIPE_TEST_SECRET_KEY) return process.env.STRIPE_TEST_SECRET_KEY;
+ try {
+ const m = readFileSync(new URL('.env', import.meta.url), 'utf8').match(/^STRIPE_TEST_SECRET_KEY=(.+)$/m);
+ if (m) return m[1].trim().replace(/^["']|["']$/g, '');
+ } catch {}
+ return null;
+}
+const STRIPE_TEST_KEY = (() => { const k = stripeTestKey(); return k && k.startsWith('sk_test_') ? k : null; })();
// Merged signatures feed (used by /api/signatures, the crawlable /a/:qid pages,
// and the sitemap). The server-side occupation gate keeps the Artists category
@@ -240,6 +252,88 @@ createServer(async (req, res) => {
return sendJSON(res, 200, { ok: true, id });
}
+ // ===== STRIPE CHECKOUT (mural orders) — TEST MODE ONLY =====
+ // Zero-dep: we call Stripe's REST API directly. Reads STRIPE_TEST_SECRET_KEY
+ // (sk_test_…). The LIVE key is deliberately NOT read here — going live is a
+ // separate Steve-gated switch (STRIPE_LIVE_ENABLED + a live key). If no test
+ // key is configured, the endpoint reports so gracefully (the lead-form flow
+ // still works as the fallback).
+ if (path === '/api/mural-checkout' && M === 'POST') {
+ if (!STRIPE_TEST_KEY) return sendJSON(res, 503, { ok: false, error: 'payments not configured yet (awaiting Stripe test key)' });
+ const b = await readBody(req);
+ const name = String(b.name || '').slice(0, 120);
+ const email = String(b.email || '').trim();
+ if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return sendJSON(res, 400, { ok: false, error: 'valid email required' });
+ const w = Math.max(4, Math.min(40, +b.widthFt || 0));
+ const h = Math.max(3, Math.min(16, +b.heightFt || 0));
+ const perSqFt = 10; // matches murals-catalog pricePerSqFt
+ const amountCents = Math.round(w * h * perSqFt) * 100;
+ if (amountCents < 500) return sendJSON(res, 400, { ok: false, error: 'invalid mural size' });
+ const title = String(b.mural_title || 'Signature Mural').slice(0, 120);
+ const u = await currentUser(req);
+ // record a PENDING order first so we have it even before webhook/return
+ const list = await load('mural-orders.json', []);
+ const id = (list.at(-1)?.id || 1000) + 1;
+ const order = { id, at: new Date().toISOString(), account: u ? u.email : null, name, email,
+ mural: b.mural, mural_title: title, widthFt: w, heightFt: h, sqft: w * h,
+ amountUsd: amountCents / 100, placement: b.placement || null, status: 'pending_payment', mode: 'test' };
+ list.push(order); await store('mural-orders.json', list);
+ // create the Stripe Checkout Session via REST (form-encoded)
+ const params = new URLSearchParams();
+ params.set('mode', 'payment');
+ params.set('success_url', 'https://celebsignatures.com/order-success?sid={CHECKOUT_SESSION_ID}');
+ params.set('cancel_url', 'https://celebsignatures.com/murals');
+ params.set('customer_email', email);
+ params.set('client_reference_id', String(id));
+ params.set('line_items[0][quantity]', '1');
+ params.set('line_items[0][price_data][currency]', 'usd');
+ params.set('line_items[0][price_data][unit_amount]', String(amountCents));
+ params.set('line_items[0][price_data][product_data][name]', `${title} — ${h}×${w} ft signature mural`);
+ params.set('line_items[0][price_data][product_data][description]', `Custom ${w * h} sq ft made-to-order signature mural`);
+ params.set('metadata[order_id]', String(id));
+ try {
+ const sres = await fetch('https://api.stripe.com/v1/checkout/sessions', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${STRIPE_TEST_KEY}`, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params.toString(),
+ });
+ const sj = await sres.json();
+ if (!sres.ok) { console.error('stripe error', sj.error?.message); return sendJSON(res, 502, { ok: false, error: sj.error?.message || 'stripe error' }); }
+ order.stripeSession = sj.id; await store('mural-orders.json', list);
+ return sendJSON(res, 200, { ok: true, url: sj.url, orderId: id });
+ } catch (e) { return sendJSON(res, 502, { ok: false, error: 'payment gateway unreachable' }); }
+ }
+ if (path === '/order-success' && M === 'GET') {
+ const sid = url.searchParams.get('sid') || '';
+ let paid = false, order = null;
+ if (STRIPE_TEST_KEY && /^cs_test_[A-Za-z0-9]+$/.test(sid)) {
+ try {
+ const s = await (await fetch(`https://api.stripe.com/v1/checkout/sessions/${sid}`, { headers: { Authorization: `Bearer ${STRIPE_TEST_KEY}` } })).json();
+ if (s.payment_status === 'paid') {
+ paid = true;
+ const list = await load('mural-orders.json', []);
+ order = list.find(o => String(o.id) === String(s.metadata?.order_id || s.client_reference_id));
+ if (order && order.status !== 'paid') { order.status = 'paid'; order.paidAt = new Date().toISOString(); await store('mural-orders.json', list); }
+ }
+ } catch {}
+ }
+ const body = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${paid ? 'Order confirmed' : 'Order'} — Celebrity Signatures</title><link rel="icon" href="/assets/favicon.png">
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600&display=swap" rel="stylesheet">
+<style>body{margin:0;font:16px/1.6 -apple-system,sans-serif;background:#f7f5f0;color:#1a1a1a;text-align:center}
+.w{max-width:520px;margin:0 auto;padding:70px 24px}h1{font:600 30px 'Playfair Display',serif}
+.ok{font-size:52px}a.btn{display:inline-block;margin-top:22px;padding:12px 26px;border-radius:999px;background:#1a1a1a;color:#fff;text-decoration:none}
+.sub{color:#6b6b6b}</style></head><body><div class="w">
+${paid ? `<div class="ok">✓</div><h1>Order confirmed</h1>
+<p>Thank you${order ? ', ' + esc(order.name) : ''} — your ${order ? esc(order.heightFt + '×' + order.widthFt + ' ft') : ''} signature mural is ordered${order ? ' (order #' + order.id + ', $' + order.amountUsd.toLocaleString() + ')' : ''}. We'll email your 150-DPI proof and shipping details.</p>
+<p class="sub">A receipt is on its way from Stripe.</p>`
+: `<h1>Order not completed</h1><p class="sub">No payment was captured. You can try again from the wall studio.</p>`}
+<a class="btn" href="/murals">${paid ? 'Design another wall' : 'Back to the studio'}</a>
+<p style="margin-top:20px"><a href="/">← Celebrity Signatures</a></p></div></body></html>`;
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(body); return;
+ }
+
// ===== CELEBRITY SIGNATURE UPLOADS (owned + commission-tracked) =====
// A celebrity uploads THEIR OWN signature as a digital file. The file is
// tied to their account, stored privately, and every download is logged to
← 5d6a79e yoloforever PIVOT (Cody unanimous KILL-the-museum-loop): sto
·
back to CelebritySignatures
·
GA4 events: wire named conversion/engagement events (mural_o 3b155de →