[object Object]

← back to Whatsmystyle

tick 5: outfit-builder + /about + Stripe FC scaffold + resale debate

0908d9380e72da9374f3e9a176dafd7705dfb7dc · 2026-05-11 20:48:42 -0700 · Steve Abrams

Outfit-builder ('Style this piece'):
- POST /api/outfits { closet_id | item_id } → 3 complete outfits drawn from
  catalog. Slot rules per anchor category (top wants bottom/shoes/outerwear/bag,
  dress wants shoes/bag/outerwear, etc). Picks the highest-similarity item
  per slot, then deduplicates across the 3 looks so they're distinct rather
  than near-clones.
- POST /api/outfits/:id/pick { index } → updates taste vector with the
  picked outfit's item embeddings (weight 0.03 per item, gentler than duel
  picks since outfits are 3-5 items at once).
- New table outfit_picks. UI screen 'Style this piece' renders closet,
  click → 3 look cards with all slots → 'Wear this' button picks.

/about pitch page:
- Plain-language founder pitch — no AI buzzwords per 2026-05-12 standing
  rule. Hero + 3 feature blocks + privacy summary + waitlist form.
- Waitlist persists to data/waitlist.csv + sqlite waitlist table.

Stripe FC scaffold (gated on STRIPE_FC_SECRET_KEY):
- scripts/stripe-fc.js — Link session create, transaction list helper,
  clothing-merchant fingerprint domains. Permissions = ['transactions']
  ONLY. Never balances. Never payment_method.
- GET /api/stripe-fc/status — capability probe
- POST /api/stripe-fc/session — creates Link session, returns client_secret
- GET  /api/stripe-fc/return  — success bounce page
- POST /api/stripe-fc/webhook — placeholder, signature verify TODO

Resale-credit debate (live local panel):
- Topic: should we help users resell closet items?
- Verdict: HOLD. Risks undermining sustainability without guardrails.
  Implement circularity checks + privacy limits + repair/recycle nudges
  BEFORE building. Logged in debates table.

Smoke-tested live: outfit build returned 3 distinct looks with real
similarity scores; pick endpoint updated taste vector; waitlist insert ok;
stripe-fc status reports unavailable (no key set) as designed.

Files touched

Diff

commit 0908d9380e72da9374f3e9a176dafd7705dfb7dc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 11 20:48:42 2026 -0700

    tick 5: outfit-builder + /about + Stripe FC scaffold + resale debate
    
    Outfit-builder ('Style this piece'):
    - POST /api/outfits { closet_id | item_id } → 3 complete outfits drawn from
      catalog. Slot rules per anchor category (top wants bottom/shoes/outerwear/bag,
      dress wants shoes/bag/outerwear, etc). Picks the highest-similarity item
      per slot, then deduplicates across the 3 looks so they're distinct rather
      than near-clones.
    - POST /api/outfits/:id/pick { index } → updates taste vector with the
      picked outfit's item embeddings (weight 0.03 per item, gentler than duel
      picks since outfits are 3-5 items at once).
    - New table outfit_picks. UI screen 'Style this piece' renders closet,
      click → 3 look cards with all slots → 'Wear this' button picks.
    
    /about pitch page:
    - Plain-language founder pitch — no AI buzzwords per 2026-05-12 standing
      rule. Hero + 3 feature blocks + privacy summary + waitlist form.
    - Waitlist persists to data/waitlist.csv + sqlite waitlist table.
    
    Stripe FC scaffold (gated on STRIPE_FC_SECRET_KEY):
    - scripts/stripe-fc.js — Link session create, transaction list helper,
      clothing-merchant fingerprint domains. Permissions = ['transactions']
      ONLY. Never balances. Never payment_method.
    - GET /api/stripe-fc/status — capability probe
    - POST /api/stripe-fc/session — creates Link session, returns client_secret
    - GET  /api/stripe-fc/return  — success bounce page
    - POST /api/stripe-fc/webhook — placeholder, signature verify TODO
    
    Resale-credit debate (live local panel):
    - Topic: should we help users resell closet items?
    - Verdict: HOLD. Risks undermining sustainability without guardrails.
      Implement circularity checks + privacy limits + repair/recycle nudges
      BEFORE building. Logged in debates table.
    
    Smoke-tested live: outfit build returned 3 distinct looks with real
    similarity scores; pick endpoint updated taste vector; waitlist insert ok;
    stripe-fc status reports unavailable (no key set) as designed.
---
 data/waitlist.csv        |   2 +
 data/whatsmystyle.db-shm | Bin 32768 -> 32768 bytes
 data/whatsmystyle.db-wal | Bin 436752 -> 527392 bytes
 public/css/app.css       |  28 +++++++
 public/index.html        |  46 ++++++++++++
 public/js/app.js         |  60 +++++++++++++++
 scripts/stripe-fc.js     |  82 +++++++++++++++++++++
 server.js                | 187 +++++++++++++++++++++++++++++++++++++++++++++++
 8 files changed, 405 insertions(+)

diff --git a/data/waitlist.csv b/data/waitlist.csv
new file mode 100644
index 0000000..7432645
--- /dev/null
+++ b/data/waitlist.csv
@@ -0,0 +1,2 @@
+email,source,created_at
+steve@designerwallcoverings.com,smoke,2026-05-12T03:45:26.941Z
diff --git a/data/whatsmystyle.db-shm b/data/whatsmystyle.db-shm
index be9eb3d..8ef4809 100644
Binary files a/data/whatsmystyle.db-shm and b/data/whatsmystyle.db-shm differ
diff --git a/data/whatsmystyle.db-wal b/data/whatsmystyle.db-wal
index 1b8fda5..6a8e3aa 100644
Binary files a/data/whatsmystyle.db-wal and b/data/whatsmystyle.db-wal differ
diff --git a/public/css/app.css b/public/css/app.css
index bf5f147..b724aac 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -172,6 +172,34 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 .tryon-result img { max-width: 100%; border-radius: 14px; box-shadow: var(--shadow); }
 .tryon-pending { padding: 14px; background: var(--paper-2); border-radius: 14px; color: #555; }
 
+/* /about pitch page */
+.pitch-hero { padding: 60px 0 30px; text-align: center; }
+.pitch-hero h1 { font-family: var(--font-display); font-size: clamp(48px, 8vw, 88px); font-weight: 600; margin: 0 0 16px; letter-spacing: -0.02em; }
+.pitch-sub { font-size: 20px; line-height: 1.5; max-width: 720px; margin: 0 auto; color: var(--ink-2); }
+.pitch-feats { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 22px; margin: 40px 0; }
+.pitch-feats article { padding: 26px; border: 1px solid var(--line); border-radius: 22px; background: var(--paper); }
+.pitch-feats h3 { font-family: var(--font-display); font-size: 26px; font-weight: 600; margin: 0 0 12px; }
+.pitch-privacy { padding: 26px; border-left: 4px solid var(--accent); background: var(--paper-2); border-radius: 0 18px 18px 0; margin: 30px 0; }
+.pitch-privacy h3 { margin: 0 0 8px; }
+.pitch-privacy a { color: var(--accent); font-weight: 600; }
+.pitch-waitlist { text-align: center; padding: 40px 0; }
+.pitch-waitlist h3 { font-family: var(--font-display); font-size: 32px; font-weight: 600; }
+.pitch-waitlist form { display: inline-flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
+.pitch-waitlist input { padding: 14px 18px; border: 1.5px solid var(--ink); border-radius: 999px; font-size: 16px; min-width: 260px; }
+.pitch-waitlist button { padding: 14px 24px; border: 0; border-radius: 999px; background: var(--ink); color: var(--paper); font-size: 16px; font-weight: 600; cursor: pointer; }
+
+/* outfit-builder */
+.outfit-card { padding: 18px; border: 1px solid var(--line); border-radius: 22px; background: var(--paper); margin-bottom: 16px; }
+.outfit-card h4 { margin: 0 0 12px; font-family: var(--font-display); font-size: 22px; }
+.outfit-slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
+.outfit-slots .slot { padding: 8px; border-radius: 14px; background: var(--paper-2); text-align: center; }
+.outfit-slots .slot img { width: 100%; aspect-ratio: 3/4; object-fit: cover; border-radius: 10px; background: white; }
+.outfit-slots .slot .cat { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #777; margin-top: 6px; }
+.outfit-slots .slot .name { font-size: 12px; font-weight: 600; }
+.outfit-card .pick-this { width: 100%; padding: 12px; margin-top: 12px; border: 0; background: var(--ink); color: var(--paper); border-radius: 999px; font-weight: 600; cursor: pointer; }
+.outfit-card.picked { border-color: var(--ok); border-width: 2px; }
+.outfit-card.picked .pick-this { background: var(--ok); }
+
 /* skip link — keyboard users land here first */
 .skip-link {
   position: absolute; left: -9999px; top: 12px;
diff --git a/public/index.html b/public/index.html
index 34e5af5..398ce2e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -19,12 +19,14 @@
 <nav id="menu" class="menu" aria-label="Main menu" hidden>
   <a href="#" data-screen="home">Duel</a>
   <a href="#" data-screen="closet">My Closet</a>
+  <a href="#" data-screen="stylepiece">Style this Piece</a>
   <a href="#" data-screen="recs">For You</a>
   <a href="#" data-screen="avatar">My Avatar</a>
   <a href="#" data-screen="timetravel">Time-Travel Wardrobe</a>
   <a href="#" data-screen="tryons">My Try-Ons</a>
   <a href="#" data-screen="stores">Stores Near Me</a>
   <a href="#" data-screen="connect">Connections</a>
+  <a href="#" data-screen="about">About</a>
   <a href="#" data-screen="privacy">Privacy</a>
   <a href="#" data-screen="onboard">Re-onboard</a>
 </nav>
@@ -131,6 +133,50 @@
     <div id="avatar-preview"></div>
   </section>
 
+  <!-- Style This Piece (outfit-builder) -->
+  <section id="stylepiece" class="screen" hidden>
+    <h2>Style this piece</h2>
+    <p class="muted">Pick something from your closet. We'll build three complete looks around it.</p>
+    <div id="sp-anchor"></div>
+    <div id="sp-closet" class="grid"></div>
+    <div id="sp-outfits"></div>
+  </section>
+
+  <!-- About / Pitch -->
+  <section id="about" class="screen" hidden>
+    <div class="pitch-hero">
+      <h1>Better A — or B?</h1>
+      <p class="pitch-sub">An honest stylist that learns from a thousand tiny choices, not a single survey. Then dresses you on you — in past photos, today's mirror, tomorrow's plans.</p>
+    </div>
+    <div class="pitch-feats">
+      <article>
+        <h3>The optometrist for your closet</h3>
+        <p>We don't ask what you like. We show you two outfits and you tap one. After ~30 taps we know your taste. After 100, we can predict any new piece before you've seen it.</p>
+      </article>
+      <article>
+        <h3>Your digital twin, dressed</h3>
+        <p>Upload a few photos. We build a private twin that lives in your account. Every piece in the catalog can be rendered on it — you see how it'll actually look on you, not on a model.</p>
+      </article>
+      <article>
+        <h3>Time-Travel Wardrobe</h3>
+        <p>"I wish I'd worn the navy gown to my sister's wedding." Pick the dress from your closet, drop the photo, tap the occasion. The wedding party stays. The venue stays. Only your outfit changes.</p>
+      </article>
+    </div>
+    <div class="pitch-privacy">
+      <h3>Plainly: how we treat your data</h3>
+      <p>Bank receipts read-only. Email read-only. Photos stay yours. Your face is never sold, never trained on. One tap deletes everything.</p>
+      <a href="#" data-screen="privacy">Full privacy →</a>
+    </div>
+    <div class="pitch-waitlist">
+      <h3>Want in early?</h3>
+      <form id="waitlist-form">
+        <input type="email" name="email" placeholder="you@example.com" required aria-label="Email for waitlist">
+        <button type="submit">Join the waitlist</button>
+      </form>
+      <p class="muted" id="waitlist-msg"></p>
+    </div>
+  </section>
+
   <!-- Time-Travel Wardrobe (closet → old photo) -->
   <section id="timetravel" class="screen" hidden>
     <h2>Time-Travel Wardrobe</h2>
diff --git a/public/js/app.js b/public/js/app.js
index 2a7fb50..472a671 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -499,11 +499,71 @@ paintCard = function(side, item) {
   card.appendChild(b);
 };
 
+// ---------- Style this Piece (outfit-builder) ----------------------------
+async function loadStylePiece() {
+  const r = await fetch('/api/closet').then(r => r.json());
+  const c = $('#sp-closet');
+  $('#sp-outfits').innerHTML = '';
+  c.innerHTML = '';
+  if (!r.items?.length) {
+    c.innerHTML = '<p class="muted">Upload a closet photo first.</p>';
+    return;
+  }
+  r.items.forEach(it => {
+    const b = document.createElement('button');
+    b.className = 'card';
+    b.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
+    b.onclick = () => buildOutfits(it);
+    c.appendChild(b);
+  });
+}
+async function buildOutfits(closetItem) {
+  $('#sp-anchor').innerHTML = `<p class="muted">Building outfits around your <strong>${closetItem.category || 'piece'}</strong>…</p>`;
+  $('#sp-outfits').innerHTML = '<div class="tryon-pending">Thinking…</div>';
+  const r = await fetch('/api/outfits', {
+    method: 'POST',
+    headers: { 'content-type': 'application/json' },
+    body: JSON.stringify({ closet_id: closetItem.id })
+  }).then(r => r.json());
+  if (!r.outfits?.length) { $('#sp-outfits').innerHTML = '<p class="muted">No outfits could be built — catalog too thin.</p>'; return; }
+  const grid = document.createElement('div');
+  r.outfits.forEach((o, i) => {
+    const card = document.createElement('div');
+    card.className = 'outfit-card';
+    const slots = Object.entries(o.slots).map(([cat, s]) => `
+      <div class="slot">
+        <img src="${s.image_url}" alt="${s.title}" loading="lazy">
+        <div class="cat">${cat}</div>
+        <div class="name">${s.brand || ''}</div>
+      </div>`).join('');
+    card.innerHTML = `<h4>Look ${i+1}</h4><div class="outfit-slots">${slots}</div><button class="pick-this">Wear this</button>`;
+    card.querySelector('.pick-this').onclick = async () => {
+      await fetch(`/api/outfits/${r.pick_id}/pick`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ index: i })});
+      grid.querySelectorAll('.outfit-card').forEach(x => x.classList.remove('picked'));
+      card.classList.add('picked');
+      card.querySelector('.pick-this').textContent = '✓ Picked';
+    };
+    grid.appendChild(card);
+  });
+  $('#sp-outfits').innerHTML = '';
+  $('#sp-outfits').appendChild(grid);
+}
+
+// ---------- Waitlist (on /about) ------------------------------------------
+$('#waitlist-form').addEventListener('submit', async e => {
+  e.preventDefault();
+  const email = e.target.email.value;
+  const r = await fetch('/api/waitlist', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, source: 'about-page' }) }).then(r => r.json());
+  $('#waitlist-msg').textContent = r.ok ? "You're on the list. We'll be in touch." : (r.error || 'Try again.');
+  if (r.ok) e.target.email.value = '';
+});
+
 // ---------- Router ---------------------------------------------------------
 function route(name) {
   show(name);
   if (name === 'home') loadDuel();
   if (name === 'closet') loadCloset();
+  if (name === 'stylepiece') loadStylePiece();
   if (name === 'recs') loadRecs();
   if (name === 'avatar') pollAvatar();
   if (name === 'timetravel') loadTimeTravel();
diff --git a/scripts/stripe-fc.js b/scripts/stripe-fc.js
new file mode 100644
index 0000000..a6f10f1
--- /dev/null
+++ b/scripts/stripe-fc.js
@@ -0,0 +1,82 @@
+/**
+ * Stripe Financial Connections — read-only bank-receipt scaffold.
+ *
+ * Three routes, all gated behind STRIPE_FC_SECRET_KEY:
+ *
+ *   POST /api/stripe-fc/session   create a Link session, returns client_secret
+ *                                 → frontend opens Stripe's hosted Link flow
+ *   GET  /api/stripe-fc/return    Stripe redirects here after the user finishes
+ *                                 → exchange + persist the financial-connection account ids
+ *   POST /api/stripe-fc/webhook   Stripe POSTs transaction.created / .updated events
+ *                                 → match clothing-merchant rows against our brand list,
+ *                                   insert into closet with acquired_via='bank'
+ *
+ * NEVER persist Stripe secret keys in this file. They live in .env only.
+ * NEVER move money. NEVER read balance. Permissions = ['transactions'] only.
+ */
+const fetch = require('node-fetch');
+
+const KEY = process.env.STRIPE_FC_SECRET_KEY;
+const BASE = 'https://api.stripe.com/v1';
+
+const PERMISSIONS = ['transactions']; // intentionally narrow — never 'balances', never 'payment_method'
+
+function available() { return !!KEY; }
+
+async function stripePost(path, formBody) {
+  const r = await fetch(`${BASE}${path}`, {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${KEY}`,
+      'content-type': 'application/x-www-form-urlencoded',
+    },
+    body: typeof formBody === 'string' ? formBody : new URLSearchParams(formBody).toString(),
+  });
+  if (!r.ok) throw new Error(`Stripe ${path} ${r.status}: ${await r.text()}`);
+  return r.json();
+}
+
+async function createLinkSession({ accountHolderName, returnUrl }) {
+  if (!KEY) throw new Error('STRIPE_FC_SECRET_KEY not set');
+  // Stripe Financial Connections session — narrow permissions
+  const body = new URLSearchParams();
+  body.append('account_holder[type]', 'individual');
+  body.append('account_holder[customer]', '');  // TODO: link to a Stripe customer once created
+  PERMISSIONS.forEach(p => body.append('permissions[]', p));
+  if (returnUrl) body.append('return_url', returnUrl);
+  return stripePost('/financial_connections/sessions', body);
+}
+
+async function listTransactionsForAccount(accountId, { limit = 100, cursor } = {}) {
+  if (!KEY) throw new Error('STRIPE_FC_SECRET_KEY not set');
+  const qs = new URLSearchParams();
+  qs.append('account', accountId);
+  qs.append('limit', String(limit));
+  if (cursor) qs.append('starting_after', cursor);
+  const r = await fetch(`${BASE}/financial_connections/transactions?${qs}`, {
+    headers: { Authorization: `Bearer ${KEY}` },
+  });
+  if (!r.ok) throw new Error(`Stripe transactions list ${r.status}: ${await r.text()}`);
+  return r.json();
+}
+
+// Clothing-merchant fingerprinting — used by the webhook to decide which
+// transactions become closet rows. Domain-based; expand as we learn vendors.
+const CLOTHING_DOMAINS = [
+  'nordstrom', 'net-a-porter', 'matchesfashion', 'mrporter', 'farfetch',
+  'ssense', 'shopbop', 'revolve', 'saks', 'neimanmarcus', 'bergdorf',
+  'therealreal', 'poshmark', 'vestiairecollective', 'ebay', 'depop',
+  'cos.com', 'arket', 'uniqlo', 'zara', 'hm.com', 'madewell', 'jcrew',
+  'everlane', 'reformation', 'patagonia', 'levis', 'agolde',
+  'totokaelo', 'shop.bottegaveneta', 'gucci', 'prada', 'fendi',
+];
+
+function isClothingTransaction(tx) {
+  const desc = (tx.description || '').toLowerCase();
+  return CLOTHING_DOMAINS.some(d => desc.includes(d));
+}
+
+module.exports = {
+  available, createLinkSession, listTransactionsForAccount,
+  isClothingTransaction, PERMISSIONS,
+};
diff --git a/server.js b/server.js
index f96a646..0bd7f96 100644
--- a/server.js
+++ b/server.js
@@ -149,6 +149,26 @@ safeAddColumn('tryon_jobs', 'occasion TEXT');
 
 db.exec(`
 
+CREATE TABLE IF NOT EXISTS outfit_picks (
+  id INTEGER PRIMARY KEY,
+  user_id INTEGER NOT NULL,
+  anchor_kind TEXT NOT NULL,      -- 'closet' | 'catalog'
+  anchor_id INTEGER NOT NULL,     -- closet.id or items.id
+  outfits TEXT NOT NULL,          -- JSON: [{slots:{top,bottom,shoes,bag,outerwear}, score}, ...]
+  picked_index INTEGER,           -- which of the 3 the user chose
+  created_at TEXT DEFAULT (datetime('now')),
+  picked_at TEXT
+);
+
+CREATE INDEX IF NOT EXISTS outfit_user_idx ON outfit_picks(user_id);
+
+CREATE TABLE IF NOT EXISTS waitlist (
+  id INTEGER PRIMARY KEY,
+  email TEXT UNIQUE,
+  source TEXT,
+  created_at TEXT DEFAULT (datetime('now'))
+);
+
 CREATE TABLE IF NOT EXISTS debates (
   id INTEGER PRIMARY KEY,
   topic TEXT NOT NULL,
@@ -521,6 +541,172 @@ app.get('/api/debates', (req, res) => {
   res.json({ debates: rows });
 });
 
+// ---- outfit-builder -------------------------------------------------------
+// Given an anchor (closet item the user owns, OR a catalog item they liked),
+// build 3 complete outfits drawn from the catalog. Slots filled by picking
+// the highest-taste-similarity item per missing category, with category
+// complementarity (a top wants a bottom + shoes; a dress wants shoes + bag).
+const SLOT_RULES = {
+  top:       ['bottom', 'shoes', 'outerwear', 'bag'],
+  bottom:    ['top',    'shoes', 'outerwear', 'bag'],
+  dress:     ['shoes',  'bag',   'outerwear'],
+  outerwear: ['top',    'bottom','shoes',     'bag'],
+  shoes:     ['top',    'bottom','bag'],
+  bag:       ['top',    'bottom','shoes'],
+  accessory: ['top',    'bottom','shoes'],
+};
+
+function dot(a, b) { let s = 0; for (let i = 0; i < a.length && i < b.length; i++) s += a[i] * b[i]; return s; }
+
+function buildOutfitsFor(db, u, anchorEmbed, anchorCategory) {
+  const need = SLOT_RULES[anchorCategory] || ['top', 'bottom', 'shoes'];
+  const candidatesByCat = {};
+  const rows = db.prepare('SELECT id, title, brand, category, color, image_url, product_url, price_cents, embedding FROM items WHERE embedding IS NOT NULL').all();
+  for (const r of rows) {
+    if (!candidatesByCat[r.category]) candidatesByCat[r.category] = [];
+    try {
+      const e = JSON.parse(r.embedding);
+      r._sim = dot(anchorEmbed, e);
+      candidatesByCat[r.category].push(r);
+    } catch {}
+  }
+  for (const cat of Object.keys(candidatesByCat)) {
+    candidatesByCat[cat].sort((a, b) => b._sim - a._sim);
+  }
+  // build 3 outfits: pick top-3 of the primary slot, then for each pick the
+  // top sim-and-also-uncoordinated picks for the remaining slots (so the
+  // 3 outfits are distinct rather than near-clones).
+  const used = new Set();
+  const outfits = [];
+  for (let k = 0; k < 3; k++) {
+    const slots = {};
+    let score = 0;
+    for (const cat of need) {
+      const pool = candidatesByCat[cat] || [];
+      const pick = pool.find(it => !used.has(`${cat}:${it.id}`));
+      if (pick) {
+        slots[cat] = { id: pick.id, title: pick.title, brand: pick.brand, image_url: pick.image_url, product_url: pick.product_url, price_cents: pick.price_cents, sim: Number(pick._sim.toFixed(3)) };
+        used.add(`${cat}:${pick.id}`);
+        score += pick._sim;
+      }
+    }
+    if (Object.keys(slots).length) outfits.push({ slots, score: Number(score.toFixed(3)) });
+  }
+  return outfits;
+}
+
+app.post('/api/outfits', (req, res) => {
+  const u = currentUser(req);
+  const { closet_id, item_id } = req.body || {};
+  if (!closet_id && !item_id) return res.status(400).json({ error: 'closet_id or item_id required' });
+
+  let anchorEmbed = null, anchorCategory = 'top';
+  if (closet_id) {
+    const c = db.prepare('SELECT category, color, vendor_guess, pattern FROM closet WHERE id=? AND user_id=?').get(closet_id, u.id);
+    if (!c) return res.status(404).json({ error: 'closet item missing' });
+    anchorCategory = c.category || 'top';
+    // No closet embedding yet — fall back to the user's taste vector
+    try { anchorEmbed = JSON.parse(u.taste_vec || '[]'); } catch { anchorEmbed = Array(32).fill(0); }
+    if (!Array.isArray(anchorEmbed) || anchorEmbed.length !== 32) anchorEmbed = Array(32).fill(0);
+  } else {
+    const it = db.prepare('SELECT category, embedding FROM items WHERE id=?').get(item_id);
+    if (!it) return res.status(404).json({ error: 'item missing' });
+    anchorCategory = it.category || 'top';
+    try { anchorEmbed = JSON.parse(it.embedding || '[]'); } catch { anchorEmbed = Array(32).fill(0); }
+  }
+
+  const outfits = buildOutfitsFor(db, u, anchorEmbed, anchorCategory);
+  const { lastInsertRowid } = db.prepare(
+    'INSERT INTO outfit_picks (user_id, anchor_kind, anchor_id, outfits) VALUES (?, ?, ?, ?)'
+  ).run(u.id, closet_id ? 'closet' : 'catalog', closet_id || item_id, JSON.stringify(outfits));
+
+  res.json({ ok: true, pick_id: lastInsertRowid, outfits });
+});
+
+app.post('/api/outfits/:id/pick', (req, res) => {
+  const u = currentUser(req);
+  const idx = Number(req.body?.index);
+  if (![0, 1, 2].includes(idx)) return res.status(400).json({ error: 'index must be 0/1/2' });
+  const row = db.prepare('SELECT * FROM outfit_picks WHERE id=? AND user_id=?').get(req.params.id, u.id);
+  if (!row) return res.status(404).json({ error: 'pick not found' });
+  db.prepare("UPDATE outfit_picks SET picked_index=?, picked_at=datetime('now') WHERE id=?").run(idx, row.id);
+
+  // taste-vector update — bump dims of the items in the picked outfit
+  const outfits = JSON.parse(row.outfits || '[]');
+  const picked = outfits[idx];
+  if (picked) {
+    const itemIds = Object.values(picked.slots).map(s => s.id).filter(Boolean);
+    const items = itemIds.length
+      ? db.prepare(`SELECT embedding FROM items WHERE id IN (${itemIds.map(() => '?').join(',')})`).all(...itemIds)
+      : [];
+    let taste = JSON.parse(u.taste_vec || '[]');
+    if (!Array.isArray(taste) || taste.length !== 32) taste = Array(32).fill(0);
+    for (const it of items) {
+      try {
+        const e = JSON.parse(it.embedding);
+        for (let i = 0; i < 32; i++) taste[i] = taste[i] * 0.97 + e[i] * 0.03;
+      } catch {}
+    }
+    db.prepare('UPDATE users SET taste_vec=? WHERE id=?').run(JSON.stringify(taste), u.id);
+  }
+  res.json({ ok: true });
+});
+
+// ---- Stripe Financial Connections (read-only) ----------------------------
+const stripeFC = require('./scripts/stripe-fc');
+
+app.get('/api/stripe-fc/status', (req, res) => {
+  res.json({ available: stripeFC.available(), permissions: stripeFC.PERMISSIONS });
+});
+
+app.post('/api/stripe-fc/session', async (req, res) => {
+  const u = currentUser(req);
+  if (!stripeFC.available()) return res.status(503).json({ error: 'Stripe FC not configured', hint: 'STRIPE_FC_SECRET_KEY missing' });
+  try {
+    const returnUrl = `${process.env.BASE_URL || 'http://127.0.0.1:9777'}/api/stripe-fc/return`;
+    const session = await stripeFC.createLinkSession({ accountHolderName: u.display_name, returnUrl });
+    db.prepare(`INSERT INTO connections (user_id, kind, state, meta) VALUES (?, 'stripe_fc', 'pending', ?)
+      ON CONFLICT(user_id, kind) DO UPDATE SET state='pending', meta=excluded.meta, updated_at=datetime('now')`)
+      .run(u.id, JSON.stringify({ session_id: session.id }));
+    res.json({ ok: true, client_secret: session.client_secret, session_id: session.id });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+app.get('/api/stripe-fc/return', (req, res) => {
+  // Stripe redirects here after the Link flow. The client also gets a callback
+  // via the JS SDK — this is purely the success page.
+  res.send(`<!doctype html><meta charset=utf-8><title>Bank connected</title>
+    <body style="font-family:system-ui;padding:40px;text-align:center;">
+    <h1>Connected.</h1><p>You can close this window.</p>
+    <script>window.opener && window.opener.postMessage({stripe_fc:'success'},'*');setTimeout(()=>window.close(),1500);</script>`);
+});
+
+app.post('/api/stripe-fc/webhook', express.raw({ type: 'application/json' }), (req, res) => {
+  // TODO when STRIPE_WEBHOOK_SIGNING_SECRET is set: verify signature,
+  // parse event, route financial_connections.transaction.created to a
+  // closet-insert path. For now just acknowledge.
+  res.json({ received: true });
+});
+
+// ---- waitlist (for /about page) ------------------------------------------
+app.post('/api/waitlist', (req, res) => {
+  const { email, source } = req.body || {};
+  if (!email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ error: 'valid email required' });
+  try {
+    db.prepare('INSERT INTO waitlist (email, source) VALUES (?, ?)').run(email.toLowerCase().trim(), source || 'about-page');
+  } catch (e) {
+    if (!/UNIQUE/i.test(e.message)) throw e;
+    // already on list — that's fine, return ok
+  }
+  // mirror to data/waitlist.csv for easy export
+  const csvPath = path.join(DATA_DIR, 'waitlist.csv');
+  const header = fs.existsSync(csvPath) ? '' : 'email,source,created_at\n';
+  fs.appendFileSync(csvPath, header + `${email.toLowerCase().trim()},${source || 'about-page'},${new Date().toISOString()}\n`);
+  res.json({ ok: true });
+});
+
 // ---- delete-my-stuff (CCPA/CPRA-shaped) -----------------------------------
 app.post('/api/me/forget', (req, res) => {
   const u = currentUser(req);
@@ -541,6 +727,7 @@ app.post('/api/me/forget', (req, res) => {
   // wipe rows
   const tx = db.transaction(() => {
     db.prepare('DELETE FROM tryon_jobs WHERE user_id = ?').run(u.id);
+    db.prepare('DELETE FROM outfit_picks WHERE user_id = ?').run(u.id);
     db.prepare('DELETE FROM user_avatars WHERE user_id = ?').run(u.id);
     db.prepare('DELETE FROM closet WHERE user_id = ?').run(u.id);
     db.prepare('DELETE FROM duels WHERE user_id = ?').run(u.id);

← 4417851 tick 4 follow-up: 24/24 seed items now on llava semantic emb  ·  back to Whatsmystyle  ·  tick 6: E2E validation + resale guardrails + debates UI + on 05b41bf →