← 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}`));