← back to Wallco

server.js

766 lines

/**
 * wallco.ai — describe a room, get matching wallcoverings.
 *
 * Primary input is a free-text description of a room (palette, mood, style).
 * Gemini 2.5 Flash extracts structured signals (colors, styles, keywords)
 * from the prose. Those signals score each product in the local catalog
 * snapshot, and the top matches render as a memo-sample-ready grid.
 *
 * Photo upload is supported as a secondary mode (same Gemini Vision pass
 * the room-render landing uses).
 *
 * Catalog snapshot lives at data/products.json (read-only mirror of
 * dw_unified.products). Order flow always hands back to
 * designerwallcoverings.com so payment/fulfillment stay on Shopify.
 */
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 || '9929', 10);
const BIND = process.env.BIND || '127.0.0.1';
const GA_ID = 'G-H47K8H8G5J';
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();

// Vendor-neutral: scrub vendor names + proxy image_url CDN urls -> /img/<token> in all
// /api JSON, and serve GET /img/:token. wallco renders product <img> client-side from
// /api/search + /api/match-photo responses, so this one mount covers its leak surface.
app.use(require('../_shared/api-vendor-redact'));

app.get(['/favicon.ico','/favicon.svg'], (req, res) => res.sendFile(require('path').join(__dirname, 'public', req.path.slice(1))));
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;
}

async function geminiAnalyzeText(prose) {
  if (!GEMINI_API_KEY) {
    return {
      ok: false,
      reason: 'gemini_key_missing',
      colors: [], styles: [], keywords: [], mood: '',
      summary: 'Gemini key not configured. Showing keyword-matched picks.',
    };
  }
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_API_KEY}`;
  const prompt = `You are an interior-design analyst. The user described a room. Return ONLY valid JSON, no prose, with this exact shape:
{
  "summary": "one-sentence read of the room they described.",
  "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']
}

User description: """${prose.slice(0, 1500)}"""`;
  const body = {
    contents: [{ parts: [{ text: prompt }] }],
    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();
    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: '' };
  }
}

async function geminiAnalyzeImage(buffer, mime) {
  if (!GEMINI_API_KEY) {
    return { ok: false, reason: 'gemini_key_missing', colors: [], styles: [], keywords: [], mood: '', summary: '' };
  }
  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",
  "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]
}`;
  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();
    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, fallbackProse) {
  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;
  }
  // If Gemini failed and we have raw prose, glean color buckets directly
  if (!colorBuckets.length && fallbackProse) {
    for (const [n, rx] of COLOR_BUCKETS) if (rx.test(fallbackProse)) { if (!colorBuckets.includes(n)) colorBuckets.push(n); if (!dominantBucket) dominantBucket = n; }
  }
  const styles = [];
  for (const s of analysis.styles || []) {
    if (STYLE_BUCKETS.some(([n]) => n.toLowerCase() === String(s).toLowerCase())) styles.push(s);
  }
  let keywords = (analysis.keywords || []).map((k) => String(k).toLowerCase().trim()).filter(Boolean);
  if (!keywords.length && fallbackProse) {
    keywords = String(fallbackProse).toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 4).slice(0, 8);
  }
  return { colorBuckets, dominantBucket, styles, keywords };
}

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);
  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 }) {
  const pageTitle = `${title} — wallco.ai`;
  const desc = description || 'Describe a room in plain English. wallco.ai reads its palette and style with Gemini and surfaces the matching wallcoverings from 8,200+ SKUs. 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">
<script>
  (function(){try{var s=localStorage.getItem('wallco-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:#f7f5f2; --fg:#141414; --muted:#6a6760; --rule:#dad5cc; --card:#ffffff;
  --accent:#0d4f4a; --accent-soft:#9ec4c0; --hi:#fff;
  --shadow:0 8px 32px rgba(0,0,0,.08);
}
:root[data-theme="dark"] {
  --bg:#0c0e0e; --fg:#e8e6e0; --muted:#8a8780; --rule:#23272a; --card:#15191b;
  --accent:#48a39c; --accent-soft:#2c5d59; --hi:#0c0e0e;
  --shadow:0 8px 32px rgba(0,0,0,.55);
}
* { 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,7.5vw,88px); font-weight:500; line-height:1; 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); }

.search-wrap { max-width:1100px; margin:36px auto 80px; padding:0 32px; }
.search-card { background:var(--card); border:1px solid var(--rule); padding:28px; box-shadow:var(--shadow); }
.search-card label { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); display:block; margin-bottom:10px; }
.search-card textarea { width:100%; min-height:128px; resize:vertical; border:1px solid var(--rule); background:var(--bg); color:var(--fg); padding:16px; font:16px/1.5 inherit; }
.search-card textarea:focus { outline:2px solid var(--accent); outline-offset:0; }
.search-card .row { display:flex; gap:12px; margin-top:16px; flex-wrap:wrap; align-items:center; }
.search-card button.primary { background:var(--accent); color:#fff; border:0; padding:14px 28px; font-size:13px; letter-spacing:.14em; text-transform:uppercase; font-weight:600; cursor:pointer; }
.search-card button.primary:hover { filter:brightness(1.1); }
.search-card .or { color:var(--muted); font-size:13px; }
.search-card .file-btn { background:var(--card); color:var(--fg); border:1px solid var(--rule); padding:13px 22px; font-size:12px; letter-spacing:.14em; text-transform:uppercase; font-weight:500; cursor:pointer; }
.search-card .file-btn:hover { border-color:var(--accent); }
.search-card input[type=file] { display:none; }
.suggestions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
.suggest { font-size:12px; color:var(--muted); border:1px solid var(--rule); padding:6px 12px; cursor:pointer; transition:all .12s; }
.suggest:hover { border-color:var(--accent); color:var(--fg); }

.preview { display:grid; grid-template-columns:1fr; gap:14px; margin-top:32px; align-items:start; padding:24px; background:var(--card); border:1px solid var(--rule); }
.preview .meta { font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:var(--muted); }
.preview .read-title { font-family:'Cormorant Garamond',Georgia,serif; font-size:28px; line-height:1.15; }
.swatches { display:flex; gap:8px; 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; }
</style>
</head>
<body>
<header class="mast">
  <div class="mast-inner">
    <a href="/" class="brand">wallco.ai<small>AI · WALLPAPER · SEARCH</small></a>
    <nav class="types">
      <a href="/">Search</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('wallco-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">wallco.ai</div>
    <p>AI wallpaper search for the Designer Wallcoverings catalog. Describe a room in plain English; we surface the wallcoverings that fit its palette and mood.</p>
  </div>
  <div>
    <h4>Tool</h4>
    <ul>
      <li><a href="/">Search</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@wallco.ai">info@wallco.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>`;
}

const SUGGEST_PROMPTS = [
  'Sunlit California-modern living room with white oak and warm linen',
  'Moody dining room, oxblood and brass, traditional damask vibe',
  'Coastal bedroom in seafoam and pale ivory, grasscloth texture',
  'Industrial loft, charcoal walls, blackened steel, geometric',
  'Sage green powder room with chinoiserie florals',
];

app.get('/', (_req, res) => {
  const body = `
    <section class="hero">
      <div class="kicker">wallco.ai · ai wallpaper search</div>
      <h1>Describe a room.<br>We'll find the <em>wallcovering</em> that fits.</h1>
      <p class="lede">Type how the room should feel — palette, mood, material, style. wallco.ai reads
      your description with Gemini and surfaces the matching wallcoverings from our 8,200+ SKU catalog.
      Memo samples are always free.</p>
    </section>
    <section class="search-wrap">
      <form id="form" class="search-card" autocomplete="off">
        <label for="q">Describe your room</label>
        <textarea id="q" name="q" placeholder="e.g. moody library, oxblood and brass, traditional damask"></textarea>
        <div class="row">
          <button class="primary" type="submit">Find Wallcoverings</button>
          <span class="or">or</span>
          <button type="button" class="file-btn" id="picker">Upload room photo</button>
          <input type="file" id="file" accept="image/*">
        </div>
        <div class="suggestions">
          ${SUGGEST_PROMPTS.map((s) => `<div class="suggest" data-q="${esc(s)}">${esc(s)}</div>`).join('')}
        </div>
      </form>
      <div id="status" style="margin-top:18px"></div>
      <div id="result"></div>
    </section>
    <script>
    (function(){
      var form = document.getElementById('form');
      var q = document.getElementById('q');
      var picker = document.getElementById('picker');
      var file = document.getElementById('file');
      var status = document.getElementById('status');
      var result = document.getElementById('result');

      document.querySelectorAll('.suggest').forEach(function(el){
        el.addEventListener('click', function(){ q.value = el.getAttribute('data-q'); q.focus(); });
      });

      form.addEventListener('submit', function(e){
        e.preventDefault();
        var prose = (q.value || '').trim();
        if (!prose) { q.focus(); return; }
        result.innerHTML = '';
        status.innerHTML = '<div style="color:var(--muted)"><span class="spinner"></span>Reading the room…</div>';
        fetch('/api/search', {
          method:'POST',
          headers:{'Content-Type':'application/json'},
          body: JSON.stringify({ q: prose })
        }).then(function(r){ return r.json(); })
          .then(function(j){ render(j); })
          .catch(function(err){ status.innerHTML = '<div class="error">Search failed: '+err.message+'</div>'; });
      });

      picker.addEventListener('click', function(){ file.click(); });
      file.addEventListener('change', function(){
        if (!file.files || !file.files[0]) return;
        result.innerHTML = '';
        status.innerHTML = '<div style="color:var(--muted)"><span class="spinner"></span>Reading the photo…</div>';
        var fd = new FormData();
        fd.append('photo', file.files[0]);
        fetch('/api/match-photo', { method:'POST', body: fd })
          .then(function(r){ return r.json(); })
          .then(function(j){ render(j); })
          .catch(function(err){ status.innerHTML = '<div class="error">Upload failed: '+err.message+'</div>'; });
      });

      function he(s) {
        return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
          return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c];
        });
      }
      function safeHex(h) { return /^#?[0-9a-fA-F]{3,8}$/.test(String(h||'')) ? String(h) : '#ccc'; }
      function render(j) {
        if (!j || !j.ok) {
          status.innerHTML = '<div class="error">'+he(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:'+safeHex(c.hex)+'"></div>'+he(c.name||c.hex||'')+'</div>'; }).join('');
        var styles = (j.analysis.styles||[]).map(function(s){ return '<div class="tag style">'+he(s)+'</div>'; }).join('');
        var keywords = (j.analysis.keywords||[]).map(function(k){ return '<div class="tag">'+he(k)+'</div>'; }).join('');
        var notice = j.analysis_ok ? '' : '<div class="error" style="margin-bottom:14px">'+he(j.notice||'AI read unavailable; showing curated picks.')+'</div>';
        result.innerHTML =
          notice +
          '<div class="preview">' +
            '<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 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('wallco-sort');
          if (savedSort) sort.value = savedSort;
          var savedCm = localStorage.getItem('wallco-cm');
          if (savedCm) { cm.value = savedCm; grid.style.setProperty('--card-min', savedCm+'px'); }
        } catch(e) {}
        var COLOR_RX = [
          ['1black', /\\b(black|noir|onyx|jet|midnight)\\b/i],
          ['2white', /\\b(white|ivory|cream|alabaster|chalk)\\b/i],
          ['3gray', /\\b(gray|grey|charcoal|graphite|ash|smoke|silver)\\b/i],
          ['4brown', /\\b(brown|tan|taupe|sand|khaki|camel|chocolate|caramel|sepia|beige)\\b/i],
          ['5red', /\\b(red|crimson|scarlet|ruby|brick|oxblood|cinnabar)\\b/i],
          ['6orange', /\\b(orange|terracotta|copper|rust|amber|tangerine)\\b/i],
          ['7yellow', /\\b(yellow|gold|ochre|saffron|honey|mustard|citrine)\\b/i],
          ['8green', /\\b(green|sage|olive|moss|emerald|jade|fern|forest|seafoam|seaglass)\\b/i],
          ['9blue', /\\b(blue|navy|cobalt|indigo|sapphire|aqua|teal|turquoise|aegean|sky)\\b/i],
          ['Apurple', /\\b(purple|violet|lavender|plum|aubergine|mauve|lilac)\\b/i],
          ['Bpink', /\\b(pink|rose|blush|coral|fuchsia|magenta)\\b/i]
        ];
        function bucketKey(p, rx) {
          var hay = (p.title||'') + ' ' + (p.product_type||'');
          for (var i=0;i<rx.length;i++) if (rx[i][1].test(hay)) return rx[i][0];
          return 'Zother';
        }
        sort.addEventListener('change', function(){
          try{localStorage.setItem('wallco-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 === 'color') arr.sort(function(a,b){ return bucketKey(a,COLOR_RX).localeCompare(bucketKey(b,COLOR_RX)) || (a.title||'').localeCompare(b.title||''); });
          else if (sort.value === 'style') arr.sort(function(a,b){ return (a.product_type||'~').localeCompare(b.product_type||'~') || (a.title||'').localeCompare(b.title||''); });
          else arr = orig.slice(); // match score / newest = server order
          grid.innerHTML = arr.map(cardHtml).join('');
        });
        cm.addEventListener('input', function(){ grid.style.setProperty('--card-min', cm.value+'px'); try{localStorage.setItem('wallco-cm', cm.value);}catch(e){} });
        try { gtag('event','room_searched',{ matches:(j.matches||[]).length, mood:j.analysis.mood||'' }); } catch(e){}
      }
      function cardHtml(p) {
        var handle = he(p.handle||'');
        var memo = '${DW_SHOPIFY}/products/' + handle + '#sample';
        var view = '${DW_SHOPIFY}/products/' + handle;
        var img = p.image_url ? '<img loading="lazy" src="'+he(p.image_url)+'" alt="'+he(p.title)+'">' : '';
        return '<article class="card">' +
          '<a class="thumb" href="'+view+'" target="_blank" rel="noopener">' + img + '</a>' +
          '<div class="info"><div class="t">'+he(p.title)+'</div>' +
          '<div class="sub">'+he(p.dw_sku)+(p.product_type?(' · '+he(p.product_type)):'')+'</div></div>' +
          '<div class="actions">' +
          '<a href="'+view+'" target="_blank" rel="noopener">View</a>' +
          '<a class="cta" href="'+memo+'" target="_blank" rel="noopener">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: 'Describe a Room. Find the Wallpaper.',
      description: 'Describe a room in plain English. Gemini reads its palette + style and we surface the matching wallcoverings from 8,200+ SKUs. Memo samples free.',
      body,
    }));
});

app.get('/about', (_req, res) => {
  const body = `
    <section class="hero">
      <div class="kicker">how it works</div>
      <h1>Words in. <em>Wallcoverings</em> out.</h1>
      <p class="lede">wallco.ai is an AI search surface for the Designer Wallcoverings catalog —
        the same 8,200-SKU library you'd browse on designerwallcoverings.com, surfaced through
        a plain-English description (or a room photo) instead of a faceted search.</p>
    </section>
    <section class="search-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 description (or photo) goes to Google's Gemini for a single, structured pass. We extract dominant
          colors with hex codes, a one-word mood, the visible materials, and a few style descriptors. Nothing about the request is 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/search', async (req, res) => {
  const prose = String(req.body?.q || '').trim();
  if (!prose) return res.status(400).json({ ok: false, message: 'No description provided.' });
  let analysis, signal, matches;
  try {
    analysis = await geminiAnalyzeText(prose);
    signal = buildSignal(analysis, prose);
    matches = topMatches(signal, 24);
  } catch (err) {
    console.error('search failed:', err);
    return res.status(500).json({ ok: false, message: 'Search failed. Please try again.' });
  }
  res.set('Cache-Control', 'no-store').json({
    ok: true,
    analysis_ok: analysis.ok,
    notice: analysis.ok ? null : (analysis.reason === 'gemini_key_missing'
      ? 'AI not yet enabled on this server — showing keyword-matched picks.'
      : 'AI call failed; showing keyword-matched picks.'),
    analysis: {
      summary: analysis.summary,
      mood: analysis.mood,
      colors: analysis.colors,
      styles: analysis.styles,
      keywords: analysis.keywords,
    },
    signal,
    matches,
  });
});

app.post('/api/match-photo', upload.single('photo'), async (req, res) => {
  if (!req.file) return res.status(400).json({ ok: false, message: 'No photo uploaded.' });
  let analysis, signal, matches;
  try {
    analysis = await geminiAnalyzeImage(req.file.buffer, req.file.mimetype);
    signal = buildSignal(analysis, '');
    matches = topMatches(signal, 24);
  } catch (err) {
    console.error('match-photo failed:', err);
    return res.status(500).json({ ok: false, message: 'Photo match failed. Please try again.' });
  }
  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,
  });
});

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

// Global error handler — catches multer file-size rejections and any
// uncaught route error so requests fail cleanly instead of hanging.
app.use((err, req, res, _next) => {
  if (res.headersSent) return;
  const tooLarge = err && err.code === 'LIMIT_FILE_SIZE';
  console.error('request error:', err && err.message ? err.message : err);
  res.status(tooLarge ? 413 : 500).json({
    ok: false,
    message: tooLarge ? 'Image is too large (15 MB max).' : 'Server error. Please try again.',
  });
});

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