← back to Dw Hero Admin

server.js

211 lines

/**
 * dw-hero-admin :9772
 *
 * Per-DW-site admin for the 2×2 rotating hero grid:
 *   - lists all 53 DW sister sites
 *   - shows each site's products sorted NEWEST FIRST
 *   - drag products into 4 slots (room/pattern/room/pattern)
 *   - writes per-site hero-4grid.json AND mirrors to prod /var/www/<site>.com/data/
 *
 * Hard rule: never displays a product image with a header band — applies the
 * `object-fit:cover; object-position:center 12%` crop universally.
 */
const express = require('express');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');

const PORT = process.env.PORT || 9772;
const PROJECTS_DIR = process.env.PROJECTS_DIR || path.join(process.env.HOME, 'Projects');
const REMOTE_HOST = process.env.REMOTE_HOST || 'root@45.61.58.125';

// Canonical 53-site list — same source as /tmp/dw-sites.txt (auto-discovers
// every project under ~/Projects/ whose public/index.html has nsContactOpen
// or .ns-modal markers — i.e. the DW family).
function loadTossedDomains() {
  try {
    const buf = fs.readFileSync(path.join(PROJECTS_DIR, '_shared', 'data', 'dw-tossed-domains.json'), 'utf8');
    return new Set((JSON.parse(buf).tossed || []).map(t => t.domain.replace(/\.com$/, '')));
  } catch (e) { return new Set(); }
}

function discoverSites() {
  const tossed = loadTossedDomains();
  const sites = [];
  const entries = fs.readdirSync(PROJECTS_DIR, { withFileTypes: true });
  for (const e of entries) {
    if (!e.isDirectory()) continue;
    if (tossed.has(e.name)) continue; // honor toss list
    const idx = path.join(PROJECTS_DIR, e.name, 'public', 'index.html');
    if (!fs.existsSync(idx)) continue;
    try {
      const buf = fs.readFileSync(idx, 'utf8');
      if (buf.includes('nsContactOpen') || buf.includes('ns-modal')) {
        sites.push(e.name);
      }
    } catch (err) { /* skip */ }
  }
  return sites.sort();
}

let SITES = discoverSites();
console.log(`[hero-admin] discovered ${SITES.length} DW sites`);

const app = express();
app.use(express.json({ limit: '2mb' }));
app.use(express.static(path.join(__dirname, 'public')));

// ── list sites ───────────────────────────────────────────────────────────
app.get('/api/sites', (req, res) => {
  res.json({ sites: SITES });
});

// ── per-site products + current grid config ──────────────────────────────
app.get('/api/site/:site', (req, res) => {
  const site = req.params.site;
  if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
  const projDir = path.join(PROJECTS_DIR, site);
  const productsPath = path.join(projDir, 'data', 'products.json');
  const gridPath = path.join(projDir, 'public', 'hero-4grid.json');
  let products = [];
  try {
    const raw = fs.readFileSync(productsPath, 'utf8');
    products = JSON.parse(raw);
  } catch (e) {
    return res.status(500).json({ error: 'cannot read products.json', detail: e.message });
  }
  // Newest first — products.json doesn't have created_at, so the LAST entries
  // in the file are typically the most-recently-added by the catalog sync.
  // We reverse to put them first.
  products = products.slice().reverse();

  // Drop products without image_url or with obvious header-watermarked sources.
  products = products.filter(p => p.image_url && /^https:/.test(p.image_url));

  let grid = null;
  try { grid = JSON.parse(fs.readFileSync(gridPath, 'utf8')); } catch (e) { grid = null; }

  res.json({
    site,
    productCount: products.length,
    // Trim payload — only ship the fields the admin UI needs.
    products: products.slice(0, 500).map(p => ({
      sku: p.sku,
      handle: p.handle,
      title: p.title,
      vendor: p.vendor || '',
      image_url: p.image_url,
      product_type: p.product_type || '',
      tags: Array.isArray(p.tags) ? p.tags.slice(0, 6) : [],
    })),
    grid: grid || defaultGrid(products),
  });
});

// Default — first 4 valid products: room, pattern, room, pattern slots.
// Steve will reassign via drag-drop.
function defaultGrid(products) {
  const picks = products.slice(0, 4);
  return {
    cells: [
      { kind: 'room',    sku: picks[0]?.sku || null, image_url: picks[0]?.image_url || null, title: picks[0]?.title || null },
      { kind: 'pattern', sku: picks[1]?.sku || null, image_url: picks[1]?.image_url || null, title: picks[1]?.title || null },
      { kind: 'room',    sku: picks[2]?.sku || null, image_url: picks[2]?.image_url || null, title: picks[2]?.title || null },
      { kind: 'pattern', sku: picks[3]?.sku || null, image_url: picks[3]?.image_url || null, title: picks[3]?.title || null },
    ],
    rotation_pool: products.slice(0, 16).map(p => ({ sku: p.sku, image_url: p.image_url, title: p.title })),
    autocycle_seconds: 6,
    updated_at: null,
  };
}

// ── save grid for a site ────────────────────────────────────────────────
// Schema check — req.body must have `cells` array of length 4, each cell
// containing image_url + (optional) overlay text. Prevents a malformed
// payload from writing a broken hero-4grid.json that then gets rsynced to
// prod by pushToProd() and breaks the live homepage hero.
function validateGrid(body) {
  if (!body || typeof body !== 'object') return 'body must be an object';
  if (!Array.isArray(body.cells)) return 'body.cells must be an array';
  if (body.cells.length !== 4) return `body.cells must have exactly 4 entries (got ${body.cells.length})`;
  for (let i = 0; i < 4; i++) {
    const c = body.cells[i];
    if (!c || typeof c !== 'object') return `cells[${i}] must be an object`;
    if (typeof c.image_url !== 'string' || !c.image_url) return `cells[${i}].image_url must be a non-empty string`;
    if (!/^https?:\/\//.test(c.image_url)) return `cells[${i}].image_url must be http(s)`;
  }
  return null;
}

app.post('/api/site/:site/grid', (req, res) => {
  const site = req.params.site;
  if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
  const grid = req.body || {};
  const validationError = validateGrid(grid);
  if (validationError) return res.status(400).json({ error: 'invalid grid payload', detail: validationError });
  const projDir = path.join(PROJECTS_DIR, site);
  const pubDir = path.join(projDir, 'public');
  if (!fs.existsSync(pubDir)) fs.mkdirSync(pubDir, { recursive: true });
  const gridPath = path.join(pubDir, 'hero-4grid.json');
  grid.updated_at = new Date().toISOString();
  fs.writeFileSync(gridPath, JSON.stringify(grid, null, 2));
  // Mirror to prod (non-blocking)
  pushToProd(site, gridPath).catch(e => console.warn(`[hero-admin] push failed ${site}:`, e.message));
  res.json({ ok: true, site, written: gridPath });
});

function pushToProd(site, localPath) {
  return new Promise((resolve, reject) => {
    // Try both layouts — /var/www/<site>.com/public/ for static-served sites,
    // /root/Projects/<site>/public/ for the 9 with .deploy.conf at /root.
    const remote = `${REMOTE_HOST}:/var/www/${site}.com/public/hero-4grid.json`;
    execFile('rsync', ['-az', '-e', 'ssh -o BatchMode=yes -o ConnectTimeout=8', localPath, remote], (err, stdout, stderr) => {
      if (err) return reject(new Error(stderr || err.message));
      resolve(stdout);
    });
  });
}

// ── fashion-house style guides (proxied from fashion-style-guides project) ──
const BRANDS_JSON = path.join(PROJECTS_DIR, 'fashion-style-guides', 'data', 'brands.json');
const BRAND_ASSIGN_FILE = path.join(__dirname, 'data', 'brand-assignments.json');

app.get('/api/brands', (req, res) => {
  try {
    const buf = fs.readFileSync(BRANDS_JSON, 'utf8');
    res.json(JSON.parse(buf));
  } catch (e) {
    res.status(500).json({ error: 'cannot read brands.json', detail: e.message, path: BRANDS_JSON });
  }
});

function loadAssignments() {
  try { return JSON.parse(fs.readFileSync(BRAND_ASSIGN_FILE, 'utf8')); }
  catch (e) { return {}; }
}
function saveAssignments(obj) {
  const dir = path.dirname(BRAND_ASSIGN_FILE);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  fs.writeFileSync(BRAND_ASSIGN_FILE, JSON.stringify(obj, null, 2));
}

app.get('/api/brand-assignments', (req, res) => res.json(loadAssignments()));

app.post('/api/site/:site/brand', (req, res) => {
  const site = req.params.site;
  if (!SITES.includes(site)) return res.status(404).json({ error: 'unknown site' });
  const brandId = (req.body && req.body.brand_id) || null;
  const all = loadAssignments();
  if (brandId) all[site] = { brand_id: brandId, updated_at: new Date().toISOString() };
  else delete all[site];
  saveAssignments(all);
  res.json({ ok: true, site, brand_id: brandId });
});

// ── ping / health ───────────────────────────────────────────────────────
app.get('/api/health', (req, res) => res.json({ ok: true, sites: SITES.length, ts: Date.now() }));

app.listen(PORT, '127.0.0.1', () => {
  console.log(`[hero-admin] http://127.0.0.1:${PORT}  (${SITES.length} sites)`);
});