← back to Interiordesignershowroom
auto-save: 2026-08-01T11:06:15 (7 files) — .deploy.conf .gitignore db/ lib/ package.json
5617cca6b11976c512b3f607e8348769a0817f2e · 2026-08-01 11:06:21 -0700 · Steve Abrams
Files touched
A .deploy.confA .gitignoreA db/schema.sqlA lib/db.jsA lib/normalize.jsA lib/render.jsA package.jsonA public/css/site.cssA public/js/grid.jsA server.js
Diff
commit 5617cca6b11976c512b3f607e8348769a0817f2e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 11:06:21 2026 -0700
auto-save: 2026-08-01T11:06:15 (7 files) — .deploy.conf .gitignore db/ lib/ package.json
---
.deploy.conf | 6 ++
.gitignore | 10 +++
db/schema.sql | 63 +++++++++++++++++++
lib/db.js | 14 +++++
lib/normalize.js | 130 ++++++++++++++++++++++++++++++++++++++
lib/render.js | 86 +++++++++++++++++++++++++
package.json | 19 ++++++
public/css/site.css | 75 ++++++++++++++++++++++
public/js/grid.js | 43 +++++++++++++
server.js | 178 ++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 624 insertions(+)
diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..e1e04c4
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,6 @@
+# Deploy manifest consumed by the /deploy skill (~/Projects/_shared/scripts/deploy.sh)
+# Go-live to Kamatera is a GATED step — Steve approves the actual deploy.
+PROJECT_NAME=interiordesignershowroom
+DEPLOY_PATH=/root/Projects/interiordesignershowroom
+HEALTH_URL=http://127.0.0.1:9820/healthz
+PORT=9820
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..a0967a2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+data/*.json
+!data/.gitkeep
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..6193708
--- /dev/null
+++ b/db/schema.sql
@@ -0,0 +1,63 @@
+-- interiordesignershowroom catalog schema
+-- One normalized products table fed by 4 network adapters (CJ, Amazon, Rakuten, ShareASale).
+-- The (network, external_id) pair is the natural key so re-ingests UPSERT instead of duplicating.
+
+CREATE TABLE IF NOT EXISTS products (
+ id BIGSERIAL PRIMARY KEY,
+ network TEXT NOT NULL, -- cj | amazon | rakuten | shareasale
+ advertiser TEXT, -- merchant/brand the link points at (Wayfair, etc.)
+ external_id TEXT NOT NULL, -- network product id / ASIN / SKU
+ title TEXT NOT NULL,
+ description TEXT,
+ brand TEXT,
+ category TEXT, -- vendor category (raw)
+ room TEXT, -- OUR taxonomy: living-room, bedroom, ...
+ style TEXT, -- OUR taxonomy: modern, traditional, ...
+ color TEXT, -- OUR taxonomy: neutral, blue, green, ...
+ price NUMERIC(12,2),
+ sale_price NUMERIC(12,2),
+ currency TEXT DEFAULT 'USD',
+ image_url TEXT,
+ affiliate_url TEXT NOT NULL, -- the tracked deep link (the whole point)
+ in_stock BOOLEAN DEFAULT TRUE,
+ featured BOOLEAN DEFAULT FALSE, -- hand-picked hero pieces
+ price_checked_at TIMESTAMPTZ, -- Amazon TOS: never show stale prices
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now(),
+ UNIQUE (network, external_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_products_room ON products (room);
+CREATE INDEX IF NOT EXISTS idx_products_style ON products (style);
+CREATE INDEX IF NOT EXISTS idx_products_color ON products (color);
+CREATE INDEX IF NOT EXISTS idx_products_created ON products (created_at DESC);
+
+-- Editorial buying guides / shop-the-look articles (the SEO + helpful-content layer).
+CREATE TABLE IF NOT EXISTS guides (
+ id BIGSERIAL PRIMARY KEY,
+ slug TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ dek TEXT, -- subtitle / summary
+ hero_image TEXT,
+ body_md TEXT, -- markdown body
+ product_ids BIGINT[] DEFAULT '{}', -- featured products in this guide
+ room TEXT,
+ style TEXT,
+ published BOOLEAN DEFAULT FALSE,
+ created_at TIMESTAMPTZ DEFAULT now(),
+ updated_at TIMESTAMPTZ DEFAULT now()
+);
+
+-- Affiliate click log — powers "what's converting" analytics + Amazon compliance audit trail.
+CREATE TABLE IF NOT EXISTS clicks (
+ id BIGSERIAL PRIMARY KEY,
+ product_id BIGINT REFERENCES products(id) ON DELETE SET NULL,
+ network TEXT,
+ advertiser TEXT,
+ referer TEXT,
+ ua TEXT,
+ clicked_at TIMESTAMPTZ DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_clicks_product ON clicks (product_id);
+CREATE INDEX IF NOT EXISTS idx_clicks_time ON clicks (clicked_at DESC);
diff --git a/lib/db.js b/lib/db.js
new file mode 100644
index 0000000..134db62
--- /dev/null
+++ b/lib/db.js
@@ -0,0 +1,14 @@
+// Postgres pool. Local dev uses a dedicated `idshowroom` database; prod overrides via DATABASE_URL.
+const { Pool } = require('pg');
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql://localhost:5432/idshowroom',
+ max: 10,
+});
+
+pool.on('error', (err) => console.error('[db] idle client error', err.message));
+
+module.exports = {
+ pool,
+ query: (text, params) => pool.query(text, params),
+};
diff --git a/lib/normalize.js b/lib/normalize.js
new file mode 100644
index 0000000..0d2f454
--- /dev/null
+++ b/lib/normalize.js
@@ -0,0 +1,130 @@
+// The unified product contract. EVERY network adapter MUST return objects that pass
+// through normalizeProduct() before they touch the database. This is what lets 4
+// completely different feed shapes (CJ XML, Amazon PA-API JSON, Rakuten CSV,
+// ShareASale pipe-delimited) render as one coherent catalog.
+
+const VALID_NETWORKS = ['cj', 'amazon', 'rakuten', 'shareasale'];
+
+// --- Taxonomy classifiers -------------------------------------------------
+// Keyword → bucket. First match wins. Deliberately simple + deterministic; the
+// editorial layer (hand-picked `featured` products + guides) carries the nuance.
+
+const ROOM_RULES = [
+ ['living-room', /\b(sofa|sectional|couch|coffee table|living room|loveseat|media console|tv stand)\b/i],
+ ['bedroom', /\b(bed|nightstand|dresser|headboard|bedroom|duvet|comforter|bedding|mattress)\b/i],
+ ['dining', /\b(dining|dinette|buffet|sideboard|bar stool|counter stool|dining table)\b/i],
+ ['kitchen', /\b(kitchen|cookware|pendant|island|backsplash)\b/i],
+ ['bathroom', /\b(bath|vanity|towel|shower|bathroom|faucet)\b/i],
+ ['office', /\b(desk|office chair|bookcase|filing|home office)\b/i],
+ ['outdoor', /\b(outdoor|patio|garden|adirondack|planter)\b/i],
+ ['lighting', /\b(lamp|chandelier|sconce|light|lighting)\b/i],
+ ['decor', /\b(vase|mirror|wall art|rug|pillow|throw|decor|curtain|clock)\b/i],
+];
+
+const STYLE_RULES = [
+ ['mid-century', /\b(mid.?century|mcm|eames|danish modern)\b/i],
+ ['modern', /\b(modern|contemporary|minimalist|sleek)\b/i],
+ ['traditional', /\b(traditional|classic|chesterfield|ornate|tufted)\b/i],
+ ['farmhouse', /\b(farmhouse|rustic|reclaimed|shaker)\b/i],
+ ['industrial', /\b(industrial|metal|pipe|loft)\b/i],
+ ['boho', /\b(boho|bohemian|rattan|macrame|woven|jute)\b/i],
+ ['coastal', /\b(coastal|nautical|beach|seaside)\b/i],
+ ['glam', /\b(glam|velvet|brass|gold leaf|mirrored|art deco|deco)\b/i],
+ ['scandinavian',/\b(scandi|scandinavian|nordic|hygge)\b/i],
+];
+
+const COLOR_RULES = [
+ ['neutral', /\b(white|ivory|cream|beige|oatmeal|greige|taupe|linen|natural|sand)\b/i],
+ ['gray', /\b(gray|grey|charcoal|slate|pewter)\b/i],
+ ['black', /\b(black|ebony|onyx|noir)\b/i],
+ ['brown', /\b(brown|walnut|espresso|cognac|tan|camel|oak|wood)\b/i],
+ ['blue', /\b(blue|navy|indigo|teal|cobalt)\b/i],
+ ['green', /\b(green|sage|olive|emerald|celadon|forest)\b/i],
+ ['pink', /\b(pink|blush|rose|mauve)\b/i],
+ ['yellow', /\b(yellow|gold|mustard|ochre)\b/i],
+ ['red', /\b(red|rust|terracotta|burgundy|crimson)\b/i],
+];
+
+function firstMatch(rules, text, fallback) {
+ for (const [bucket, re] of rules) if (re.test(text)) return bucket;
+ return fallback;
+}
+
+function classify(title = '', category = '') {
+ const text = `${title} ${category}`;
+ return {
+ room: firstMatch(ROOM_RULES, text, 'decor'),
+ style: firstMatch(STYLE_RULES, text, 'modern'),
+ color: firstMatch(COLOR_RULES, text, 'neutral'),
+ };
+}
+
+// --- The normalizer -------------------------------------------------------
+function toNumber(v) {
+ if (v === null || v === undefined || v === '') return null;
+ const n = parseFloat(String(v).replace(/[^0-9.]/g, ''));
+ return Number.isFinite(n) ? n : null;
+}
+
+/**
+ * @param {object} raw adapter-shaped fields (see below)
+ * @param {string} network one of VALID_NETWORKS
+ * @returns {object|null} DB-ready row, or null if it fails the minimum bar
+ */
+function normalizeProduct(raw, network) {
+ if (!VALID_NETWORKS.includes(network)) {
+ throw new Error(`normalizeProduct: unknown network "${network}"`);
+ }
+ const title = (raw.title || '').trim();
+ const affiliate_url = (raw.affiliate_url || '').trim();
+ // Minimum bar: a product with no title or no tracked link is useless to us.
+ if (!title || !affiliate_url || !raw.external_id) return null;
+
+ const category = (raw.category || '').trim();
+ const tax = classify(title, category);
+
+ return {
+ network,
+ advertiser: (raw.advertiser || '').trim() || null,
+ external_id: String(raw.external_id),
+ title,
+ description: (raw.description || '').trim() || null,
+ brand: (raw.brand || '').trim() || null,
+ category: category || null,
+ room: raw.room || tax.room,
+ style: raw.style || tax.style,
+ color: raw.color || tax.color,
+ price: toNumber(raw.price),
+ sale_price: toNumber(raw.sale_price),
+ currency: raw.currency || 'USD',
+ image_url: (raw.image_url || '').trim() || null,
+ affiliate_url,
+ in_stock: raw.in_stock !== false,
+ featured: !!raw.featured,
+ };
+}
+
+// UPSERT helper shared by every adapter + the seeder.
+const UPSERT_SQL = `
+ INSERT INTO products
+ (network, advertiser, external_id, title, description, brand, category,
+ room, style, color, price, sale_price, currency, image_url, affiliate_url,
+ in_stock, featured, price_checked_at, updated_at)
+ VALUES
+ ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now(), now())
+ ON CONFLICT (network, external_id) DO UPDATE SET
+ advertiser=EXCLUDED.advertiser, title=EXCLUDED.title, description=EXCLUDED.description,
+ brand=EXCLUDED.brand, category=EXCLUDED.category, room=EXCLUDED.room, style=EXCLUDED.style,
+ color=EXCLUDED.color, price=EXCLUDED.price, sale_price=EXCLUDED.sale_price,
+ currency=EXCLUDED.currency, image_url=EXCLUDED.image_url, affiliate_url=EXCLUDED.affiliate_url,
+ in_stock=EXCLUDED.in_stock, price_checked_at=now(), updated_at=now()
+ RETURNING id;
+`;
+
+function upsertParams(p) {
+ return [p.network, p.advertiser, p.external_id, p.title, p.description, p.brand,
+ p.category, p.room, p.style, p.color, p.price, p.sale_price, p.currency,
+ p.image_url, p.affiliate_url, p.in_stock, p.featured];
+}
+
+module.exports = { VALID_NETWORKS, classify, normalizeProduct, UPSERT_SQL, upsertParams };
diff --git a/lib/render.js b/lib/render.js
new file mode 100644
index 0000000..fd2761a
--- /dev/null
+++ b/lib/render.js
@@ -0,0 +1,86 @@
+// Server-rendered page shell. Server-rendering (not a client SPA) is deliberate:
+// affiliate sites live or die on SEO, and crawlers reward real HTML with proper
+// <title>/meta/canonical/JSON-LD over JS-hydrated shells.
+
+const SITE = {
+ name: 'Interior Designer’s Showroom',
+ tagline: 'A curated showroom of designer-worthy pieces — shop the look, room by room.',
+ url: process.env.PUBLIC_URL || 'https://interiordesignershowroom.com',
+};
+
+const esc = (s = '') => String(s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"');
+
+const money = (n) => (n == null ? '' : '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));
+
+// FTC 16 CFR Part 255 requires a clear, conspicuous disclosure. This is not optional.
+const DISCLOSURE_SHORT =
+ 'This showroom is reader-supported. When you buy through links on our site, we may earn an affiliate commission at no extra cost to you.';
+
+function disclosureBar() {
+ return `<div class="disclosure" role="note">${esc(DISCLOSURE_SHORT)} <a href="/disclosure">How this works.</a></div>`;
+}
+
+function productCard(p) {
+ const price = p.sale_price
+ ? `<span class="price sale">${money(p.sale_price)}</span> <span class="price was">${money(p.price)}</span>`
+ : `<span class="price">${money(p.price)}</span>`;
+ return `
+ <article class="card" data-room="${esc(p.room)}" data-style="${esc(p.style)}" data-color="${esc(p.color)}"
+ data-price="${p.sale_price ?? p.price ?? 0}" data-title="${esc(p.title)}" data-network="${esc(p.network)}">
+ <a class="card-img" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">
+ ${p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">` : `<div class="noimg">${esc(p.advertiser || '')}</div>`}
+ </a>
+ <div class="card-body">
+ <div class="card-brand">${esc(p.brand || p.advertiser || '')}</div>
+ <h3 class="card-title"><a href="/go/${p.id}" rel="nofollow sponsored" target="_blank">${esc(p.title)}</a></h3>
+ <div class="card-meta">${price}</div>
+ <a class="buy" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(p.advertiser || p.network)} →</a>
+ </div>
+ </article>`;
+}
+
+function layout({ title, description, canonical, jsonld, body, activeNav }) {
+ const nav = [
+ ['/shop', 'Shop'],
+ ['/rooms/living-room', 'Living'],
+ ['/rooms/bedroom', 'Bedroom'],
+ ['/rooms/dining', 'Dining'],
+ ['/guides', 'Guides'],
+ ].map(([href, label]) =>
+ `<a href="${href}"${activeNav === href ? ' class="active"' : ''}>${label}</a>`).join('');
+
+ return `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${esc(title)} · ${esc(SITE.name)}</title>
+<meta name="description" content="${esc(description || SITE.tagline)}">
+<link rel="canonical" href="${esc(canonical || SITE.url)}">
+<meta property="og:site_name" content="${esc(SITE.name)}">
+<meta property="og:title" content="${esc(title)}">
+<meta property="og:description" content="${esc(description || SITE.tagline)}">
+<meta property="og:type" content="website">
+<meta name="twitter:card" content="summary_large_image">
+<link rel="stylesheet" href="/css/site.css">
+${jsonld ? `<script type="application/ld+json">${JSON.stringify(jsonld)}</script>` : ''}
+</head>
+<body>
+${disclosureBar()}
+<header class="site-header">
+ <a class="brand" href="/"><span class="brand-mark">IDS</span><span class="brand-word">Interior Designer’s Showroom</span></a>
+ <nav class="site-nav">${nav}</nav>
+</header>
+<main>${body}</main>
+<footer class="site-footer">
+ <p>${esc(DISCLOSURE_SHORT)}</p>
+ <p><a href="/disclosure">Affiliate Disclosure</a> · <a href="/privacy">Privacy</a> · <a href="/guides">Buying Guides</a></p>
+ <p class="fine">© ${new Date().getFullYear()} ${esc(SITE.name)}. Prices and availability are accurate as of the date/time shown and are subject to change. As an Amazon Associate we earn from qualifying purchases.</p>
+</footer>
+</body>
+</html>`;
+}
+
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT };
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..c079f3e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "interiordesignershowroom",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Curated, editorial affiliate showroom for interior design — multi-network (CJ, Amazon, Rakuten, ShareASale) feed-driven catalog + buying guides.",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "node server.js",
+ "seed": "node scripts/seed.js",
+ "ingest": "node scripts/ingest.js",
+ "schema": "psql \"$DATABASE_URL\" -f db/schema.sql"
+ },
+ "dependencies": {
+ "express": "^4.19.2",
+ "pg": "^8.12.0"
+ },
+ "engines": { "node": ">=20" }
+}
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..6cbf99f
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,75 @@
+:root{
+ --ink:#1a1714; --muted:#6b6259; --line:#e7e1d8; --bg:#faf8f5; --card:#fff;
+ --accent:#8a7250; --cols:240px;
+ --serif:"Georgia","Times New Roman",serif; --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
+}
+*{box-sizing:border-box}
+body{margin:0;font-family:var(--sans);color:var(--ink);background:var(--bg);line-height:1.5}
+a{color:inherit;text-decoration:none}
+img{max-width:100%;display:block}
+h1,h2,h3{font-family:var(--serif);font-weight:600;letter-spacing:-.01em}
+main{max-width:1240px;margin:0 auto;padding:0 20px 64px}
+section{margin:44px 0}
+h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--line)}
+
+.disclosure{background:#1a1714;color:#f3ede3;font-size:.8rem;text-align:center;padding:7px 16px}
+.disclosure a{color:#e6c98a;text-decoration:underline}
+
+.site-header{display:flex;align-items:center;justify-content:space-between;max-width:1240px;margin:0 auto;padding:18px 20px;gap:20px}
+.brand{display:flex;align-items:center;gap:12px}
+.brand-mark{font-family:var(--serif);font-weight:700;background:var(--ink);color:#fff;width:38px;height:38px;display:grid;place-items:center;border-radius:3px;letter-spacing:.02em}
+.brand-word{font-family:var(--serif);font-size:1.05rem}
+.site-nav{display:flex;gap:22px;font-size:.92rem}
+.site-nav a{color:var(--muted)}
+.site-nav a.active,.site-nav a:hover{color:var(--ink)}
+
+.hero{text-align:center;padding:56px 20px;background:linear-gradient(180deg,#f2ece2,#faf8f5);border-radius:8px}
+.hero h1{font-size:2.6rem;margin:0 0 10px}
+.tagline{color:var(--muted);font-size:1.1rem;max-width:620px;margin:0 auto 22px}
+.cta{display:inline-block;background:var(--ink);color:#fff;padding:12px 26px;border-radius:40px;font-size:.95rem}
+.cta:hover{background:var(--accent)}
+
+.room-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}
+.room-tile{aspect-ratio:4/3;background:#efe8dd;border:1px solid var(--line);border-radius:6px;display:grid;place-items:center;font-family:var(--serif);font-size:1.1rem;transition:.2s}
+.room-tile:hover{background:#e6dccb;transform:translateY(-2px)}
+
+.grid-controls{display:flex;gap:24px;align-items:center;flex-wrap:wrap;margin-bottom:18px;font-size:.85rem;color:var(--muted)}
+.grid-controls select,.grid-controls input[type=range]{margin-left:6px}
+.grid-controls select{padding:5px 8px;border:1px solid var(--line);border-radius:4px;background:#fff;font-size:.85rem}
+
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cols),1fr));gap:18px}
+.card{background:var(--card);border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column;transition:.2s}
+.card:hover{border-color:#cfc4b2;box-shadow:0 6px 20px rgba(40,30,15,.07)}
+.card-img{aspect-ratio:1/1;background:#f1ece3;display:block}
+.card-img img{width:100%;height:100%;object-fit:cover}
+.noimg{width:100%;height:100%;display:grid;place-items:center;color:var(--muted);font-size:.8rem}
+.card-body{padding:12px 13px 14px;display:flex;flex-direction:column;gap:5px;flex:1}
+.card-brand{font-size:.68rem;text-transform:uppercase;letter-spacing:.08em;color:var(--accent)}
+.card-title{font-size:.95rem;font-family:var(--sans);font-weight:600;margin:0;line-height:1.3}
+.card-meta{margin-top:auto}
+.price{font-weight:700}
+.price.sale{color:#a33}
+.price.was{text-decoration:line-through;color:var(--muted);font-weight:400;font-size:.85rem}
+.buy{margin-top:8px;font-size:.82rem;color:var(--accent);font-weight:600}
+.buy:hover{text-decoration:underline}
+.subtle,.dek{color:var(--muted)}
+
+.guide-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:18px}
+.guide-card{background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden}
+.guide-card img{aspect-ratio:16/9;object-fit:cover;width:100%}
+.guide-card h3{margin:12px 14px 4px}
+.guide-card p{margin:0 14px 14px;color:var(--muted);font-size:.9rem}
+
+.guide{max-width:760px;margin:0 auto}
+.guide h1{font-size:2.1rem}
+.guide-hero{border-radius:8px;margin:18px 0}
+.guide-body{font-size:1.05rem}
+.guide-body h2{font-family:var(--serif)}
+.legal{max-width:720px;margin:0 auto}
+.legal p{color:#3a352e}
+
+.site-footer{border-top:1px solid var(--line);padding:32px 20px;text-align:center;color:var(--muted);font-size:.85rem}
+.site-footer a{text-decoration:underline}
+.site-footer .fine{font-size:.75rem;margin-top:10px;max-width:720px;margin-left:auto;margin-right:auto}
+
+@media(max-width:640px){.site-nav{gap:14px}.brand-word{display:none}.hero h1{font-size:2rem}}
diff --git a/public/js/grid.js b/public/js/grid.js
new file mode 100644
index 0000000..d1de656
--- /dev/null
+++ b/public/js/grid.js
@@ -0,0 +1,43 @@
+// Client-side sort + density for any product grid. Reads data-* attributes off the
+// server-rendered cards (so the initial paint is SEO-clean) and re-orders in place.
+// Both choices persist in localStorage per Steve's standing grid rule.
+(function () {
+ var grid = document.getElementById('grid');
+ var sortSel = document.getElementById('sortSel');
+ var densitySel = document.getElementById('densitySel');
+ if (!grid) return;
+
+ var LS_SORT = 'ids.sort', LS_DENSITY = 'ids.density';
+
+ function applyDensity(px) {
+ document.documentElement.style.setProperty('--cols', px + 'px');
+ if (densitySel) densitySel.value = px;
+ localStorage.setItem(LS_DENSITY, px);
+ }
+
+ function num(el, a) { return parseFloat(el.getAttribute(a)) || 0; }
+ function str(el, a) { return (el.getAttribute(a) || '').toLowerCase(); }
+
+ function sortBy(mode) {
+ var cards = Array.prototype.slice.call(grid.children);
+ var cmp = {
+ 'newest': function () { return 0; }, // server order = newest first
+ 'price-asc': function (a, b) { return num(a, 'data-price') - num(b, 'data-price'); },
+ 'price-desc': function (a, b) { return num(b, 'data-price') - num(a, 'data-price'); },
+ 'color': function (a, b) { return str(a, 'data-color').localeCompare(str(b, 'data-color')); },
+ 'style': function (a, b) { return str(a, 'data-style').localeCompare(str(b, 'data-style')); },
+ 'brand': function (a, b) { return str(a, 'data-title').localeCompare(str(b, 'data-title')); },
+ 'title': function (a, b) { return str(a, 'data-title').localeCompare(str(b, 'data-title')); },
+ }[mode];
+ if (cmp && mode !== 'newest') cards.sort(cmp);
+ cards.forEach(function (c) { grid.appendChild(c); });
+ if (sortSel) sortSel.value = mode;
+ localStorage.setItem(LS_SORT, mode);
+ }
+
+ if (sortSel) sortSel.addEventListener('change', function () { sortBy(sortSel.value); });
+ if (densitySel) densitySel.addEventListener('input', function () { applyDensity(densitySel.value); });
+
+ applyDensity(localStorage.getItem(LS_DENSITY) || 240);
+ sortBy(localStorage.getItem(LS_SORT) || 'newest');
+})();
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..bbcc667
--- /dev/null
+++ b/server.js
@@ -0,0 +1,178 @@
+const express = require('express');
+const path = require('path');
+const db = require('./lib/db');
+const { SITE, esc, layout, productCard } = require('./lib/render');
+
+const app = express();
+const PORT = process.env.PORT || 9820;
+
+app.use('/css', express.static(path.join(__dirname, 'public/css')));
+app.use('/js', express.static(path.join(__dirname, 'public/js')));
+app.use('/img', express.static(path.join(__dirname, 'public/img')));
+
+const ROOMS = [
+ ['living-room', 'Living Room'], ['bedroom', 'Bedroom'], ['dining', 'Dining'],
+ ['office', 'Office'], ['lighting', 'Lighting'], ['decor', 'Decor'], ['outdoor', 'Outdoor'],
+];
+
+// --- tiny markdown-lite for guide bodies (headings, bold, paragraphs) ------
+function md(src = '') {
+ return src.split(/\n\n+/).map((block) => {
+ const b = block.trim();
+ if (/^### /.test(b)) return `<h3>${esc(b.slice(4))}</h3>`;
+ if (/^## /.test(b)) return `<h2>${esc(b.slice(3))}</h2>`;
+ const html = esc(b).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
+ return `<p>${html.replace(/\n/g, '<br>')}</p>`;
+ }).join('\n');
+}
+
+// ---- Sort + density controls (Steve's standing rule for every product grid) ----
+function gridControls() {
+ return `
+ <div class="grid-controls">
+ <label>Sort
+ <select id="sortSel">
+ <option value="newest">Newest</option>
+ <option value="color">Color</option>
+ <option value="style">Style</option>
+ <option value="brand">Brand A→Z</option>
+ <option value="title">Title A→Z</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="price-desc">Price ↓</option>
+ </select>
+ </label>
+ <label class="density">Density
+ <input id="densitySel" type="range" min="140" max="360" step="20" value="240">
+ </label>
+ </div>`;
+}
+
+function grid(products) {
+ return `<div class="grid" id="grid">${products.map(productCard).join('')}</div>`;
+}
+
+// -------------------------- Routes ----------------------------------------
+app.get('/healthz', (_req, res) => res.status(200).send('ok'));
+
+app.get('/', async (_req, res, next) => {
+ try {
+ const [{ rows: featured }, { rows: latest }, { rows: guides }] = await Promise.all([
+ db.query(`SELECT * FROM products WHERE featured=TRUE AND in_stock ORDER BY created_at DESC LIMIT 8`),
+ db.query(`SELECT * FROM products WHERE in_stock ORDER BY created_at DESC LIMIT 12`),
+ db.query(`SELECT slug,title,dek,hero_image FROM guides WHERE published ORDER BY created_at DESC LIMIT 3`),
+ ]);
+ const roomTiles = ROOMS.slice(0, 6).map(([slug, label]) =>
+ `<a class="room-tile" href="/rooms/${slug}"><span>${esc(label)}</span></a>`).join('');
+ const guideCards = guides.map((g) =>
+ `<a class="guide-card" href="/guides/${esc(g.slug)}">
+ ${g.hero_image ? `<img loading="lazy" src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+ <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
+ const body = `
+ <section class="hero">
+ <h1>${esc(SITE.name)}</h1>
+ <p class="tagline">${esc(SITE.tagline)}</p>
+ <a class="cta" href="/shop">Enter the showroom →</a>
+ </section>
+ <section class="rooms"><h2>Shop by room</h2><div class="room-grid">${roomTiles}</div></section>
+ ${featured.length ? `<section><h2>Editor’s picks</h2>${grid(featured)}</section>` : ''}
+ ${guideCards ? `<section class="guides-strip"><h2>Buying guides</h2><div class="guide-grid">${guideCards}</div></section>` : ''}
+ <section><h2>New in the showroom</h2>${grid(latest)}</section>`;
+ res.send(layout({ title: 'Curated Designer Furniture & Decor', description: SITE.tagline, canonical: SITE.url, body, activeNav: '/' }));
+ } catch (e) { next(e); }
+});
+
+app.get('/shop', async (_req, res, next) => {
+ try {
+ const { rows } = await db.query(`SELECT * FROM products WHERE in_stock ORDER BY created_at DESC LIMIT 200`);
+ const body = `<section><h1>The Showroom</h1><p class="subtle">${rows.length} curated pieces across every room.</p>
+ ${gridControls()}${grid(rows)}</section><script src="/js/grid.js"></script>`;
+ res.send(layout({ title: 'Shop the Showroom', description: 'Browse every curated piece — sort by color, style, or price.', canonical: `${SITE.url}/shop`, body, activeNav: '/shop' }));
+ } catch (e) { next(e); }
+});
+
+app.get('/rooms/:room', async (req, res, next) => {
+ try {
+ const room = req.params.room;
+ const label = (ROOMS.find(([s]) => s === room) || [null, room])[1];
+ const { rows } = await db.query(`SELECT * FROM products WHERE room=$1 AND in_stock ORDER BY created_at DESC LIMIT 200`, [room]);
+ const body = `<section><h1>${esc(label)}</h1><p class="subtle">${rows.length} pieces for the ${esc(label.toLowerCase())}.</p>
+ ${gridControls()}${grid(rows)}</section><script src="/js/grid.js"></script>`;
+ res.send(layout({ title: `${label} Furniture & Decor`, description: `Curated ${label.toLowerCase()} pieces from top design brands.`, canonical: `${SITE.url}/rooms/${room}`, body, activeNav: `/rooms/${room}` }));
+ } catch (e) { next(e); }
+});
+
+app.get('/guides', async (_req, res, next) => {
+ try {
+ const { rows } = await db.query(`SELECT slug,title,dek,hero_image FROM guides WHERE published ORDER BY created_at DESC`);
+ const cards = rows.map((g) =>
+ `<a class="guide-card" href="/guides/${esc(g.slug)}">
+ ${g.hero_image ? `<img loading="lazy" src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+ <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
+ const body = `<section><h1>Buying Guides</h1><div class="guide-grid">${cards || '<p>Guides coming soon.</p>'}</div></section>`;
+ res.send(layout({ title: 'Interior Design Buying Guides', description: 'Editorial guides: how to choose, shop-the-look, and the best of every category.', canonical: `${SITE.url}/guides`, body, activeNav: '/guides' }));
+ } catch (e) { next(e); }
+});
+
+app.get('/guides/:slug', async (req, res, next) => {
+ try {
+ const { rows } = await db.query(`SELECT * FROM guides WHERE slug=$1 AND published`, [req.params.slug]);
+ if (!rows.length) return next();
+ const g = rows[0];
+ let picks = [];
+ if (g.product_ids && g.product_ids.length) {
+ const r = await db.query(`SELECT * FROM products WHERE id = ANY($1)`, [g.product_ids]);
+ picks = r.rows;
+ }
+ const jsonld = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: g.hero_image, datePublished: g.created_at };
+ const body = `<article class="guide">
+ <h1>${esc(g.title)}</h1><p class="dek">${esc(g.dek || '')}</p>
+ ${g.hero_image ? `<img class="guide-hero" src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+ <div class="guide-body">${md(g.body_md || '')}</div>
+ ${picks.length ? `<h2>Shop this guide</h2><div class="grid">${picks.map(productCard).join('')}</div>` : ''}
+ </article>`;
+ res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, body, activeNav: '/guides' }));
+ } catch (e) { next(e); }
+});
+
+// Affiliate go-through: log the click (analytics + Amazon audit trail), then 302 to the tracked link.
+app.get('/go/:id', async (req, res, next) => {
+ try {
+ const { rows } = await db.query(`SELECT id, network, advertiser, affiliate_url FROM products WHERE id=$1`, [req.params.id]);
+ if (!rows.length) return next();
+ const p = rows[0];
+ db.query(`INSERT INTO clicks (product_id, network, advertiser, referer, ua) VALUES ($1,$2,$3,$4,$5)`,
+ [p.id, p.network, p.advertiser, req.get('referer') || null, req.get('user-agent') || null]).catch(() => {});
+ res.redirect(302, p.affiliate_url);
+ } catch (e) { next(e); }
+});
+
+app.get('/disclosure', (_req, res) => {
+ const body = `<article class="legal"><h1>Affiliate Disclosure</h1>
+ <p>Interior Designer’s Showroom is a participant in affiliate advertising programs — including the Amazon Associates Program, Commission Junction (CJ), Rakuten Advertising, and ShareASale/Impact — designed to provide a means for sites to earn advertising fees.</p>
+ <p><strong>When you click a product link and make a purchase, we may earn a commission at no additional cost to you.</strong> This never changes the price you pay, and it never influences which products we feature — every piece is chosen for design merit first.</p>
+ <p>Prices and availability shown are pulled from our partners and are accurate as of the date and time displayed; they are subject to change. As an Amazon Associate we earn from qualifying purchases.</p></article>`;
+ res.send(layout({ title: 'Affiliate Disclosure', description: 'How Interior Designer’s Showroom earns affiliate commissions.', canonical: `${SITE.url}/disclosure`, body }));
+});
+
+app.get('/privacy', (_req, res) => {
+ const body = `<article class="legal"><h1>Privacy</h1>
+ <p>We log anonymous click events (which product link was clicked, referring page, browser type) to understand what our readers find useful. We do not sell personal data. Affiliate partners may set their own cookies when you visit their sites after clicking through.</p></article>`;
+ res.send(layout({ title: 'Privacy', description: 'Privacy practices for Interior Designer’s Showroom.', canonical: `${SITE.url}/privacy`, body }));
+});
+
+app.get('/sitemap.xml', async (_req, res, next) => {
+ try {
+ const { rows: guides } = await db.query(`SELECT slug, updated_at FROM guides WHERE published`);
+ const urls = [
+ '', 'shop', 'guides', 'disclosure', 'privacy',
+ ...ROOMS.map(([s]) => `rooms/${s}`),
+ ...guides.map((g) => `guides/${g.slug}`),
+ ].map((u) => `<url><loc>${SITE.url}/${u}</loc></url>`).join('');
+ res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
+ } catch (e) { next(e); }
+});
+
+app.use((_req, res) => res.status(404).send(layout({ title: 'Not found', body: '<section><h1>Not found</h1><p><a href="/">Back to the showroom →</a></p></section>' })));
+app.use((err, _req, res, _next) => { console.error(err); res.status(500).send(layout({ title: 'Error', body: '<section><h1>Something went wrong</h1></section>' })); });
+
+app.listen(PORT, () => console.log(`[IDS] listening on http://127.0.0.1:${PORT}`));
(oldest)
·
back to Interiordesignershowroom
·
Scaffold interiordesignershowroom: multi-network affiliate s 7f2672c →