← back to Dw Yolo Loop

server.js

745 lines

/**
 * designerwallcoverings.ai — AI room-render landing.
 *
 * User uploads a room photo. The server passes the image to Gemini
 * Vision and asks for a structured palette + style read of the room
 * (dominant colors, style descriptors, texture/finish hints, room
 * mood). Those signals score each product in the local catalog
 * snapshot, and the top matches render as a memo-sample-ready grid.
 *
 * The DW catalog snapshot lives at data/products.json (read-only
 * mirror, regenerated upstream). Order flow always hands back to
 * designerwallcoverings.com so payments / sample fulfillment stay on
 * the canonical Shopify surface.
 */
const express = require('express');
const multer = require('multer');
const path = require('path');
const fs = require('fs');

try {
  const envPath = path.join(__dirname, '.env');
  if (fs.existsSync(envPath)) {
    for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
      const m = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/.exec(line);
      if (!m) continue;
      const [, k, raw] = m;
      if (process.env[k]) continue;
      let v = raw.trim();
      if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
      process.env[k] = v;
    }
  }
} catch (_) {}

const PORT = parseInt(process.env.PORT || '9925', 10);
const BIND = process.env.BIND || '127.0.0.1';
const GA_ID = 'G-69X70WPXJ1';
const DW_SHOPIFY = 'https://designerwallcoverings.com';
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
const GEMINI_MODEL = process.env.GEMINI_MODEL || 'gemini-2.5-flash';

const PRODUCTS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'products.json'), 'utf8'));

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 15 * 1024 * 1024 },
});

const app = express();
app.use(express.json({ limit: '2mb' }));
app.use((_req, res, next) => {
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('X-Frame-Options', 'SAMEORIGIN');
  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});
app.use('/static', express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));

const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[c]);

const COLOR_BUCKETS = [
  ['black', /\b(black|noir|onyx|jet|midnight)\b/i],
  ['white', /\b(white|ivory|cream|alabaster|chalk)\b/i],
  ['gray', /\b(gray|grey|charcoal|graphite|ash|smoke|silver)\b/i],
  ['red', /\b(red|crimson|scarlet|ruby|brick|oxblood|cinnabar)\b/i],
  ['orange', /\b(orange|terracotta|copper|rust|amber|tangerine)\b/i],
  ['yellow', /\b(yellow|gold|ochre|saffron|honey|mustard|citrine)\b/i],
  ['green', /\b(green|sage|olive|moss|emerald|jade|fern|forest|seafoam|seaglass)\b/i],
  ['blue', /\b(blue|navy|cobalt|indigo|sapphire|aqua|teal|turquoise|aegean|sky)\b/i],
  ['purple', /\b(purple|violet|lavender|plum|aubergine|mauve|lilac)\b/i],
  ['pink', /\b(pink|rose|blush|coral|fuchsia|magenta)\b/i],
  ['brown', /\b(brown|tan|taupe|sand|khaki|camel|chocolate|caramel|sepia|beige)\b/i],
];
const STYLE_BUCKETS = [
  ['Floral', /\b(floral|flower|botanical|peony|rose|garden|aviary|bird|chinoiserie)\b/i],
  ['Geometric', /\b(stripe|geometric|lattice|herringbone|chevron|grid|fret|trellis|diamond|hex)\b/i],
  ['Damask', /\b(damask|brocade|fleur|scroll|arabesque|baroque|jacquard)\b/i],
  ['Texture', /\b(linen|grasscloth|sisal|cork|paperweave|paper weave|plaster|raffia|jute)\b/i],
  ['Metallic', /\b(metallic|gold|silver|mica|foil|gilded|shimmer|bead)\b/i],
  ['Global', /\b(ikat|suzani|kilim|paisley|medallion|moroccan|persian|block.?print|tribal)\b/i],
  ['Minimal', /\b(solid|plain|woven|tonal|texture|grass|natural|paper)\b/i],
];

const HEX_TO_BUCKET = (hex) => {
  const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
  if (!m) return null;
  const r = parseInt(m[1].slice(0, 2), 16);
  const g = parseInt(m[1].slice(2, 4), 16);
  const b = parseInt(m[1].slice(4, 6), 16);
  const max = Math.max(r, g, b);
  const min = Math.min(r, g, b);
  const l = (max + min) / 2;
  const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
  if (sat < 0.12 && l < 40) return 'black';
  if (sat < 0.12 && l > 220) return 'white';
  if (sat < 0.18) return 'gray';
  let h = 0;
  if (max === r) h = ((g - b) / (max - min)) % 6;
  else if (max === g) h = (b - r) / (max - min) + 2;
  else h = (r - g) / (max - min) + 4;
  h = (h * 60 + 360) % 360;
  if (r > 180 && g > 130 && b < 110 && sat < 0.4) return 'brown';
  if (h < 15 || h >= 345) return 'red';
  if (h < 45) return 'orange';
  if (h < 70) return 'yellow';
  if (h < 170) return 'green';
  if (h < 250) return 'blue';
  if (h < 295) return 'purple';
  return 'pink';
};

function colorBucketOfProduct(p) {
  const hay = `${p.title || ''}`.toLowerCase();
  for (const [name, rx] of COLOR_BUCKETS) if (rx.test(hay)) return name;
  return null;
}
function styleBucketsOfProduct(p) {
  const hay = `${p.title || ''} ${p.product_type || ''}`.toLowerCase();
  const out = [];
  for (const [name, rx] of STYLE_BUCKETS) if (rx.test(hay)) out.push(name);
  return out;
}

function scoreProduct(p, signal) {
  let score = 0;
  const pColor = colorBucketOfProduct(p);
  const pStyles = styleBucketsOfProduct(p);
  if (pColor && signal.colorBuckets.includes(pColor)) score += 4;
  if (pColor === signal.dominantBucket) score += 3;
  for (const s of signal.styles) if (pStyles.includes(s)) score += 3;
  for (const kw of signal.keywords) {
    if (!kw) continue;
    const re = new RegExp(`\\b${kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
    if (re.test(p.title || '')) score += 2;
  }
  if (p.image_url) score += 0.5;
  return score;
}

function sortProducts(arr, mode) {
  const a = arr.slice();
  switch (mode) {
    case 'color':
      return a.sort((x, y) => (colorBucketOfProduct(x) || 'zz').localeCompare(colorBucketOfProduct(y) || 'zz') || (x.title || '').localeCompare(y.title || ''));
    case 'style':
      return a.sort((x, y) => (styleBucketsOfProduct(x)[0] || 'zz').localeCompare(styleBucketsOfProduct(y)[0] || 'zz') || (x.title || '').localeCompare(y.title || ''));
    case 'sku': return a.sort((x, y) => (x.dw_sku || x.handle || '').localeCompare(y.dw_sku || y.handle || ''));
    case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
    case 'price-up': return a.sort((x, y) => (parseFloat(x.retail_price) || Infinity) - (parseFloat(y.retail_price) || Infinity));
    case 'price-down': return a.sort((x, y) => (parseFloat(y.retail_price) || -Infinity) - (parseFloat(x.retail_price) || -Infinity));
    default: return a;
  }
}

function memoSampleUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}#sample` : DW_SHOPIFY; }
function shopifyUrl(handle) { return handle ? `${DW_SHOPIFY}/products/${handle}` : DW_SHOPIFY; }

async function geminiAnalyzeRoom(buffer, mime) {
  if (!GEMINI_API_KEY) {
    return {
      ok: false,
      reason: 'gemini_key_missing',
      colors: [],
      styles: [],
      keywords: [],
      mood: '',
      summary: 'Gemini key not configured. Showing curated catalog instead.',
    };
  }
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
  const prompt = `You are an interior-design analyst. Look at this room photo and return ONLY valid JSON, no prose, with this exact shape:
{
  "summary": "one-sentence read of the room (e.g., 'sunlit California-modern living room with white oak and warm linen').",
  "mood": "one of: serene, dramatic, energetic, refined, playful, moody, light, warm, cool",
  "colors": [{"name":"warm taupe","hex":"#a89878"}, ...up to 5 dominant colors with names + hex],
  "styles": [up to 4 style tags from this list: Floral, Geometric, Damask, Texture, Metallic, Global, Minimal, Modern, Traditional, Coastal, Industrial, Mid-Century, Maximalist, Eclectic],
  "keywords": [up to 8 single-word descriptors that could match a wallpaper title — e.g. 'linen','seafoam','grasscloth','damask','botanical']
}`;
  const body = {
    contents: [{
      parts: [
        { text: prompt },
        { inline_data: { mime_type: mime || 'image/jpeg', data: buffer.toString('base64') } },
      ],
    }],
    generationConfig: { temperature: 0.2, responseMimeType: 'application/json' },
  };
  try {
    const r = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    if (!r.ok) {
      const txt = await r.text();
      return { ok: false, reason: `gemini_${r.status}`, raw: txt.slice(0, 400), colors: [], styles: [], keywords: [], mood: '', summary: '' };
    }
    const j = await r.json();
    try {
      const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
      logGemini(j, { app: 'designerwallcoverings', model: 'gemini-2.0-flash', note: 'image-classify' });
    } catch {}
    const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';
    let parsed = {};
    try { parsed = JSON.parse(text); } catch (e) {
      const m = text.match(/\{[\s\S]*\}/);
      if (m) { try { parsed = JSON.parse(m[0]); } catch {} }
    }
    return {
      ok: true,
      summary: String(parsed.summary || ''),
      mood: String(parsed.mood || ''),
      colors: Array.isArray(parsed.colors) ? parsed.colors.slice(0, 5) : [],
      styles: Array.isArray(parsed.styles) ? parsed.styles.slice(0, 4) : [],
      keywords: Array.isArray(parsed.keywords) ? parsed.keywords.slice(0, 8) : [],
    };
  } catch (err) {
    return { ok: false, reason: 'gemini_exception', error: String(err).slice(0, 200), colors: [], styles: [], keywords: [], mood: '', summary: '' };
  }
}

function buildSignal(analysis) {
  const colorBuckets = [];
  let dominantBucket = null;
  for (const c of analysis.colors || []) {
    const fromHex = HEX_TO_BUCKET(c?.hex);
    let fromName = null;
    const lname = String(c?.name || '').toLowerCase();
    for (const [n, rx] of COLOR_BUCKETS) if (rx.test(lname)) { fromName = n; break; }
    const bucket = fromName || fromHex;
    if (bucket && !colorBuckets.includes(bucket)) colorBuckets.push(bucket);
    if (bucket && !dominantBucket) dominantBucket = bucket;
  }
  const styles = [];
  for (const s of analysis.styles || []) {
    if (STYLE_BUCKETS.some(([n]) => n.toLowerCase() === String(s).toLowerCase())) styles.push(s);
  }
  return {
    colorBuckets,
    dominantBucket,
    styles,
    keywords: (analysis.keywords || []).map((k) => String(k).toLowerCase().trim()).filter(Boolean),
  };
}

function topMatches(signal, n = 24) {
  const scored = PRODUCTS.map((p) => ({ p, s: scoreProduct(p, signal) }))
    .filter((x) => x.s > 0)
    .sort((a, b) => b.s - a.s);
  if (scored.length >= n) return scored.slice(0, n).map((x) => x.p);
  // pad with image-bearing catalog samples so the grid never feels empty
  const extras = PRODUCTS.filter((p) => p.image_url && !scored.some((x) => x.p.handle === p.handle))
    .slice(0, n - scored.length);
  return [...scored.map((x) => x.p), ...extras];
}

function layout({ title, description, body, ogImage }) {
  const pageTitle = `${title} — designerwallcoverings.ai`;
  const desc = description || 'Show us the room. Gemini Vision reads its palette, light, and material story — and surfaces the wallcoverings from our 8,200-SKU library that belong there. Memo samples free.';
  return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(pageTitle)}</title>
<meta name="description" content="${esc(desc)}">
<meta property="og:title" content="${esc(pageTitle)}">
<meta property="og:description" content="${esc(desc)}">
<meta property="og:type" content="website">
${ogImage ? `<meta property="og:image" content="${esc(ogImage)}">` : ''}
<script>
  (function(){try{var s=localStorage.getItem('dwa-theme');var p=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.setAttribute('data-theme', s || (p?'dark':'light'));}catch(e){}})();
</script>
<!-- GA4 gtag (auto) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=${GA_ID}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','${GA_ID}');</script>
<!-- /GA4 gtag -->
<link rel="preconnect" href="https://cdn.shopify.com">
<style>
:root[data-theme="light"] {
  --bg:#f7f5fb; --fg:#14121a; --muted:#6a627a; --rule:#d8d3e6; --card:#ffffff;
  --accent:#5d3dff; --accent-soft:#b4a8ed; --hi:#fff;
  --shadow:0 10px 40px rgba(40,28,120,.10);
}
:root[data-theme="dark"] {
  --bg:#0b0a14; --fg:#ece8f5; --muted:#8a82a3; --rule:#25212e; --card:#16131f;
  --accent:#9b7dff; --accent-soft:#5d4ab8; --hi:#0b0a14;
  --shadow:0 10px 40px rgba(0,0,0,.6);
}
* { box-sizing: border-box; margin:0; padding:0; }
html,body { font-family:-apple-system,'Inter','Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--fg); line-height:1.55; -webkit-font-smoothing:antialiased; }
a { color:inherit; text-decoration:none; }
img { max-width:100%; display:block; }

header.mast { border-bottom:1px solid var(--rule); position:sticky; top:0; z-index:50;
  background:var(--bg); backdrop-filter:saturate(140%) blur(8px); }
.mast-inner { max-width:1480px; margin:0 auto; padding:18px 32px; display:flex; align-items:center; gap:24px; }
.brand { font-family:'Cormorant Garamond','Playfair Display',Georgia,serif; font-weight:600; font-size:24px; letter-spacing:.01em; white-space:nowrap; }
.brand small { display:block; font-family:-apple-system,'Inter',sans-serif; font-size:10px; letter-spacing:.26em; font-weight:500; color:var(--muted); margin-top:2px; }
nav.types { display:flex; gap:18px; flex:1; flex-wrap:wrap; }
nav.types a { font-size:12px; font-weight:500; color:var(--muted); padding:4px 0; letter-spacing:.08em; text-transform:uppercase; }
nav.types a:hover { color:var(--fg); }
button.theme-toggle { border:1px solid var(--rule); background:var(--card); color:var(--fg); width:36px; height:36px; cursor:pointer; font-size:16px; display:inline-flex; align-items:center; justify-content:center; }
button.theme-toggle:hover { border-color:var(--accent); }

.hero { max-width:1480px; margin:0 auto; padding:64px 32px 24px; }
.hero .kicker { font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:var(--muted); margin-bottom:16px; }
.hero h1 { font-family:'Cormorant Garamond',Georgia,serif; font-size:clamp(40px,8vw,96px); font-weight:500; line-height:.98; letter-spacing:-.01em; max-width:1100px; }
.hero h1 em { font-style:italic; color:var(--accent); }
.hero .lede { margin-top:24px; max-width:760px; font-size:17px; color:var(--muted); }

.upload-wrap { max-width:1100px; margin:48px auto 80px; padding:0 32px; }
.dropzone { border:2px dashed var(--rule); background:var(--card); padding:64px 32px; text-align:center; transition:all .15s; cursor:pointer; }
.dropzone:hover, .dropzone.drag { border-color:var(--accent); background:var(--bg); }
.dropzone .big { font-family:'Cormorant Garamond',Georgia,serif; font-size:32px; line-height:1.1; margin-bottom:12px; }
.dropzone .small { color:var(--muted); font-size:14px; margin-bottom:24px; }
.dropzone button { background:var(--accent); color:#fff; border:0; padding:14px 28px; font-size:13px; letter-spacing:.14em; text-transform:uppercase; font-weight:600; cursor:pointer; }
.dropzone button:hover { background:#4528d4; }
.dropzone input[type=file] { display:none; }
.preview { display:grid; grid-template-columns:300px 1fr; gap:32px; margin-top:32px; align-items:start; }
@media (max-width:780px) { .preview { grid-template-columns:1fr; } }
.preview .img { background:var(--rule); aspect-ratio:4/3; overflow:hidden; }
.preview .img img { width:100%; height:100%; object-fit:cover; }
.preview .read .read-title { font-family:'Cormorant Garamond',Georgia,serif; font-size:28px; line-height:1.15; margin-bottom:8px; }
.preview .read .meta { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); margin-bottom:14px; }
.preview .read p { color:var(--muted); margin-bottom:20px; }
.swatches { display:flex; gap:8px; margin-bottom:18px; flex-wrap:wrap; }
.swatch { display:flex; align-items:center; gap:8px; border:1px solid var(--rule); padding:6px 10px; font-size:12px; }
.swatch .chip { width:18px; height:18px; border:1px solid var(--rule); }
.tags { display:flex; gap:6px; flex-wrap:wrap; }
.tag { border:1px solid var(--rule); padding:4px 10px; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); }
.tag.style { color:var(--accent); border-color:var(--accent); }
.spinner { display:inline-block; width:14px; height:14px; border:2px solid var(--rule); border-top-color:var(--accent); border-radius:50%; animation:spin 1s linear infinite; vertical-align:middle; margin-right:8px; }
@keyframes spin { to { transform:rotate(360deg); } }
.error { color:#a02020; padding:14px; border:1px solid #a02020; background:#fff5f5; }
:root[data-theme="dark"] .error { background:#2a1010; color:#ff8080; }

.toolbar { max-width:1480px; margin:0 auto; padding:18px 32px; border-top:1px solid var(--rule); border-bottom:1px solid var(--rule); display:flex; gap:24px; align-items:center; flex-wrap:wrap; background:var(--bg); }
.toolbar label { font-size:11px; letter-spacing:.14em; text-transform:uppercase; color:var(--muted); font-weight:500; }
.toolbar select, .toolbar input[type="range"] { font:inherit; border:1px solid var(--rule); background:var(--card); color:var(--fg); padding:6px 10px; }
.toolbar .group { display:flex; align-items:center; gap:10px; }
.toolbar .meta { margin-left:auto; font-size:12px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; }

.results-wrap { max-width:1480px; margin:0 auto; padding:32px; }
.grid { display:grid; gap:22px; grid-template-columns:repeat(auto-fill,minmax(var(--card-min,240px),1fr)); }
.card { background:var(--card); border:1px solid var(--rule); transition:box-shadow .15s, transform .12s; }
.card:hover { box-shadow:var(--shadow); transform:translateY(-2px); }
.card a.thumb { display:block; aspect-ratio:1/1; background:var(--rule); overflow:hidden; }
.card a.thumb img { width:100%; height:100%; object-fit:cover; }
.card .info { padding:14px; }
.card .info .t { font-family:'Cormorant Garamond',Georgia,serif; font-size:17px; line-height:1.22; margin-bottom:4px; display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
.card .info .sub { font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em; }
.card .actions { display:flex; border-top:1px solid var(--rule); }
.card .actions a { flex:1; padding:10px 12px; text-align:center; font-size:11px; text-transform:uppercase; letter-spacing:.08em; font-weight:500; color:var(--muted); transition:all .12s; }
.card .actions a + a { border-left:1px solid var(--rule); }
.card .actions a:hover { background:var(--bg); color:var(--fg); }
.card .actions a.cta { color:var(--accent); font-weight:600; }
.card .actions a.cta:hover { background:var(--accent); color:#fff; }

footer.fp { border-top:1px solid var(--rule); background:var(--card); padding:56px 32px; display:grid; gap:28px; grid-template-columns:2fr 1fr 1fr 1fr; }
@media (max-width:880px) { footer.fp { grid-template-columns:1fr 1fr; } }
footer.fp h4 { font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--muted); margin-bottom:12px; font-weight:500; }
footer.fp p, footer.fp a { font-size:13px; color:var(--fg); line-height:1.7; }
footer.fp .col-brand .name { font-family:'Cormorant Garamond',Georgia,serif; font-size:22px; font-weight:500; margin-bottom:8px; }
footer.fp ul { list-style:none; }
footer.fp ul li { margin-bottom:6px; }
footer.fp .legal { grid-column:1/-1; padding-top:28px; border-top:1px solid var(--rule); font-size:11px; color:var(--muted); letter-spacing:.04em; display:flex; gap:24px; flex-wrap:wrap; }

.sample-row { max-width:1480px; margin:0 auto; padding:24px 32px; border-top:1px solid var(--rule); }
.sample-row h3 { font-family:'Cormorant Garamond',Georgia,serif; font-size:22px; font-weight:500; margin-bottom:8px; }
.sample-row p { color:var(--muted); font-size:14px; }
.sample-pics { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; margin-top:14px; }
@media (max-width:760px) { .sample-pics { grid-template-columns:repeat(3,1fr); } }
.sample-pics .pic { aspect-ratio:1/1; background:var(--rule); cursor:pointer; overflow:hidden; transition:transform .12s; }
.sample-pics .pic:hover { transform:scale(1.04); outline:2px solid var(--accent); }
.sample-pics .pic img { width:100%; height:100%; object-fit:cover; }

.howitworks { max-width:1480px; margin:0 auto; padding:48px 32px 80px; }
.howitworks .eyebrow { font-family:'JetBrains Mono','SF Mono',ui-monospace,monospace; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--accent); margin-bottom:18px; }
.howitworks h2 { font-family:'Cormorant Garamond',Georgia,serif; font-weight:500; font-size:clamp(28px,4vw,44px); line-height:1.08; letter-spacing:-.005em; max-width:780px; margin-bottom:48px; }
.howitworks h2 em { font-style:italic; color:var(--accent); }
.howitworks .steps { display:grid; grid-template-columns:repeat(3,1fr); gap:32px; }
@media (max-width:880px) { .howitworks .steps { grid-template-columns:1fr; gap:24px; } }
.howitworks .step { border:1px solid var(--rule); background:var(--card); padding:32px 28px; position:relative; overflow:hidden; }
.howitworks .step::before { content:""; position:absolute; left:0; top:0; bottom:0; width:3px; background:var(--accent); opacity:0; transition:opacity .15s; }
.howitworks .step:hover::before { opacity:1; }
.howitworks .step .n { font-family:'JetBrains Mono','SF Mono',ui-monospace,monospace; font-size:13px; letter-spacing:.18em; color:var(--accent); margin-bottom:18px; }
.howitworks .step h3 { font-family:'Cormorant Garamond',Georgia,serif; font-weight:500; font-size:26px; line-height:1.12; margin-bottom:10px; }
.howitworks .step p { font-size:14px; line-height:1.6; color:var(--muted); }
.howitworks .step .ms { display:inline-block; margin-top:14px; font-family:'JetBrains Mono','SF Mono',ui-monospace,monospace; font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--accent-soft); border:1px solid var(--rule); padding:3px 8px; }
</style>
</head>
<body>
<header class="mast">
  <div class="mast-inner">
    <a href="/" class="brand">designerwallcoverings.ai<small>AI · ROOM · MATCH</small></a>
    <nav class="types">
      <a href="/">Match a Room</a>
      <a href="/about">How It Works</a>
      <a href="${DW_SHOPIFY}" rel="noopener">Shop on DW</a>
    </nav>
    <button class="theme-toggle" onclick="(function(){var r=document.documentElement;var t=r.getAttribute('data-theme')==='dark'?'light':'dark';r.setAttribute('data-theme',t);try{localStorage.setItem('dwa-theme',t);}catch(e){}})()" aria-label="Toggle theme" title="Toggle theme">
      <span class="theme-icon-light" style="display:inline">☀</span>
      <span class="theme-icon-dark"  style="display:none">☾</span>
    </button>
  </div>
</header>
<style>
  :root[data-theme="dark"] .theme-icon-light { display:none !important; }
  :root[data-theme="dark"] .theme-icon-dark  { display:inline !important; }
</style>
${body}
<footer class="fp">
  <div class="col-brand">
    <div class="name">designerwallcoverings.ai</div>
    <p>An AI room-match landing for the Designer Wallcoverings catalog. Upload a room photo, get the wallcoverings that fit its palette and mood.</p>
  </div>
  <div>
    <h4>Tool</h4>
    <ul>
      <li><a href="/">Match a Room</a></li>
      <li><a href="/about">How It Works</a></li>
    </ul>
  </div>
  <div>
    <h4>Catalog</h4>
    <ul>
      <li><a href="${DW_SHOPIFY}" rel="noopener">Shop on DW</a></li>
      <li><a href="${DW_SHOPIFY}/collections" rel="noopener">All Collections</a></li>
    </ul>
  </div>
  <div>
    <h4>Contact</h4>
    <p>
      <a href="mailto:info@designerwallcoverings.ai">info@designerwallcoverings.ai</a><br>
      <a href="tel:+18883734564">888-373-4564</a><br>
      Designer Wallcoverings<br>
      15442 Ventura Bl #102<br>
      Sherman Oaks CA 91403
    </p>
  </div>
  <div class="legal">
    <span>© ${new Date().getFullYear()} Designer Wallcoverings. All rights reserved.</span>
    <span>Memo samples are always free.</span>
  </div>
</footer>
</body>
</html>`;
}

// ---------------------------------------------------------------------------
// Sample image picks for the "Try one of these rooms" row on the landing.
// Image URLs are the product photos already served from the DW Shopify CDN —
// safe to display, public-domain alternative to outside stock photo sites.
// ---------------------------------------------------------------------------
const SAMPLE_PICKS = PRODUCTS
  .filter((p) => p.image_url && p.title && p.title.length < 60)
  .slice(0, 6)
  .map((p) => ({ url: p.image_url, label: p.title.split(' — ')[0] || p.title }));

// ---------------------------------------------------------------------------
// routes
// ---------------------------------------------------------------------------
app.get('/', (_req, res) => {
  const body = `
    <section class="hero">
      <div class="kicker">_camera_roll → wallcovering · vision match</div>
      <h1>Show us the room.<br>We'll find the <em>wallcovering</em>.</h1>
      <p class="lede">Vision model in, catalog out. One photo of your space — Gemini reads its palette, light, and material story, then surfaces the 24 patterns from our 8,200-SKU library that actually belong there. Memo samples free.</p>
    </section>
    <section class="upload-wrap">
      <div id="zone" class="dropzone" tabindex="0" role="button" aria-label="Upload a room photo">
        <div class="big">Upload a room photo</div>
        <div class="small">JPG, PNG, HEIC, or WebP · up to 15 MB · processed once, never stored</div>
        <button type="button" id="picker">Choose photo</button>
        <input type="file" id="file" accept="image/*">
      </div>
      <div id="status"></div>
      <div id="result"></div>
      <div class="sample-row">
        <h3>Or try one of these</h3>
        <p>Click any sample to see what the matcher returns for that mood.</p>
        <div class="sample-pics">
          ${SAMPLE_PICKS.map((s) => `<div class="pic" data-sample="${esc(s.url)}" title="${esc(s.label)}"><img loading="lazy" src="${esc(s.url)}" alt="${esc(s.label)}"></div>`).join('')}
        </div>
      </div>
    </section>
    <section class="howitworks">
      <div class="eyebrow">_how_it_works</div>
      <h2>Three steps from a room photo to a <em>wallcovering</em> that belongs there.</h2>
      <div class="steps">
        <div class="step">
          <div class="n">STEP_01 · READ</div>
          <h3>Gemini Vision reads the room.</h3>
          <p>One structured pass — dominant colors with hex codes, mood, visible materials, and style descriptors that could match a wallpaper title. Image processed once, never stored.</p>
          <span class="ms">~2.5s · 1 vision call</span>
        </div>
        <div class="step">
          <div class="n">STEP_02 · MATCH</div>
          <h3>Score 8,200 SKUs against the read.</h3>
          <p>Each catalog product is scored on color bucket overlap, style tags, and keyword hits in the title. Color contributes the most; style next. The top 24 surface as a sortable, density-adjustable grid.</p>
          <span class="ms">~80ms · local index</span>
        </div>
        <div class="step">
          <div class="n">STEP_03 · SAMPLE</div>
          <h3>Order free memo samples in one click.</h3>
          <p>Every match links back to the canonical product page on designerwallcoverings.com — order, payment, and shipping live on Shopify. This site is purely the discovery surface.</p>
          <span class="ms">free · hands back to DW</span>
        </div>
      </div>
    </section>
    <script>
    (function(){
      var zone = document.getElementById('zone');
      var picker = document.getElementById('picker');
      var file = document.getElementById('file');
      var status = document.getElementById('status');
      var result = document.getElementById('result');

      function pick() { file.click(); }
      picker.addEventListener('click', function(e){ e.stopPropagation(); pick(); });
      zone.addEventListener('click', pick);
      zone.addEventListener('keydown', function(e){ if (e.key==='Enter'||e.key===' ') { e.preventDefault(); pick(); } });
      ['dragenter','dragover'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.add('drag'); }); });
      ['dragleave','drop'].forEach(function(ev){ zone.addEventListener(ev, function(e){ e.preventDefault(); zone.classList.remove('drag'); }); });
      zone.addEventListener('drop', function(e){
        if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) submit(e.dataTransfer.files[0]);
      });
      file.addEventListener('change', function(){ if (file.files && file.files[0]) submit(file.files[0]); });

      document.querySelectorAll('.sample-pics .pic').forEach(function(el){
        el.addEventListener('click', function(){ submitSampleUrl(el.getAttribute('data-sample')); });
      });

      async function submit(blob) {
        result.innerHTML = '';
        status.innerHTML = '<div class="error" style="background:transparent;border:0;color:var(--muted)"><span class="spinner"></span>Reading the room…</div>';
        var fd = new FormData();
        fd.append('photo', blob);
        try {
          var r = await fetch('/api/match', { method:'POST', body: fd });
          var j = await r.json();
          render(j, URL.createObjectURL(blob));
        } catch (err) { status.innerHTML = '<div class="error">Upload failed: '+err.message+'</div>'; }
      }
      async function submitSampleUrl(url) {
        result.innerHTML = '';
        status.innerHTML = '<div class="error" style="background:transparent;border:0;color:var(--muted)"><span class="spinner"></span>Reading the room…</div>';
        try {
          var r = await fetch('/api/match-url', {
            method:'POST',
            headers:{'Content-Type':'application/json'},
            body: JSON.stringify({ url: url })
          });
          var j = await r.json();
          render(j, url);
        } catch (err) { status.innerHTML = '<div class="error">Match failed: '+err.message+'</div>'; }
      }
      function render(j, previewUrl) {
        if (!j || !j.ok) {
          status.innerHTML = '<div class="error">'+(j && j.message ? j.message : 'Something went wrong')+'</div>';
          return;
        }
        status.innerHTML = '';
        var swatches = (j.analysis.colors||[]).map(function(c){ return '<div class="swatch"><div class="chip" style="background:'+(c.hex||'#ccc')+'"></div>'+(c.name||c.hex||'')+'</div>'; }).join('');
        var styles = (j.analysis.styles||[]).map(function(s){ return '<div class="tag style">'+s+'</div>'; }).join('');
        var keywords = (j.analysis.keywords||[]).map(function(k){ return '<div class="tag">'+k+'</div>'; }).join('');
        var notice = j.analysis_ok ? '' : '<div class="error" style="margin-bottom:14px">'+(j.notice||'AI read unavailable; showing curated picks.')+'</div>';
        result.innerHTML =
          notice +
          '<div class="preview">' +
            '<div class="img"><img src="'+previewUrl+'" alt="your room"></div>' +
            '<div class="read">' +
              '<div class="meta">'+(j.analysis.mood||'room read')+'</div>' +
              '<div class="read-title">'+(j.analysis.summary||'Catalog picks')+'</div>' +
              (swatches ? '<div class="swatches">'+swatches+'</div>' : '') +
              (styles+keywords ? '<div class="tags">'+styles+keywords+'</div>' : '') +
            '</div>' +
          '</div>' +
          '<div class="toolbar" style="margin-top:32px">' +
            '<div class="group"><label for="sort">Sort</label>' +
              '<select id="sort">' +
                '<option value="match">Match score</option>' +
                '<option value="newest">Newest</option>' +
                '<option value="color">Color</option>' +
                '<option value="style">Style</option>' +
                '<option value="sku">SKU A→Z</option>' +
                '<option value="title">Title A→Z</option>' +
              '</select>' +
            '</div>' +
            '<div class="group"><label for="cm">Density</label>' +
              '<input type="range" id="cm" min="160" max="420" step="20" value="240">' +
            '</div>' +
            '<div class="meta">'+(j.matches||[]).length+' matches</div>' +
          '</div>' +
          '<div class="grid" id="gridRoot" style="--card-min:240px; margin-top:22px">' +
            (j.matches||[]).map(cardHtml).join('') +
          '</div>';

        var sort = document.getElementById('sort');
        var cm = document.getElementById('cm');
        var grid = document.getElementById('gridRoot');
        var orig = (j.matches||[]).slice();
        try {
          var savedSort = localStorage.getItem('dwa-sort');
          if (savedSort) sort.value = savedSort;
          var savedCm = localStorage.getItem('dwa-cm');
          if (savedCm) { cm.value = savedCm; grid.style.setProperty('--card-min', savedCm+'px'); }
        } catch(e) {}
        sort.addEventListener('change', function(){
          try{localStorage.setItem('dwa-sort', sort.value);}catch(e){}
          var arr = orig.slice();
          if (sort.value === 'sku') arr.sort(function(a,b){ return (a.dw_sku||'').localeCompare(b.dw_sku||''); });
          else if (sort.value === 'title') arr.sort(function(a,b){ return (a.title||'').localeCompare(b.title||''); });
          else if (sort.value === 'newest') arr = orig.slice();
          grid.innerHTML = arr.map(cardHtml).join('');
        });
        cm.addEventListener('input', function(){ grid.style.setProperty('--card-min', cm.value+'px'); try{localStorage.setItem('dwa-cm', cm.value);}catch(e){} });
        try { gtag('event','room_matched',{ matches:(j.matches||[]).length, mood:j.analysis.mood||'' }); } catch(e){}
      }
      function cardHtml(p) {
        var memo = '${DW_SHOPIFY}/products/' + (p.handle||'') + '#sample';
        var view = '${DW_SHOPIFY}/products/' + (p.handle||'');
        return '<article class="card">' +
          '<a class="thumb" href="'+view+'" target="_blank" rel="noopener noreferrer">' + (p.image_url ? '<img loading="lazy" src="'+p.image_url+'" alt="'+(p.title||'').replace(/"/g,'&quot;')+'">' : '') + '</a>' +
          '<div class="info"><div class="t">'+(p.title||'').replace(/</g,'&lt;')+'</div>' +
          '<div class="sub">'+(p.dw_sku||'')+(p.product_type?(' · '+p.product_type):'')+'</div></div>' +
          '<div class="actions">' +
          '<a href="'+view+'" target="_blank" rel="noopener noreferrer">View</a>' +
          '<a class="cta" href="'+memo+'" target="_blank" rel="noopener noreferrer">Order Memo</a>' +
          '</div></article>';
      }
    })();
    </script>
  `;
  res.set('Cache-Control', 'no-store, must-revalidate')
    .set('Content-Type', 'text/html; charset=utf-8')
    .send(layout({
      title: 'Vision-Match a Room to Wallcovering',
      description: 'Show us the room. Gemini Vision reads its palette, light, and material story — and surfaces the 24 wallcoverings from the 8,200-SKU Designer Wallcoverings catalog that actually belong there. Memo samples free.',
      body,
    }));
});

app.get('/about', (_req, res) => {
  const body = `
    <section class="hero">
      <div class="kicker">how it works</div>
      <h1>Vision in. <em>Wallcoverings</em> out.</h1>
      <p class="lede">designerwallcoverings.ai is a room-match landing for the Designer Wallcoverings catalog —
        the same 8,200-SKU library you'd browse on designerwallcoverings.com, surfaced through a
        photo-first interface instead of a faceted search.</p>
    </section>
    <section class="upload-wrap" style="max-width:880px">
      <div style="border-top:1px solid var(--rule); padding:32px 0">
        <h3 style="font-family:'Cormorant Garamond',Georgia,serif; font-size:32px; font-weight:500; margin-bottom:12px">1. Read</h3>
        <p style="color:var(--muted)">Your photo is sent to Google's Gemini Vision API for a single, structured pass. We ask it for the
          dominant colors (with hex codes), the room's mood, the visible materials, and a few style descriptors that could match a
          wallpaper title. The image is processed once and not retained.</p>
      </div>
      <div style="border-top:1px solid var(--rule); padding:32px 0">
        <h3 style="font-family:'Cormorant Garamond',Georgia,serif; font-size:32px; font-weight:500; margin-bottom:12px">2. Match</h3>
        <p style="color:var(--muted)">Each catalog product is scored against the room's color buckets, style tags, and keyword pool.
          Color contributes the most, style next, with a small bonus for keyword overlap in the product title. The top 24 surface as
          a sortable, density-adjustable grid.</p>
      </div>
      <div style="border-top:1px solid var(--rule); padding:32px 0">
        <h3 style="font-family:'Cormorant Garamond',Georgia,serif; font-size:32px; font-weight:500; margin-bottom:12px">3. Sample</h3>
        <p style="color:var(--muted)">Memo samples are free. Every "Order Memo" button hands you back to the canonical product page on
          <a href="${DW_SHOPIFY}" style="color:var(--accent)">designerwallcoverings.com</a> so the order, payment, and shipping all
          live on Shopify — this site is purely the discovery surface.</p>
      </div>
    </section>
  `;
  res.set('Cache-Control', 'no-store, must-revalidate')
    .set('Content-Type', 'text/html; charset=utf-8')
    .send(layout({ title: 'How It Works', body }));
});

app.post('/api/match', upload.single('photo'), async (req, res) => {
  if (!req.file) return res.status(400).json({ ok: false, message: 'No photo uploaded.' });
  const analysis = await geminiAnalyzeRoom(req.file.buffer, req.file.mimetype);
  const signal = buildSignal(analysis);
  const matches = topMatches(signal, 24);
  res.set('Cache-Control', 'no-store').json({
    ok: true,
    analysis_ok: analysis.ok,
    notice: analysis.ok ? null : (analysis.reason === 'gemini_key_missing'
      ? 'AI vision not yet enabled on this server — showing curated catalog picks.'
      : 'AI vision call failed; showing curated catalog picks.'),
    analysis: {
      summary: analysis.summary,
      mood: analysis.mood,
      colors: analysis.colors,
      styles: analysis.styles,
      keywords: analysis.keywords,
    },
    signal,
    matches,
  });
});

app.post('/api/match-url', async (req, res) => {
  const url = String(req.body?.url || '').trim();
  if (!/^https?:\/\//.test(url)) return res.status(400).json({ ok: false, message: 'Bad url' });
  try {
    const r = await fetch(url);
    if (!r.ok) return res.status(502).json({ ok: false, message: 'fetch_failed' });
    const buf = Buffer.from(await r.arrayBuffer());
    const mime = r.headers.get('content-type') || 'image/jpeg';
    const analysis = await geminiAnalyzeRoom(buf, mime);
    const signal = buildSignal(analysis);
    const matches = topMatches(signal, 24);
    res.set('Cache-Control', 'no-store').json({
      ok: true,
      analysis_ok: analysis.ok,
      notice: analysis.ok ? null : 'AI vision call failed; showing curated picks.',
      analysis: {
        summary: analysis.summary,
        mood: analysis.mood,
        colors: analysis.colors,
        styles: analysis.styles,
        keywords: analysis.keywords,
      },
      signal,
      matches,
    });
  } catch (err) {
    res.status(500).json({ ok: false, message: String(err).slice(0, 200) });
  }
});

app.get('/health', (_req, res) => res.json({
  ok: true,
  products: PRODUCTS.length,
  gemini_key_set: !!GEMINI_API_KEY,
  ga: GA_ID,
}));

app.listen(PORT, BIND, () => {
  console.log(`designerwallcoverings.ai on http://${BIND}:${PORT} · ${PRODUCTS.length} products · ga=${GA_ID} · gemini_key=${!!GEMINI_API_KEY}`);
});