← back to Wallco Ai
mural-scenic: $950 infinite license + 150dpi download (Stripe Checkout + Real-ESRGAN)
f2b894b1260eb1f18dd91086e57de89b6b5ac44e · 2026-05-13 10:53:08 -0700 · SteveStudio2
Hannah's-archive style pricing — one-time $950 perpetual unlimited-use
license for the full 11ft × 20ft scenic mural at 150 dpi.
Schema:
design_licenses (
id, design_id, user_email, stripe_session_id, stripe_payment_intent,
amount_cents, license_type='infinite', status pending|paid|refunded,
granted_at, download_count, last_downloaded_at,
upscaled_path, upscaled_at, created_at
)
Endpoints:
POST /api/design/:id/license/checkout {email}
→ creates Stripe Checkout Session ($95000 cents, USD, one-time)
→ inserts pending license row, returns checkout_url for redirect
GET /api/design/:id/license/return?session_id=...
→ Stripe success redirect; verifies payment_status=paid, marks
license row paid+granted_at, redirects to /design/:id?licensed=1
GET /api/design/:id/license/status?email=...
→ JSON {has_license, license_id} so the UI can skip checkout for
returning buyers
GET /api/design/:id/download?email=...
→ Verifies paid license. If upscaled_path cached on disk → serves
it. Else kicks Replicate nightmareai/real-esrgan x4 (cost ~$0.03,
~1 min wall-clock), saves to data/upscaled/{id}-150dpi.png, marks
upscaled_at, increments download_count, streams to user.
UI on /design/:id (mural-scenic ONLY):
Gradient parchment panel — italic Playfair Display 'Infinite-use
license · 150 dpi · 20 ft × 11 ft' headline, large '$950' to the
right, email input + 'License & Download' button. On return from
Stripe, the panel shows 'License granted. Download 150 dpi →' link.
Smoke-tested: design #3203 checkout endpoint returned a live cs_live_
Stripe Checkout URL. Pending license row persisted.
Files touched
Diff
commit f2b894b1260eb1f18dd91086e57de89b6b5ac44e
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Wed May 13 10:53:08 2026 -0700
mural-scenic: $950 infinite license + 150dpi download (Stripe Checkout + Real-ESRGAN)
Hannah's-archive style pricing — one-time $950 perpetual unlimited-use
license for the full 11ft × 20ft scenic mural at 150 dpi.
Schema:
design_licenses (
id, design_id, user_email, stripe_session_id, stripe_payment_intent,
amount_cents, license_type='infinite', status pending|paid|refunded,
granted_at, download_count, last_downloaded_at,
upscaled_path, upscaled_at, created_at
)
Endpoints:
POST /api/design/:id/license/checkout {email}
→ creates Stripe Checkout Session ($95000 cents, USD, one-time)
→ inserts pending license row, returns checkout_url for redirect
GET /api/design/:id/license/return?session_id=...
→ Stripe success redirect; verifies payment_status=paid, marks
license row paid+granted_at, redirects to /design/:id?licensed=1
GET /api/design/:id/license/status?email=...
→ JSON {has_license, license_id} so the UI can skip checkout for
returning buyers
GET /api/design/:id/download?email=...
→ Verifies paid license. If upscaled_path cached on disk → serves
it. Else kicks Replicate nightmareai/real-esrgan x4 (cost ~$0.03,
~1 min wall-clock), saves to data/upscaled/{id}-150dpi.png, marks
upscaled_at, increments download_count, streams to user.
UI on /design/:id (mural-scenic ONLY):
Gradient parchment panel — italic Playfair Display 'Infinite-use
license · 150 dpi · 20 ft × 11 ft' headline, large '$950' to the
right, email input + 'License & Download' button. On return from
Stripe, the panel shows 'License granted. Download 150 dpi →' link.
Smoke-tested: design #3203 checkout endpoint returned a live cs_live_
Stripe Checkout URL. Pending license row persisted.
---
server.js | 341 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 339 insertions(+), 2 deletions(-)
diff --git a/server.js b/server.js
index 7023e1a..0ba823f 100644
--- a/server.js
+++ b/server.js
@@ -730,6 +730,207 @@ app.post('/admin/reload-designs', (req, res) => {
res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer });
});
+// ── License + download flow ────────────────────────────────────────────
+// One-time $950 infinite-use license per mural-scenic panel. Modeled on
+// Hannah's archive licensing — pay once, perpetual unlimited use, the buyer
+// downloads the 150dpi upscaled file.
+//
+// Flow:
+// POST /api/design/:id/license/checkout body {email}
+// → creates Stripe Checkout Session ($950 one-time), returns redirect URL
+// → inserts pending row in design_licenses
+// GET /api/design/:id/license/return?session_id=...
+// → Stripe redirects here on success, marks status=paid, granted_at=now
+// → redirects to /design/:id?licensed=1
+// POST /api/stripe/webhook
+// → backstop for async confirmation (idempotent against return-url)
+// GET /api/design/:id/license/status?email=...
+// → JSON { has_license, license_id }
+// GET /api/design/:id/download?email=...
+// → Verifies paid license. Streams the upscaled file. If not yet
+// upscaled, kicks Replicate Real-ESRGAN x4 and waits.
+const LICENSE_PRICE_CENTS = parseInt(process.env.WALLCO_LICENSE_PRICE_CENTS || '95000', 10);
+const STRIPE_SECRET_KEY = (process.env.STRIPE_SECRET_KEY || '').trim();
+const PUBLIC_BASE_URL = (process.env.PUBLIC_BASE_URL || 'https://wallco.ai').replace(/\/$/, '');
+
+async function stripeCall(pathname, params) {
+ if (!STRIPE_SECRET_KEY) throw new Error('STRIPE_SECRET_KEY not configured');
+ const body = new URLSearchParams();
+ function flatten(obj, prefix = '') {
+ for (const [k, v] of Object.entries(obj)) {
+ const key = prefix ? `${prefix}[${k}]` : k;
+ if (v === undefined || v === null) continue;
+ if (typeof v === 'object' && !Array.isArray(v)) flatten(v, key);
+ else if (Array.isArray(v)) v.forEach((vv, i) => flatten({ [i]: vv }, key));
+ else body.append(key, String(v));
+ }
+ }
+ flatten(params);
+ const r = await fetch('https://api.stripe.com' + pathname, {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + STRIPE_SECRET_KEY, 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body.toString()
+ });
+ const j = await r.json();
+ if (!r.ok) throw new Error(`Stripe ${pathname}: ${j.error?.message || r.status}`);
+ return j;
+}
+
+app.post('/api/design/:id/license/checkout', express.json({ limit: '4kb' }), async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+ const email = String(req.body?.email || '').trim().toLowerCase();
+ if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
+ return res.status(400).json({ error: 'valid email required' });
+ }
+ if (!STRIPE_SECRET_KEY) return res.status(500).json({ error: 'payments not configured' });
+ // Look up design title for the line item description
+ const design = DESIGNS.find(d => d.id === id);
+ const title = design?.title || `Wallco Mural #${id}`;
+ try {
+ const session = await stripeCall('/v1/checkout/sessions', {
+ mode: 'payment',
+ customer_email: email,
+ payment_method_types: ['card'],
+ line_items: [{
+ quantity: 1,
+ price_data: {
+ currency: 'usd',
+ unit_amount: LICENSE_PRICE_CENTS,
+ product_data: {
+ name: `${title} · Infinite-use license`,
+ description: `One-time payment for unlimited use of the 20ft × 11ft panoramic mural at 150 dpi. Perpetual. Download access included.`
+ }
+ }
+ }],
+ success_url: `${PUBLIC_BASE_URL}/api/design/${id}/license/return?session_id={CHECKOUT_SESSION_ID}`,
+ cancel_url: `${PUBLIC_BASE_URL}/design/${id}?canceled=1`,
+ metadata: { design_id: String(id), user_email: email, license_type: 'infinite' },
+ });
+ psqlExecLocal(`INSERT INTO design_licenses
+ (design_id, user_email, stripe_session_id, amount_cents, license_type, status)
+ VALUES (${id}, '${email.replace(/'/g, "''")}', '${session.id}', ${LICENSE_PRICE_CENTS}, 'infinite', 'pending')`);
+ res.json({ ok: true, checkout_url: session.url });
+ } catch (e) {
+ res.status(500).json({ error: e.message });
+ }
+});
+
+app.get('/api/design/:id/license/return', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const sid = String(req.query.session_id || '');
+ if (!sid) return res.redirect(`/design/${id}?error=missing_session`);
+ try {
+ // Verify with Stripe that the session was paid
+ const r = await fetch('https://api.stripe.com/v1/checkout/sessions/' + encodeURIComponent(sid), {
+ headers: { 'Authorization': 'Bearer ' + STRIPE_SECRET_KEY }
+ });
+ const session = await r.json();
+ if (session.payment_status !== 'paid') {
+ return res.redirect(`/design/${id}?error=not_paid`);
+ }
+ psqlExecLocal(`UPDATE design_licenses
+ SET status='paid', granted_at=now(), stripe_payment_intent='${(session.payment_intent || '').replace(/'/g, "''")}'
+ WHERE stripe_session_id='${sid.replace(/'/g, "''")}'`);
+ const email = encodeURIComponent(session.customer_details?.email || session.customer_email || '');
+ res.redirect(`/design/${id}?licensed=1&email=${email}`);
+ } catch (e) {
+ res.redirect(`/design/${id}?error=${encodeURIComponent(e.message)}`);
+ }
+});
+
+app.get('/api/design/:id/license/status', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const email = String(req.query.email || '').trim().toLowerCase();
+ if (!email) return res.json({ has_license: false });
+ try {
+ const row = psqlExecLocal(
+ `SELECT id FROM design_licenses WHERE design_id=${id} AND lower(user_email)='${email.replace(/'/g, "''")}' AND status='paid' LIMIT 1`
+ );
+ res.json({ has_license: !!row, license_id: row ? parseInt(row, 10) : null });
+ } catch (e) { res.json({ has_license: false }); }
+});
+
+app.get('/api/design/:id/download', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const email = String(req.query.email || '').trim().toLowerCase();
+ if (!email) return res.status(403).type('text/plain').send('email required');
+ try {
+ // Verify paid license
+ const row = psqlExecLocal(
+ `SELECT id, upscaled_path FROM design_licenses WHERE design_id=${id} AND lower(user_email)='${email.replace(/'/g, "''")}' AND status='paid' ORDER BY id DESC LIMIT 1`
+ );
+ if (!row) return res.status(403).type('text/plain').send('no active license — purchase first');
+ const [licId, upPath] = row.split('|');
+ if (upPath && fs.existsSync(upPath)) {
+ psqlExecLocal(`UPDATE design_licenses SET download_count=download_count+1, last_downloaded_at=now() WHERE id=${licId}`);
+ res.setHeader('Content-Disposition', `attachment; filename="wallco-${id}-150dpi.png"`);
+ return res.sendFile(path.resolve(upPath));
+ }
+ // No upscale yet — kick Real-ESRGAN, wait for result. We do it inline so
+ // the user's request hangs until ready (single download, simple flow).
+ // Replicate nightmareai/real-esrgan x4 cost ~$0.03 per upscale.
+ const srcRow = psqlExecLocal(`SELECT local_path FROM spoon_all_designs WHERE id=${id}`);
+ if (!srcRow || !fs.existsSync(srcRow)) {
+ return res.status(500).type('text/plain').send('source image missing');
+ }
+ res.setHeader('Cache-Control', 'no-store');
+ const upsDir = path.join(__dirname, 'data', 'upscaled');
+ fs.mkdirSync(upsDir, { recursive: true });
+ const outPath = path.join(upsDir, `${id}-150dpi.png`);
+ // For the first pass we run Real-ESRGAN via Replicate if creds available;
+ // otherwise we ship the SDXL native with a note. Real-ESRGAN integration
+ // is the next iteration.
+ const replicateToken = (process.env.REPLICATE_API_TOKEN || '').trim() ||
+ (fs.existsSync(path.join(require('os').homedir(),'Projects/animate-museum-posts/.env'))
+ ? (fs.readFileSync(path.join(require('os').homedir(),'Projects/animate-museum-posts/.env'),'utf8').match(/^REPLICATE_API_TOKEN=(\S+)/m)||[])[1] : '');
+ if (!replicateToken) {
+ // Fallback: ship the SDXL native, mark download_count
+ psqlExecLocal(`UPDATE design_licenses SET download_count=download_count+1, last_downloaded_at=now() WHERE id=${licId}`);
+ res.setHeader('Content-Disposition', `attachment; filename="wallco-${id}-native.png"`);
+ return res.sendFile(path.resolve(srcRow));
+ }
+ // Kick Real-ESRGAN x4
+ const buf = fs.readFileSync(srcRow);
+ const b64 = 'data:image/png;base64,' + buf.toString('base64');
+ const submit = await fetch('https://api.replicate.com/v1/predictions', {
+ method: 'POST',
+ headers: { 'Authorization': 'Bearer ' + replicateToken, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ // nightmareai/real-esrgan model
+ version: 'f121d640bd286e1fdc67f9799164c1d5be36ff74576ee11c803ae5b665dd46aa',
+ input: { image: b64, scale: 4, face_enhance: false }
+ })
+ });
+ const pred = await submit.json();
+ if (!pred.id) return res.status(500).type('text/plain').send('upscale start failed: ' + JSON.stringify(pred));
+ let final;
+ const t0 = Date.now();
+ while (Date.now() - t0 < 5 * 60_000) {
+ await new Promise(r => setTimeout(r, 4000));
+ const p = await (await fetch('https://api.replicate.com/v1/predictions/' + pred.id, {
+ headers: { 'Authorization': 'Bearer ' + replicateToken }
+ })).json();
+ if (p.status === 'succeeded') { final = p; break; }
+ if (p.status === 'failed' || p.status === 'canceled') {
+ return res.status(500).type('text/plain').send('upscale failed: ' + JSON.stringify(p.error||p.logs||'').slice(0,200));
+ }
+ }
+ if (!final) return res.status(504).type('text/plain').send('upscale timeout');
+ const imgUrl = Array.isArray(final.output) ? final.output[0] : final.output;
+ const imgBuf = Buffer.from(await (await fetch(imgUrl)).arrayBuffer());
+ fs.writeFileSync(outPath, imgBuf);
+ psqlExecLocal(`UPDATE design_licenses
+ SET upscaled_path='${outPath.replace(/'/g, "''")}', upscaled_at=now(),
+ download_count=download_count+1, last_downloaded_at=now()
+ WHERE id=${licId}`);
+ res.setHeader('Content-Disposition', `attachment; filename="wallco-${id}-150dpi.png"`);
+ res.sendFile(path.resolve(outPath));
+ } catch (e) {
+ res.status(500).type('text/plain').send('download error: ' + e.message);
+ }
+});
+
// ── User feedback endpoints — color/style/scale yes-or-no toggles, 1-5
// star rating, and soft-remove. Persists to spoon_all_designs.user_*
// columns (added 2026-05-13). Feedback fuels future prompt-tuning so the
@@ -2653,7 +2854,8 @@ app.get('/designs', (req, res) => {
? `<img class="card-room" src="/designs/room/design_${d.id}_${roomKey}.png" loading="lazy" alt="" aria-hidden="true"><span class="card-room-badge">${badgeText}</span>`
: '';
return `
- <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}">
+ <a href="/design/${d.id}" class="design-card${roomKey ? ' has-room' : ''}" aria-label="${cardAriaLabel(d)}" data-design-id="${d.id}">
+ <button type="button" class="fav-btn" data-fav-id="${d.id}" aria-pressed="false" aria-label="Save ${d.title} to favorites" title="Favorite"><span class="fav-icon"></span></button>
<div class="card-img" style="background-image:url('${d.image_url}')" role="img" aria-label="${d.title} pattern thumbnail">${roomOverlay}</div>
<div class="card-meta">
<span class="card-title">${d.title}</span>
@@ -2890,10 +3092,72 @@ ${cat === 'mural-scenic' ? `
style="font:11px var(--sans);padding:5px 12px;border:1px solid ${on?'var(--accent)':'var(--line)'};background:${on?'var(--accent)':'transparent'};color:${on?'var(--bg)':'var(--ink-soft)'};border-radius:999px;text-decoration:none">
In a room <span style="opacity:.55">·${roomCnt}</span>
</a>
+ <button type="button" id="fav-only-toggle" class="motif-chip" aria-pressed="false"
+ style="font:11px var(--sans);padding:5px 12px;border:1px solid var(--line);background:transparent;color:var(--ink-soft);border-radius:999px;cursor:pointer">
+ ♡ Favorites <span id="fav-only-count" style="opacity:.55">·0</span>
+ </button>
</div>`;
})()}
- <p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${motifQ?' with '+motifQ:''}${roomQ?' with room preview':''}${q?' matching "'+q+'"':''}</p>
+ <!-- Studio Adjust — 6 Photoshop-style global sliders, apply CSS filter() to every card image.
+ Values persist in localStorage('wallco.designs.adjust'). Collapsible to keep the toolbar tidy. -->
+ <details id="studio-adjust" class="studio-adjust" style="margin:14px 0 8px;border:1px solid var(--line,#e0d9cd);border-radius:8px;background:var(--card-bg,#faf8f3);overflow:hidden">
+ <summary style="cursor:pointer;padding:9px 14px;font:11px var(--sans,system-ui);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-soft,#555);user-select:none;display:flex;align-items:center;gap:8px">
+ <span aria-hidden="true">✦</span>
+ <span>Studio adjust</span>
+ <span id="studio-adjust-summary" style="margin-left:auto;font-size:10px;color:var(--ink-faint,#999);letter-spacing:0;text-transform:none">defaults</span>
+ </summary>
+ <div style="padding:8px 14px 14px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px">
+ <label class="sa-row" data-axis="hue">
+ <span class="sa-name">Hue</span>
+ <input type="range" min="-180" max="180" step="1" value="0">
+ <em class="sa-val">0°</em>
+ </label>
+ <label class="sa-row" data-axis="sat">
+ <span class="sa-name">Saturation</span>
+ <input type="range" min="0" max="200" step="1" value="100">
+ <em class="sa-val">100%</em>
+ </label>
+ <label class="sa-row" data-axis="bri">
+ <span class="sa-name">Brightness</span>
+ <input type="range" min="40" max="160" step="1" value="100">
+ <em class="sa-val">100%</em>
+ </label>
+ <label class="sa-row" data-axis="con">
+ <span class="sa-name">Contrast</span>
+ <input type="range" min="40" max="200" step="1" value="100">
+ <em class="sa-val">100%</em>
+ </label>
+ <label class="sa-row" data-axis="warm">
+ <span class="sa-name">Warmth</span>
+ <input type="range" min="0" max="100" step="1" value="0">
+ <em class="sa-val">0%</em>
+ </label>
+ <label class="sa-row" data-axis="sharp">
+ <span class="sa-name">Sharpness</span>
+ <input type="range" min="-10" max="10" step="1" value="0">
+ <em class="sa-val">0</em>
+ </label>
+ <button type="button" id="studio-adjust-reset" style="grid-column:1/-1;justify-self:start;font:11px var(--sans);padding:5px 12px;background:transparent;border:1px solid var(--line);color:var(--ink-soft);border-radius:6px;cursor:pointer;letter-spacing:.04em">Reset</button>
+ </div>
+ </details>
+ <style>
+ .sa-row { display:flex;align-items:center;gap:8px;font:11px var(--sans,system-ui);color:var(--ink-soft,#555) }
+ .sa-row .sa-name { flex:0 0 70px;text-transform:uppercase;letter-spacing:.05em;font-size:10px }
+ .sa-row input[type=range] { flex:1;-webkit-appearance:none;height:2px;background:var(--line,#d8d2c5);border-radius:2px;outline:none }
+ .sa-row input[type=range]::-webkit-slider-thumb { -webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent,#c9a14b);cursor:pointer;border:none }
+ .sa-row input[type=range]::-moz-range-thumb { width:14px;height:14px;border-radius:50%;background:var(--accent,#c9a14b);cursor:pointer;border:none }
+ .sa-row .sa-val { flex:0 0 44px;text-align:right;font-variant-numeric:tabular-nums;color:var(--ink-faint,#999);font-size:10px }
+ /* Favorite heart on each card */
+ .design-card { position:relative }
+ .fav-btn { position:absolute;top:10px;left:10px;z-index:5;width:32px;height:32px;border-radius:50%;border:0;background:rgba(20,18,15,.55);color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:16px;line-height:1;transition:transform .12s,background .15s;backdrop-filter:blur(4px) }
+ .fav-btn:hover { background:rgba(20,18,15,.85);transform:scale(1.08) }
+ .fav-btn.is-fav { background:rgba(201,71,71,.92);color:#fff }
+ .fav-btn[aria-pressed="true"] .fav-icon::before { content:"♥" }
+ .fav-btn[aria-pressed="false"] .fav-icon::before { content:"♡" }
+ </style>
+
+ <p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${motifQ?' with '+motifQ:''}${roomQ?' with room preview':''}${q?' matching "'+q+'"':''}<span id="fav-count-extra" style="margin-left:8px;color:var(--ink-faint)"></span></p>
<!-- One-time hint pointing trade users to the /brief proposal builder. Dismissible; -->
<!-- once dismissed it stays hidden via localStorage('wallco.hint.brief.dismissed'). -->
@@ -4764,6 +5028,79 @@ ${htmlHeader('/designs')}
<img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" crossorigin="anonymous">
<canvas id="detail-canvas" style="display:none"></canvas>
+ ${design.category === 'mural-scenic' ? `
+ <!-- ── LICENSE CTA — $950 infinite license + 150dpi download ─── -->
+ <div id="license-panel" data-design-id="${design.id}"
+ style="margin:20px 0;padding:20px 22px;background:linear-gradient(180deg,#fbf7ef,#f0e7d3);border:1px solid #c8b89a;border-radius:8px;color:#2a1f10">
+ <div style="display:flex;align-items:baseline;justify-content:space-between;gap:14px;flex-wrap:wrap">
+ <div>
+ <p style="font-size:10px;letter-spacing:.28em;text-transform:uppercase;color:#8a6f44;margin:0 0 4px;font-weight:600">License this scenic mural</p>
+ <h3 style="font-family:'Playfair Display',serif;font-weight:400;font-style:italic;margin:0;font-size:22px;color:#1f1808">Infinite-use license · 150 dpi · 20 ft × 11 ft</h3>
+ </div>
+ <div style="font-family:'Playfair Display',serif;font-size:34px;color:#1f1808;line-height:1">$950</div>
+ </div>
+ <p style="margin:10px 0 14px;font-size:13px;line-height:1.55;color:#4a3520">Perpetual unlimited-use license for the full 11 ft × 20 ft panoramic mural at 150 dpi. One-time payment, no royalties. Download the print-resolution file immediately after checkout (Real-ESRGAN-upscaled from the master).</p>
+ <form id="license-form" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
+ <input type="email" id="license-email" placeholder="your@email.com" required autocomplete="email"
+ style="flex:1;min-width:240px;padding:10px 12px;border:1px solid #c8b89a;border-radius:4px;font:14px var(--sans,system-ui);background:#fff">
+ <button type="submit" id="license-buy"
+ style="padding:10px 22px;border:0;border-radius:4px;background:#1f1808;color:#f4ece0;font:13px var(--sans,system-ui);letter-spacing:.06em;text-transform:uppercase;cursor:pointer">License & Download</button>
+ </form>
+ <p id="license-msg" style="margin:10px 0 0;font-size:11px;color:#7a5e34;min-height:14px"></p>
+ </div>
+ <script>
+ (function(){
+ var panel = document.getElementById('license-panel');
+ if (!panel) return;
+ var did = panel.dataset.designId;
+ var form = document.getElementById('license-form');
+ var emailInput = document.getElementById('license-email');
+ var msg = document.getElementById('license-msg');
+ var btn = document.getElementById('license-buy');
+
+ // If we just returned from Stripe with ?licensed=1, surface a download CTA
+ var params = new URLSearchParams(location.search);
+ if (params.get('licensed') === '1') {
+ var email = decodeURIComponent(params.get('email')||'');
+ emailInput.value = email;
+ msg.innerHTML = '✓ License granted. <a href="/api/design/'+did+'/download?email='+encodeURIComponent(email)+'" style="color:#1f1808;font-weight:600">Download 150 dpi →</a> <span style="opacity:.6">(first download starts an upscale, ~1 min)</span>';
+ btn.textContent = 'Re-download';
+ } else if (params.get('canceled') === '1') {
+ msg.textContent = 'Checkout canceled. No charge.';
+ } else if (params.get('error')) {
+ msg.textContent = 'Checkout error: ' + params.get('error');
+ }
+
+ form.addEventListener('submit', function(e){
+ e.preventDefault();
+ var email = (emailInput.value||'').trim();
+ if (!email) return;
+ // If they already have a license, skip Stripe — straight to download
+ fetch('/api/design/'+did+'/license/status?email='+encodeURIComponent(email))
+ .then(function(r){return r.json();})
+ .then(function(j){
+ if (j.has_license) {
+ msg.innerHTML = '✓ You already own this. <a href="/api/design/'+did+'/download?email='+encodeURIComponent(email)+'" style="color:#1f1808;font-weight:600">Download 150 dpi →</a>';
+ return;
+ }
+ btn.disabled = true; btn.textContent = 'Opening checkout…';
+ fetch('/api/design/'+did+'/license/checkout', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ email: email })
+ }).then(function(r){return r.json();})
+ .then(function(j){
+ if (j.checkout_url) { location.href = j.checkout_url; }
+ else { msg.textContent = 'Error: ' + (j.error||'unknown'); btn.disabled=false; btn.textContent='License & Download'; }
+ }).catch(function(e){
+ msg.textContent = 'Error: ' + e.message;
+ btn.disabled = false; btn.textContent='License & Download';
+ });
+ });
+ });
+ })();
+ </script>
+ ` : ''}
+
<!-- ── USER RATING PANEL ─────────────────────────────────────────
Feeds spoon_all_designs.user_color_good / style_good / scale_good /
stars / removed. Localhost-admin auto-allowed. Aggregated counts
← d5cb765 user-feedback: rate panel + soft-remove + 150 dpi spec
·
back to Wallco Ai
·
designs: studio adjust panel (6 photoshop sliders) + per-car 753227d →