← back to Quadrille Internal

server.js

158 lines

/* Astek editorial vendor-landing — Designer Wallcoverings house brand.
   INTERNAL DATA ONLY: catalog is dw_unified.astek_catalog (NOT on Shopify).
   Self-contained: reads data/products.json. Links resolve to this site's OWN
   internal PDPs and a memo-sample/inquiry CTA — never Shopify, never astek.com. */
const express = require('express');
const fs = require('fs');
const path = require('path');

const PORT = process.env.PORT || 0; // 0 = OS-assigned free port
const DATA = path.join(__dirname, 'data', 'products.json');

const app = express();
app.use(require('compression')());

// HTTP Basic Auth — internal-only gate (username + password). Override via
// BASIC_AUTH="user:pass"; defaults to the DW standard admin / DW2024!.
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
app.use((req, res, next) => {
  const hdr = req.headers.authorization || '';
  const [scheme, b64] = hdr.split(' ');
  if (scheme === 'Basic' && b64) {
    const [u, p] = Buffer.from(b64, 'base64').toString('utf8').split(':');
    if (u === AUTH_USER && p === AUTH_PASS) return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="Quadrille House - Designer Wallcoverings (internal)"');
  return res.status(401).send('Authentication required.');
});

app.use(express.json({ limit: '2mb' }));

let SNAP = { products: [], facets: { total: 0, books: [], series: [], colors: [] } };
let LIGHT = []; // grid-index payload — heavy PDP-only fields stripped
let LAST_REFRESH = new Date().toISOString();
const REFRESH_INTERVAL_SEC = Number(process.env.REFRESH_INTERVAL_SEC || 900); // 15 min, matches the cron
function load() {
  SNAP = JSON.parse(fs.readFileSync(DATA, 'utf8'));
  // the index grid never renders body_html / images[] / eyebrow — keep those PDP-only
  LIGHT = SNAP.products.map(({ body_html, images, display_eyebrow, published_at, ...p }) => p);
  try { LAST_REFRESH = fs.statSync(DATA).mtime.toISOString(); } catch { LAST_REFRESH = new Date().toISOString(); }
  console.log(`[quadrille] loaded ${SNAP.products.length} products (data mtime ${LAST_REFRESH})`);
}
load();
// Hot-reload when the 15-min cron rewrites data/products.json — no restart needed.
fs.watchFile(DATA, { interval: 5000 }, (cur, prev) => {
  if (cur.mtimeMs !== prev.mtimeMs) { try { load(); } catch (e) { console.error('[quadrille] reload failed:', e.message); } }
});

// House identity — Designer Wallcoverings is the brand; Astek is the featured line.
const CONFIG = {
  house: 'Designer Wallcoverings',
  houseUrl: 'https://www.designerwallcoverings.com',
  nav: [
    { label: 'The Collection', href: '#collections' },
    { label: 'Designer Wallcoverings', href: 'https://www.designerwallcoverings.com' },
  ],
  vendor: 'Quadrille',
  line: 'Quadrille House',
  wordmark: 'Designer Wallcoverings',
  eyebrow: 'Internal Catalog — Designer Wallcoverings',
  kicker: 'Internal use only',
  tagline: 'The Quadrille family — China Seas, Alan Campbell, Home Couture, and more — internal purchasing viewer.',
  booksHeading: 'The Quadrille House Collection',
  title: 'Quadrille House — Internal Catalog | Designer Wallcoverings',
  metaDescription: 'Internal Quadrille House catalog viewer — Designer Wallcoverings purchasing.',
  slug: 'quadrille',
  palette: null,
  about: {
    paragraphs: [
      'The Quadrille HOUSE spans eight lines: Quadrille, Alan Campbell, Suncloth, Home Couture, Plains, Charles Burger, Cloth and Paper, and Grasscloth.',
      'Quadrille shows no prices online — use Get Price to email the vendor on our account.',
    ],
    collab: 'Internal viewer — Designer Wallcoverings purchasing.',
  },
};

// vendorMeta (phone / our account # / discount / pricing model) rides in from
// data/products.json — the 15-min refresh keeps it current from PG.
app.get('/api/config', (_req, res) => res.json({ ...CONFIG, vendorMeta: SNAP.vendor || null }));
// Refresh metadata — drives the corner countdown pill on the landing.
app.get('/api/meta', (_req, res) => res.json({
  lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
  count: SNAP.products.length, now: new Date().toISOString(),
}));
app.get('/api/products', (_req, res) => res.json({ count: LIGHT.length, products: LIGHT }));
app.get('/api/facets', (_req, res) => res.json(SNAP.facets));

app.get('/api/product/:handle', (req, res) => {
  const p = SNAP.products.find(x => x.handle === req.params.handle);
  if (!p) return res.status(404).json({ error: 'not found' });
  res.json(p);
});

// "Pairs well with" — same/adjacent color family, different pattern (contrast of scale). Up to 6.
app.get('/api/pairs/:handle', (req, res) => {
  const p = SNAP.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 = SNAP.products.filter(x => x.handle !== p.handle && x.series !== p.series).map(x => {
    let s = 0;
    if (x.color_bucket && x.color_bucket === p.color_bucket) s += 40;
    s += Math.max(0, 30 - hueDist(x.hue, p.hue) / 2);
    s += 20; // always a different pattern (filtered above) — reward scale contrast
    if (x.book && x.book === p.book) s += 8;
    return { x, s };
  }).sort((a, b) => b.s - a.s);
  // de-dup by series so pairs aren't 6 colorways of one pattern
  const seen = new Set(), out = [];
  for (const o of scored) { if (seen.has(o.x.series)) continue; seen.add(o.x.series); out.push(o.x); if (out.length === 6) break; }
  res.json({ pairs: out });
});

// Internal purchasing requests — Request Memo / Check Stock / Get Price.
// REQ-numbered in dw_unified.vendor_requests; stock/price auto-email the vendor
// via george-gmail when an email is on file (account # only, never a client name).
const { mountVendorRequests } = require('./lib/vendor-requests');
mountVendorRequests(app, { vendorCode: 'quadrille', getVendor: () => SNAP.vendor || {}, dataDir: path.join(__dirname, 'data') });

// Legacy inquiry endpoint — kept for anything still posting here; logs only.
const ACTION_MSG = {
  memo: 'Memo request logged — purchasing will order the sample.',
  stock: 'Stock check logged — purchasing will confirm availability with the vendor.',
  price: 'Price request logged — purchasing will confirm current pricing with the vendor.',
};
app.post('/api/inquiry', (req, res) => {
  const { sku, name, email, note } = req.body || {};
  const type = ['memo', 'stock', 'price'].includes(req.body && req.body.type) ? req.body.type : 'memo';
  if (!sku || !email) return res.status(400).json({ ok: false, error: 'sku and email required' });
  const rec = { at: new Date().toISOString(), type, sku, name: name || '', email, note: note || '', ip: req.ip };
  try {
    fs.appendFileSync(path.join(__dirname, 'data', 'inquiries.jsonl'), JSON.stringify(rec) + '\n');
  } catch (e) { return res.status(500).json({ ok: false, error: 'log failed' }); }
  res.json({ ok: true, message: ACTION_MSG[type] });
});

// ── Private-label curation (Astek → Phillipe Romano) ─────────────────────
// Selection is the FIRST step only — picks persist server-side so the later
// (Steve-gated) private-label push has a durable list. NO Shopify writes here.
const SEL = path.join(__dirname, 'data', 'selection.json');
app.get('/api/selection', (_req, res) => {
  try { res.json(JSON.parse(fs.readFileSync(SEL, 'utf8'))); }
  catch { res.json({ updated_at: null, count: 0, skus: [] }); }
});
app.post('/api/selection', (req, res) => {
  const skus = Array.isArray(req.body?.skus) ? req.body.skus.filter(s => typeof s === 'string').slice(0, 20000) : null;
  if (!skus) return res.status(400).json({ ok: false, error: 'skus[] required' });
  const doc = { updated_at: new Date().toISOString(), label: 'Phillipe Romano candidates', count: skus.length, skus };
  fs.writeFileSync(SEL, JSON.stringify(doc, null, 1));
  res.json({ ok: true, count: skus.length });
});

app.use(express.static(path.join(__dirname, 'public')));
app.get('/product/:handle', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'product.html')));
app.get('/curate', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'curate.html')));

const server = app.listen(PORT, () => {
  console.log(`Quadrille House internal viewer → http://127.0.0.1:${server.address().port}`);
});