[object Object]

← 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 &amp; 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 →