← 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) => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
})[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,'"')+'">' : '') + '</a>' +
'<div class="info"><div class="t">'+(p.title||'').replace(/</g,'<')+'</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}`);
});