← back to Quadrille House Site
server.js
147 lines
/* Quadrille HOUSE — multi-brand editorial lookbook over the Quadrille family of lines,
served under Designer Wallcoverings house chrome at quadrille.designerwallcoverings.com.
- House brand = Designer Wallcoverings (corner wordmark, nav, footer).
- Featured = the Quadrille HOUSE: China Seas, Quadrille, Alan Campbell, Home Couture,
Charles Burger, Lulu DK, Cloth & Paper, Suncloth, Plains, Grasscloth (REAL brand names).
- brand/line FILTER switches between lines.
- Two CTA modes: live (China Seas → real DW product link) | quote (held net-new → memo/quote).
- NO prices anywhere on the landing.
- Lookbook section surfaces 32 harvested PDFs + room imagery.
*/
const express = require('express');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 9943;
const DATA = path.join(__dirname, 'data');
const STORE_BASE = 'https://www.designerwallcoverings.com';
let HOUSE = { products: [], brands: [] };
let LOOKBOOK = { lookbooks: [], rooms: [] };
// Settlement gate (legal, HARD): never render banana-leaf / tropical-foliage /
// bird / butterfly / grape motifs — the settlement-trigger families. Word-boundary
// match on title/series/color/display_name; "leaf" only as a motif (keep "Leaf
// Green/Greens" colorways). Filtering at load means flagged products are absent
// from EVERY endpoint (/api/products, /api/facets, /api/pairs) and the hero at once.
const SETTLEMENT_BLOCK = /\b(banana|palm|frond|tropical|foliage|bird|butterfly|grape)\b/i;
const LEAF_MOTIF = /\bleaf(?!\s+greens?\b)/i;
function settlementSafe(p) {
const t = `${p.title || ''} ${p.series || ''} ${p.color || ''} ${p.display_name || ''}`;
return !SETTLEMENT_BLOCK.test(t) && !LEAF_MOTIF.test(t);
}
function load() {
HOUSE = JSON.parse(fs.readFileSync(path.join(DATA, 'house.json'), 'utf8'));
const before = HOUSE.products.length;
HOUSE.products = HOUSE.products.filter(settlementSafe);
const removed = before - HOUSE.products.length;
try { LOOKBOOK = JSON.parse(fs.readFileSync(path.join(DATA, 'lookbook.json'), 'utf8')); } catch {}
// scrub settlement-triggering room shots from the hero/lookbook pool too
if (LOOKBOOK.rooms) LOOKBOOK.rooms = LOOKBOOK.rooms.filter(r => settlementSafe({ title: r.src, color: r.caption }));
console.log(`[quadrille-house] ${HOUSE.products.length} products (settlement-scrubbed ${removed}) · ${HOUSE.brands.length} brands · ${LOOKBOOK.lookbooks.length} lookbooks · ${LOOKBOOK.rooms.length} room shots`);
}
load();
// --- per-brand About copy (real brand heritage, framed "in collaboration with DW").
// Factual, no invented dates/awards; no upstream-sourcing leaks. (standing rule) ---
const ABOUT = require('./about.js');
const app = express();
app.use(express.json());
const NAV = [
{ label: 'All Wallcoverings', href: `${STORE_BASE}/collections/all` },
{ label: 'The House', href: '#catalog' },
{ label: 'Lookbook', href: '#lookbook' },
{ label: 'Trade', href: `${STORE_BASE}/pages/trade` },
];
app.get('/api/config', (_req, res) => res.json({
house: 'Designer Wallcoverings',
houseUrl: STORE_BASE,
houseTagline: 'Designer Wallcoverings & Fabrics · To the Trade',
nav: NAV,
line: 'The Quadrille House',
wordmark: 'Designer Wallcoverings',
eyebrow: 'A Designer Wallcoverings Collection',
kicker: 'Hand-screened American textiles & wallcoverings',
tagline: 'China Seas · Quadrille · Alan Campbell · Home Couture · Charles Burger · Cloth & Paper · Suncloth · Plains · Grasscloth',
booksHeading: 'The Quadrille House',
title: 'The Quadrille House — China Seas, Alan Campbell & more | Designer Wallcoverings',
metaDescription: 'The full Quadrille house of hand-screened American wallcoverings and fabrics — China Seas, Quadrille, Alan Campbell, Home Couture, Charles Burger, Cloth & Paper, Suncloth, Plains & Grasscloth — curated by Designer Wallcoverings, to the trade.',
storeBase: STORE_BASE,
collectionUrl: `${STORE_BASE}/collections/china-seas`,
slug: 'quadrille',
palette: { bg: '#f5f1ea', paper: '#fffdf9', ink: '#1f1c17', accent: '#9c6b4f', gold: '#b08440', line: '#e3dccf', taupe: '#8a8071' },
brands: HOUSE.brands, // [ [brand, count], ... ]
about: ABOUT,
}));
app.get('/api/products', (req, res) => {
const brand = req.query.brand;
let items = HOUSE.products;
if (brand && brand !== 'all') items = items.filter(p => p.brand === brand);
res.json({ count: items.length, products: items });
});
app.get('/api/product/:handle', (req, res) => {
const p = HOUSE.products.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).json({ error: 'not found' });
res.json(p);
});
// "Pairs well with" — 60-30-10 interior logic: same/adjacent color family, contrast of
// scale (different pattern series), prefer SAME brand for a coherent house look. Up to 6.
app.get('/api/pairs/:handle', (req, res) => {
const p = HOUSE.products.find(x => x.handle === req.params.handle);
if (!p) return res.status(404).json({ error: 'not found' });
const hueDist = (a, b) => { if (a == null || b == null) return 180; const d = Math.abs(a - b) % 360; return d > 180 ? 360 - d : d; };
const scored = HOUSE.products.filter(x => x.handle !== p.handle).map(x => {
let s = 0;
if (x.brand === p.brand) s += 18; // same line = coherent
if (x.color_bucket && x.color_bucket === p.color_bucket) s += 40; // same color family
s += Math.max(0, 30 - hueDist(x.hue, p.hue) / 2); // hue proximity
if (x.series && x.series !== p.series) s += 20; // contrast of scale
if (x.color_bucket && ['grey', 'white', 'black'].includes(x.color_bucket) && !['grey', 'white', 'black'].includes(p.color_bucket)) s += 8;
return { x, s };
}).sort((a, b) => b.s - a.s).slice(0, 6).map(o => o.x);
res.json({ pairs: scored });
});
app.get('/api/facets', (req, res) => {
const brand = req.query.brand;
let items = HOUSE.products;
if (brand && brand !== 'all') items = items.filter(p => p.brand === brand);
const series = {}, colors = {}, brands = {};
for (const p of items) {
if (p.series) series[p.series] = (series[p.series] || 0) + 1;
if (p.color_bucket) colors[p.color_bucket] = (colors[p.color_bucket] || 0) + 1;
brands[p.brand] = (brands[p.brand] || 0) + 1;
}
res.json({
total: items.length,
brands: HOUSE.brands,
series: Object.entries(series).sort((a, b) => b[1] - a[1]).slice(0, 60),
colors: Object.entries(colors).sort((a, b) => b[1] - a[1]),
});
});
app.get('/api/lookbook', (_req, res) => res.json(LOOKBOOK));
// Memo-sample / quote request (to-the-trade, NO public price) for HELD brands.
// Local stub: logs the request. (Real send is gated through vp-compliance-policy.)
app.post('/api/quote', (req, res) => {
const { sku, brand, pattern, color, name, email, company, message } = req.body || {};
if (!email || !sku) return res.status(400).json({ error: 'email and sku required' });
const line = JSON.stringify({ at: new Date().toISOString(), sku, brand, pattern, color, name, email, company, message });
try { fs.appendFileSync(path.join(DATA, 'quote-requests.log.jsonl'), line + '\n'); } catch {}
res.json({ ok: true, message: 'Request received. Our trade team will follow up with memo-sample availability and pricing.' });
});
// images / pdfs (lookbooks + curated room shots) shipped in public/
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
app.get('/product/:handle', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'product.html')));
app.listen(PORT, () => console.log(`Quadrille house → http://127.0.0.1:${PORT}`));