← back to Astek Landing

server.js

161 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')());

// Unauthenticated health probe (deploy smoke test + uptime monitors) — BEFORE the auth gate.
app.get('/healthz', (_req, res) => res.json({ ok: true, products: SNAP.products.length, dataMtime: LAST_REFRESH }));

// 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="Astek - 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(`[astek] 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('[astek] 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: 'Astek',
  line: 'Astek',
  wordmark: 'Designer Wallcoverings',
  eyebrow: 'A Designer Wallcoverings Collection',
  kicker: 'Digitally Printed · Made in the USA',
  tagline: 'Bold, painterly, made-to-order wallcoverings — curated by Designer Wallcoverings.',
  booksHeading: 'The Astek Collection',
  title: 'Astek — A Designer Wallcoverings Collection',
  metaDescription: 'The Astek wallcovering collection at Designer Wallcoverings. To the trade — order a memo sample before specifying.',
  slug: 'astek',
  palette: null,
  about: {
    paragraphs: [
      'Astek is an American design house known for its painterly, made-to-order digitally printed wallcoverings — a studio where fine-art sensibility meets architectural surface. From sweeping chinoiserie murals to abstract washes and modern geometrics, each pattern is printed to order and finished for interiors that ask to be remembered.',
      'The Astek collection spans traditional botanicals, contemporary abstracts, and statement murals — colorways developed for both residential and hospitality specification.',
    ],
    collab: 'The Astek collection, presented in collaboration with Designer Wallcoverings.',
  },
};

// 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: 'astek', 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(`Astek landing → http://127.0.0.1:${server.address().port}`);
});