← back to Stars of Design

routes/public.js

794 lines

const express = require('express');
const { Pool } = require('pg');
const router = express.Router();

const data = require('../lib/data');
const clientsLib = require('../lib/clients');
const firmsLib = require('../lib/firms');
const feedLib = require('../lib/feed');
const { sortDesigners, SORTS } = require('../lib/sort');

// Shared pool just for the small JSON stats endpoint — the lib/* modules
// each carry their own pool and we don't want to leak handles per request.
const statsPool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
  max: 2,
});

// Cached version metadata: package.json version + git short SHA (best-effort).
// Resolved once at module-load so /api/version stays sub-1ms.
const PKG_VERSION = (() => {
  try { return require('../package.json').version || '0.0.0'; }
  catch (_) { return '0.0.0'; }
})();
const GIT_SHA = (() => {
  try {
    return require('child_process')
      .execSync('git rev-parse --short HEAD', { cwd: require('path').resolve(__dirname, '..'), stdio: ['ignore', 'pipe', 'ignore'] })
      .toString().trim() || 'unknown';
  } catch (_) { return 'unknown'; }
})();
const STARTED_AT_ISO = new Date().toISOString();

const SITE_TITLE = 'Stars of Design';
const META_DESC = 'Stars of Design — a curated profile directory of the world\'s most influential interior designers, decade by decade, with the wallcoverings that match each designer\'s signature style.';

router.use((req, res, next) => {
  res.locals.site_title = SITE_TITLE;
  res.locals.meta_desc = META_DESC;
  res.locals.canonical = `https://starsofdesign.com${req.path}`;
  res.locals.path = req.path;
  next();
});

// /favicon.ico fallback for bots/legacy clients that don't honor
// <link rel="icon" href="/favicon.svg">. Serves the same SVG file.
router.get('/favicon.ico', (req, res) => {
  res.setHeader('Cache-Control', 'public, max-age=86400');
  res.sendFile(require('path').join(__dirname, '..', 'public', 'favicon.svg'));
});

// k8s-style cheap liveness probe — no db check, mirrors asim.
// no-store so monitors always see a fresh liveness signal.
router.get('/healthz', (req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  res.json({ ok: true, ts: Date.now() });
});

// /api/version — deploy fingerprint. Lets Steve verify which build is live
// without inspecting pm2 logs. Static at boot, so no DB hit.
router.get('/api/version', (_req, res) => {
  res.setHeader('Cache-Control', 'no-store');
  res.json({
    name:       'starsofdesign',
    version:    PKG_VERSION,
    git:        GIT_SHA,
    node:       process.version,
    started_at: STARTED_AT_ISO,
  });
});

// /api/health — slightly heavier liveness + count snapshot. Mirrors asim
// /api/health pattern; uses the same shared statsPool. no-store so monitor
// tools always see fresh state. Includes uptime_s and as_of for ops dashboards.
router.get('/api/health', async (_req, res) => {
  try {
    res.setHeader('Cache-Control', 'no-store');
    const r = await statsPool.query(`
      SELECT
        (SELECT count(*) FROM sod_designers WHERE role IS NULL OR role <> 'Manufacturer Rep') AS designers,
        (SELECT count(*) FROM sod_firms) AS firms`);
    const row = r.rows[0] || {};
    res.json({
      ok:        true,
      counts:    { designers: Number(row.designers || 0), firms: Number(row.firms || 0) },
      uptime_s:  Math.floor(process.uptime()),
      ts:        Date.now(),
      as_of:     new Date().toISOString(),
    });
  } catch (e) {
    res.status(500).json({ ok: false, error: e.message });
  }
});

// Editorial era index for the homepage — four anchored era cards (Early 20th,
// Mid-Century, Late 20th, Contemporary) with a count and three exemplar
// names per era, each linking to /designers?era=<bucket>. Built once at
// request time from the in-memory dataset; cheap, no DB hit.
const ERA_ORDER = ['Early 20th Century', 'Mid-Century', 'Late 20th Century', 'Contemporary'];
const ERA_BLURB = {
  'Early 20th Century': 'When the profession of decoration was invented — chintz, pale silk, the Edith Wharton school.',
  'Mid-Century':        'Plaster, jungle prints, glazed bamboo — the era of Draper, Frances Elkins, and the Greenbrier.',
  'Late 20th Century':  'Chintz on chintz on chintz — Buatta, Hicks, Denning & Fourcade, the maximalist American salon.',
  'Contemporary':       'The working bench today — Wearstler, McGee, Vervoordt, Tarlow — pattern paired to current practice.',
};
function buildEraIndex(all) {
  const buckets = {};
  for (const d of all) {
    if (!d.era) continue;
    (buckets[d.era] = buckets[d.era] || []).push(d.name);
  }
  return ERA_ORDER
    .filter((era) => buckets[era] && buckets[era].length)
    .map((era) => ({
      era,
      blurb:    ERA_BLURB[era] || '',
      count:    buckets[era].length,
      exemplars:buckets[era].slice(0, 3),
      href:     `/designers?era=${encodeURIComponent(era)}`,
    }));
}

router.get('/', (req, res, next) => {
  try {
    const all = data.load();
    const sortKey = SORTS[req.query.sort] ? req.query.sort : 'newest';
    const eraIndex = buildEraIndex(all);
    // WebSite + Organization JSON-LD. SearchAction declares the site's
    // search endpoint to Google for sitelinks search-box eligibility.
    // /designers acts as the search target (it accepts ?q=).
    const json_ld = {
      '@context': 'https://schema.org',
      '@graph': [
        {
          '@type': 'WebSite',
          '@id': 'https://starsofdesign.com/#website',
          url: 'https://starsofdesign.com/',
          name: 'Stars of Design',
          description: 'A curated profile directory of the interior designers who shape interiors — public-data seed; designers may claim and edit their profile.',
          potentialAction: {
            '@type': 'SearchAction',
            target: {
              '@type': 'EntryPoint',
              urlTemplate: 'https://starsofdesign.com/designers?q={search_term_string}',
            },
            'query-input': 'required name=search_term_string',
          },
        },
        {
          '@type': 'Organization',
          '@id': 'https://starsofdesign.com/#org',
          name: 'Designer Wallcoverings',
          url: 'https://designerwallcoverings.com/',
        },
      ],
    };
    res.render('public/home', {
      title: 'Stars of Design — A Profile Directory of the Designers Who Shape Interiors',
      designers: sortDesigners(all, sortKey),
      total: all.length,
      sortKey,
      filter: {},
      eraIndex,
      json_ld,
    });
  } catch (e) { next(e); }
});

router.get('/designers', (req, res, next) => {
  try {
    const filt = {
      style: req.query.style,
      era:   req.query.era,
      q:     req.query.q,
    };
    const rows = data.filter(filt);
    const sortKey = SORTS[req.query.sort] ? req.query.sort : 'newest';
    const sortedRows = sortDesigners(rows, sortKey);
    const json_ld = {
      '@context': 'https://schema.org',
      '@type': 'ItemList',
      itemListElement: sortedRows.slice(0, 60).map((d, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        item: {
          '@type': 'Person',
          name: d.name,
          url: `https://starsofdesign.com/designers/${d.slug}`,
        },
      })),
      numberOfItems: rows.length,
    };
    res.render('public/designers', {
      title: 'Browse Designers — Stars of Design',
      designers: sortedRows,
      total: rows.length,
      sortKey,
      filter: filt,
      styles: data.allStyles(),
      eras: data.allEras(),
      json_ld,
    });
  } catch (e) { next(e); }
});

router.get('/designers/:slug', (req, res, next) => {
  try {
    const slug = req.params.slug;
    if (!/^[a-z0-9-]{1,80}$/.test(slug)) {
      return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    }
    const d = data.bySlug(slug);
    if (!d) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    const designerVideos = data.videosByDesigner(slug);
    const json_ld = {
      '@context': 'https://schema.org',
      '@graph': [
        {
          '@type': 'Person',
          name: d.name,
          url: `https://starsofdesign.com/designers/${d.slug || slug}`,
          ...(d.headline ? { description: d.headline } : {}),
          ...(d.city ? {
            homeLocation: {
              '@type': 'Place',
              name: d.city,
            }
          } : {}),
          ...(d.styles && d.styles.length ? { knowsAbout: d.styles } : {}),
          jobTitle: 'Interior Designer',
        },
        {
          '@type': 'BreadcrumbList',
          itemListElement: [
            { '@type': 'ListItem', position: 1, name: 'Home',      item: 'https://starsofdesign.com/' },
            { '@type': 'ListItem', position: 2, name: 'Designers', item: 'https://starsofdesign.com/designers' },
            { '@type': 'ListItem', position: 3, name: d.name,      item: `https://starsofdesign.com/designers/${d.slug || slug}` },
          ],
        },
      ],
    };
    res.render('public/designer', {
      title: `${d.name} — Profile, Signature Style & Wallcovering Pairings | Stars of Design`,
      meta_desc_override: `${d.name} — ${d.headline || (d.styles || []).join(', ')} interior designer based in ${d.city || 'unknown'}. Profile, era, signature works, and wallcoverings that pair with their style.`,
      designer: d,
      designerVideos,
      json_ld,
    });
  } catch (e) { next(e); }
});

// ── DW client tier — real designers/firms who actually buy from DW. Pulled
// from George info@designerwallcoverings.com Gmail recon + sig-extract. The
// 150-entry editorial JSON list at /designers stays as the curated marquee
// (Dorothy Draper / Kelly Wearstler / Studio Sofield etc.); this surfaces the
// active client list as its own tier.
router.get('/clients', async (req, res, next) => {
  try {
    const q    = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
    const area = typeof req.query.area === 'string' && req.query.area.length < 80 ? req.query.area : null;
    const sort = clientsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'default';
    const [clients, total] = await Promise.all([
      clientsLib.listClients({ q, area, sort, limit: 300 }),
      clientsLib.totalCount(),
    ]);
    const json_ld = {
      '@context': 'https://schema.org',
      '@type': 'ItemList',
      itemListElement: clients.slice(0, 60).map((c, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        item: {
          '@type': 'Person',
          name: c.full_name,
          url: `https://starsofdesign.com/clients/${c.slug}`,
        },
      })),
      numberOfItems: total,
    };
    res.render('public/clients', {
      title: 'DW Client Designers — Active Buyer List | Stars of Design',
      meta_desc_override: 'The active list of interior designers and architecture firms who buy wallcoverings from Designer Wallcoverings. Pulled from order history, trade signups, and sample requests. City + state + areas served per profile (no street addresses).',
      clients,
      total,
      sortKey: sort,
      filter: { q, area },
      json_ld,
    });
  } catch (e) { next(e); }
});

router.get('/clients/:slug', async (req, res, next) => {
  try {
    const slug = req.params.slug;
    if (!/^[a-z0-9-]{1,120}$/.test(slug)) {
      return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    }
    const c = await clientsLib.getClient(slug);
    if (!c) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    // JSON-LD Person schema for the DW client tier. Firm affiliation, if
    // known, becomes worksFor → Organization. Memory rule: city + state
    // only, never street/zip/phone — schema mirrors that exactly.
    const json_ld = {
      '@context': 'https://schema.org',
      '@graph': [
        {
          '@type': 'Person',
          name: c.full_name,
          url: `https://starsofdesign.com/clients/${c.slug || slug}`,
          jobTitle: c.role || 'Interior Designer',
          ...(c.city || c.state ? {
            homeLocation: {
              '@type': 'Place',
              name: [c.city, c.state].filter(Boolean).join(', '),
            }
          } : {}),
          ...(c.firm ? {
            worksFor: {
              '@type': 'Organization',
              name: c.firm.name,
              ...(c.firm.slug ? { url: `https://starsofdesign.com/firms/${c.firm.slug}` } : {}),
            }
          } : {}),
        },
        {
          '@type': 'BreadcrumbList',
          itemListElement: [
            { '@type': 'ListItem', position: 1, name: 'Home',    item: 'https://starsofdesign.com/' },
            { '@type': 'ListItem', position: 2, name: 'Clients', item: 'https://starsofdesign.com/clients' },
            { '@type': 'ListItem', position: 3, name: c.full_name, item: `https://starsofdesign.com/clients/${c.slug || slug}` },
          ],
        },
      ],
    };
    res.render('public/dwclient', {
      title: `${c.full_name} — DW Client Designer | Stars of Design`,
      meta_desc_override: `${c.full_name}${c.firm ? ' · ' + c.firm.name : ''}${c.city ? ' · ' + c.city : ''}${c.state ? ', ' + c.state : ''}. Active Designer Wallcoverings client.`,
      profile: c,
      json_ld,
    });
  } catch (e) { next(e); }
});

// ── Firm tier — seeded top-US architecture + interior design firms
// (Gensler, Studio Sofield, Kelly Wearstler, ...) plus any firms
// auto-materialized from sig-extract. Keyed on sod_firms not sod_designers.
router.get('/firms', async (req, res, next) => {
  try {
    const q    = typeof req.query.q === 'string' && req.query.q.length < 80 ? req.query.q : null;
    const sort = firmsLib.SORT_KEYS[req.query.sort] ? req.query.sort : 'claimed';
    const [firms, total] = await Promise.all([
      firmsLib.listFirms({ q, sort, limit: 400 }),
      firmsLib.totalCount(),
    ]);
    // ItemList JSON-LD for the index page — surfaces each firm as a
    // schema.org/Organization ListItem with position + url. Rich-snippet
    // candidates in Google. numberOfItems uses the full total, not page slice.
    const json_ld = {
      '@context': 'https://schema.org',
      '@type': 'ItemList',
      itemListElement: firms.slice(0, 60).map((f, i) => ({
        '@type': 'ListItem',
        position: i + 1,
        item: {
          '@type': 'Organization',
          name: f.name,
          url: `https://starsofdesign.com/firms/${f.slug}`,
        },
      })),
      numberOfItems: total,
    };
    res.render('public/firms', {
      title: 'Top US Design Firms — Architecture + Interiors | Stars of Design',
      meta_desc_override: 'Top US architecture and interior design firms — Gensler, HOK, Perkins&Will, Studio Sofield, Kelly Wearstler, Studio McGee, and more. Public-data seed; firms may claim and edit their profile.',
      firms,
      total,
      sortKey: sort,
      filter: { q },
      json_ld,
    });
  } catch (e) { next(e); }
});

router.get('/firms/:slug', async (req, res, next) => {
  try {
    const slug = req.params.slug;
    if (!/^[a-z0-9-]{1,120}$/.test(slug)) {
      return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    }
    const f = await firmsLib.getFirm(slug);
    if (!f) return res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
    // JSON-LD @graph — Organization (the firm itself) + BreadcrumbList
    // (Home > Firms > {firm}) + ItemList of associated designers, all in
    // one script tag for cleaner head injection.
    const json_ld = {
      '@context': 'https://schema.org',
      '@graph': [
        {
          '@type': 'Organization',
          name: f.name,
          url: `https://starsofdesign.com/firms/${f.slug}`,
          ...(f.website ? { sameAs: [f.website] } : {}),
          ...(f.founded_year ? { foundingDate: String(f.founded_year) } : {}),
          ...(f.bio ? { description: f.bio.slice(0, 800) } : {}),
          ...(f.city || f.state ? {
            address: {
              '@type': 'PostalAddress',
              ...(f.city ? { addressLocality: f.city } : {}),
              ...(f.state ? { addressRegion: f.state } : {}),
              ...(f.country ? { addressCountry: f.country } : {}),
            }
          } : {}),
          ...(f.areas && f.areas.length ? { areaServed: f.areas } : {}),
          ...(f.designers && f.designers.length ? {
            employee: f.designers.map(d => ({
              '@type': 'Person',
              name: d.full_name,
              url: `https://starsofdesign.com/clients/${d.slug}`,
            })),
          } : {}),
        },
        {
          '@type': 'BreadcrumbList',
          itemListElement: [
            { '@type': 'ListItem', position: 1, name: 'Home',  item: 'https://starsofdesign.com/' },
            { '@type': 'ListItem', position: 2, name: 'Firms', item: 'https://starsofdesign.com/firms' },
            { '@type': 'ListItem', position: 3, name: f.name,  item: `https://starsofdesign.com/firms/${f.slug}` },
          ],
        },
      ],
    };
    res.render('public/firm', {
      title: `${f.name} — Design Firm | Stars of Design`,
      meta_desc_override: `${f.name}${f.city ? ' · ' + f.city : ''}${f.state ? ', ' + f.state : ''}${f.founded_year ? ' · founded ' + f.founded_year : ''}.`,
      firm: f,
      json_ld,
    });
  } catch (e) { next(e); }
});

// ── Phone-shaped social feed for the /clients tier — Facebook/X.com vibe.
// Anonymous likes via httpOnly=false cookie (so client JS can show liked-state
// optimistically). Anonymous comments require a name + body; we collect
// optional email for follow-up but don't authenticate it.
router.get('/feed', async (req, res, next) => {
  try {
    const anonId = feedLib.ensureAnonId(req, res);
    const page = Math.max(1, parseInt(req.query.page || '1', 10));
    const limit = 15;
    const offset = (page - 1) * limit;
    const items = await feedLib.feedPage({ anonId, limit, offset });
    res.render('public/feed', {
      title: 'DW Designer Feed — Stars of Design',
      meta_desc_override: 'A scrolling feed of the interior designers and architecture firms who buy from Designer Wallcoverings. Like, comment, follow your favorites.',
      items,
      page,
      anonId,
    });
  } catch (e) { next(e); }
});

// /api — JSON discovery doc for sod public/api endpoints. Mirrors asim.
router.get('/api', (_req, res) => {
  res.json({
    name: 'Stars of Design API',
    base: 'https://starsofdesign.com',
    endpoints: {
      'GET /api/health':                 'Liveness probe (SELECT 1) — JSON ok+ts',
      'GET /api/version':                'Deploy fingerprint — JSON {name, version, git, node, started_at}',
      'GET /api/stats':                  'Aggregate counts (designers/firms/claimed/links/likes/comments/candidates)',
      'GET /api/clients/:id/comments':   'Comments on a DW client profile (chronological)',
      'POST /api/clients/:id/like':      'Toggle like on a DW client (anonymous ID via cookie)',
      'POST /api/clients/:id/comment':   'Post a comment on a DW client (name + body, optional email)',
      'GET /api/designers/random':       'Random designer — JSON {slug, display_name}',
      'GET /api/firms/random':           'Random firm — JSON {slug, name}',
      'GET /api/clients/random':         'Random DW client — JSON {slug, name}',
      'GET /api/designers/featured':     'N random designers (?n=6, max 24) — multi-row companion to /api/designers/random',
      'GET /api/firms/featured':         'N random firms (?n=6, max 24)',
      'GET /api/clients/featured':       'N random DW clients (?n=6, max 24)',
      'GET /random/designer':            '302 redirect to a random designer detail page',
      'GET /random/firm':                '302 redirect to a random firm detail page',
      'GET /random/client':              '302 redirect to a random DW client detail page',
    },
    notes: [
      'Profiles are city + state only; no street addresses, no phone numbers (PII policy).',
      'Likes + comments use anonymous browser IDs — no account required.',
      'Profile content is editorial; designers may submit edits via /submit.',
    ],
  });
});

router.get('/api/clients/:id/comments', async (req, res, next) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
    const rows = await feedLib.commentsFor(id, 100);
    // 30s Cache-Control — comments can move quickly during active threads.
    // SWR lets a stale list render while the refresh fires in the background.
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({ comments: rows });
  } catch (e) { next(e); }
});

// JSON stats — counts surfaced by the homepage + cross-consumed by status-loop
// and external dashboards. Public, no auth, returns DW-client tier only
// (excluding Manufacturer Reps which are gated out everywhere else too).
router.get('/api/stats', async (_req, res, next) => {
  try {
    // 30s Cache-Control + 60s SWR so the endpoint can absorb status-loop
    // polling + dashboard refreshes without hammering pg every hit.
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    const r = await statsPool.query(`
      SELECT
        (SELECT count(*) FROM sod_designers WHERE role IS NULL OR role <> 'Manufacturer Rep') AS designers,
        (SELECT count(*) FROM sod_designers WHERE claimed_at IS NOT NULL AND (role IS NULL OR role <> 'Manufacturer Rep')) AS designers_claimed,
        (SELECT count(*) FROM sod_firms) AS firms,
        (SELECT count(*) FROM sod_firms WHERE claimed_at IS NOT NULL) AS firms_claimed,
        (SELECT count(*) FROM sod_links) AS links,
        (SELECT count(*) FROM sod_likes) AS likes,
        (SELECT count(*) FROM sod_comments WHERE is_hidden = false) AS comments,
        (SELECT count(*) FROM sod_email_candidates) AS candidates_total,
        (SELECT count(*) FROM sod_email_candidates WHERE status = 'new') AS candidates_unreviewed`);
    const row = r.rows[0] || {};
    res.json({
      designers:           Number(row.designers || 0),
      designers_claimed:   Number(row.designers_claimed || 0),
      firms:               Number(row.firms || 0),
      firms_claimed:       Number(row.firms_claimed || 0),
      links:               Number(row.links || 0),
      likes:               Number(row.likes || 0),
      comments:            Number(row.comments || 0),
      candidates_total:    Number(row.candidates_total || 0),
      candidates_unreviewed:Number(row.candidates_unreviewed || 0),
      as_of:               new Date().toISOString(),
    });
  } catch (e) { next(e); }
});

// One-shot random discovery endpoints. Used by /random/* redirect helpers
// (parity with asim) and by humans hitting "feeling lucky"-style UX.
// 30s/60s SWR — cache lifespan is short by design so the random feel
// stays fresh while keeping pg hits cheap under repeat polling.
// /api/{designers,firms,clients}/featured — N (default 6, max 24) random rows.
// Used by widgets that want a small grid inline. 60s cache + 120s SWR.
function pickN(arr, n) {
  if (arr.length <= n) return arr.slice();
  const out = [];
  const seen = new Set();
  while (out.length < n) {
    const i = Math.floor(Math.random() * arr.length);
    if (seen.has(i)) continue;
    seen.add(i);
    out.push(arr[i]);
  }
  return out;
}
router.get('/api/designers/featured', (req, res, next) => {
  try {
    const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
    const all = data.load();
    const picks = pickN(all, n).map((d) => ({ slug: d.slug, display_name: d.name || d.full_name || null }));
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ designers: picks });
  } catch (e) { next(e); }
});
router.get('/api/firms/featured', async (req, res, next) => {
  try {
    const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
    const rows = await firmsLib.listFirms({ limit: 1000 });
    const picks = pickN(rows, n).map((f) => ({ slug: f.slug, name: f.name || null }));
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ firms: picks });
  } catch (e) { next(e); }
});
router.get('/api/clients/featured', async (req, res, next) => {
  try {
    const n = Math.min(Math.max(parseInt(req.query.n, 10) || 6, 1), 24);
    const rows = await clientsLib.listClients({ limit: 1000 });
    const picks = pickN(rows, n).map((c) => ({ slug: c.slug, name: c.full_name || c.name || null }));
    res.setHeader('Cache-Control', 'public, max-age=60, stale-while-revalidate=120');
    res.json({ clients: picks });
  } catch (e) { next(e); }
});

router.get('/api/designers/random', (_req, res, next) => {
  try {
    const all = data.load();
    if (!all.length) return res.status(503).json({ error: 'no-data' });
    const pick = all[Math.floor(Math.random() * all.length)];
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({ slug: pick.slug, display_name: pick.name || pick.full_name || null });
  } catch (e) { next(e); }
});

router.get('/api/firms/random', async (_req, res, next) => {
  try {
    const rows = await firmsLib.listFirms({ limit: 1000 });
    if (!rows.length) return res.status(503).json({ error: 'no-data' });
    const pick = rows[Math.floor(Math.random() * rows.length)];
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({ slug: pick.slug, name: pick.name || null });
  } catch (e) { next(e); }
});

router.get('/api/clients/random', async (_req, res, next) => {
  try {
    const rows = await clientsLib.listClients({ limit: 1000 });
    if (!rows.length) return res.status(503).json({ error: 'no-data' });
    const pick = rows[Math.floor(Math.random() * rows.length)];
    res.setHeader('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
    res.json({ slug: pick.slug, name: pick.full_name || pick.name || null });
  } catch (e) { next(e); }
});

router.post('/api/clients/:id/like', async (req, res, next) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
    const anonId = feedLib.ensureAnonId(req, res);
    const result = await feedLib.toggleLike({ designerId: id, anonId });
    res.json(result);
  } catch (e) { next(e); }
});

router.post('/api/clients/:id/comment', async (req, res, next) => {
  try {
    const id = parseInt(req.params.id, 10);
    if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad-id' });
    const anonId = feedLib.ensureAnonId(req, res);
    const ipHash = feedLib.hashIp(req);
    const { author_name, author_email, body } = req.body || {};
    const row = await feedLib.postComment({
      designerId: id, anonId, ipHash,
      author_name, author_email, body,
    });
    res.json({ ok: true, comment: row });
  } catch (e) {
    if (/spam-or-empty|name-required|rate-limit/.test(e.message)) {
      return res.status(400).json({ error: e.message });
    }
    next(e);
  }
});

router.get('/videos', (req, res, next) => {
  try {
    const filt = {
      category:   req.query.category,
      source_org: req.query.source_org,
      q:          req.query.q,
    };
    const rows = data.filterVideos(filt);
    res.render('public/videos', {
      title: 'Designer Videos — Home Tours, Interviews & Project Showcases | Stars of Design',
      meta_desc_override: 'Curated YouTube video gallery of US interior designer home tours, sit-down interviews, and project showcases from Architectural Digest, House Beautiful, Studio McGee, and the firms themselves. Cross-linked to the Stars of Design directory.',
      videos: rows,
      total: rows.length,
      filter: filt,
      categories: data.videoCategories(),
      sources:    data.videoSources(),
    });
  } catch (e) { next(e); }
});

router.get('/about', (req, res) => {
  res.render('public/about', { title: 'About — Stars of Design' });
});

router.get('/submit', (req, res) => {
  res.render('public/submit', { title: 'Submit a designer — Stars of Design' });
});

router.get('/privacy', (req, res) => {
  res.render('public/privacy', {
    title: 'Privacy — Stars of Design',
    meta_desc_override: 'Stars of Design privacy notice — no accounts, no identifying cookies, anonymous like/comment, city+state only (no street addresses).',
  });
});

router.get('/terms', (req, res) => {
  res.render('public/terms', {
    title: 'Terms of use — Stars of Design',
    meta_desc_override: 'Stars of Design terms of use — editorial directory, public-data sourcing, user submissions, claim your profile, liability.',
  });
});

// /random/* — 302 to a random detail page. Parity with asim /random/movie etc.
// Falls through to the index page if the dataset is somehow empty.
router.get('/random/designer', (_req, res) => {
  try {
    const all = data.load();
    if (!all.length) return res.redirect('/designers');
    const pick = all[Math.floor(Math.random() * all.length)];
    res.redirect(`/designers/${pick.slug}`);
  } catch (_e) { res.redirect('/designers'); }
});
router.get('/random/firm', async (_req, res) => {
  try {
    const rows = await firmsLib.listFirms({ limit: 1000 });
    if (!rows.length) return res.redirect('/firms');
    const pick = rows[Math.floor(Math.random() * rows.length)];
    res.redirect(`/firms/${pick.slug}`);
  } catch (_e) { res.redirect('/firms'); }
});
router.get('/random/client', async (_req, res) => {
  try {
    const rows = await clientsLib.listClients({ limit: 1000 });
    if (!rows.length) return res.redirect('/clients');
    const pick = rows[Math.floor(Math.random() * rows.length)];
    res.redirect(`/clients/${pick.slug}`);
  } catch (_e) { res.redirect('/clients'); }
});

router.get('/sitemap.xml', async (req, res, next) => {
  try {
    const base = 'https://starsofdesign.com';
    const all = data.load();
    const today = new Date().toISOString().slice(0, 10);
    const [firms, clients] = await Promise.all([
      firmsLib.listFirms({ limit: 1000 }),
      clientsLib.listClients({ limit: 1000 }),
    ]);
    const urls = [
      { loc: `${base}/`,          priority: '1.0', changefreq: 'weekly' },
      { loc: `${base}/designers`, priority: '0.9', changefreq: 'weekly' },
      { loc: `${base}/firms`,     priority: '0.9', changefreq: 'weekly' },
      { loc: `${base}/clients`,   priority: '0.9', changefreq: 'weekly' },
      { loc: `${base}/feed`,      priority: '0.7', changefreq: 'daily' },
      { loc: `${base}/videos`,    priority: '0.8', changefreq: 'weekly' },
      { loc: `${base}/about`,     priority: '0.5', changefreq: 'monthly' },
      { loc: `${base}/submit`,    priority: '0.4', changefreq: 'monthly' },
      { loc: `${base}/privacy`,   priority: '0.3', changefreq: 'yearly' },
      { loc: `${base}/terms`,     priority: '0.3', changefreq: 'yearly' },
      ...all.map((d) => ({
        loc: `${base}/designers/${d.slug}`,
        priority: '0.8',
        changefreq: 'monthly',
      })),
      ...firms.map((f) => ({
        loc: `${base}/firms/${f.slug}`,
        priority: '0.7',
        changefreq: 'monthly',
      })),
      ...clients.map((c) => ({
        loc: `${base}/clients/${c.slug}`,
        priority: '0.7',
        changefreq: 'monthly',
      })),
    ];
    const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' +
      '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
      urls.map((u) =>
        `  <url><loc>${u.loc}</loc><lastmod>${today}</lastmod><changefreq>${u.changefreq}</changefreq><priority>${u.priority}</priority></url>`
      ).join('\n') +
      '\n</urlset>\n';
    res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
    res.setHeader('Last-Modified', new Date().toUTCString());
    res.type('application/xml').send(xml);
  } catch (e) { next(e); }
});

// /sitemap.txt — plain-text Google+Bing-supported sitemap. Mirrors asim
// /sitemap.txt. Pulls URLs from /sitemap.xml on the fly (cheap, fetches
// only the local route via in-process call to the XML generator below).
router.get('/sitemap.txt', async (req, res, next) => {
  try {
    const base = 'https://starsofdesign.com';
    const all = data.load();
    const [firms, clients] = await Promise.all([
      firmsLib.listFirms({ limit: 1000 }),
      clientsLib.listClients({ limit: 1000 }),
    ]);
    const urls = [
      `${base}/`,
      `${base}/designers`,
      `${base}/firms`,
      `${base}/clients`,
      `${base}/feed`,
      `${base}/videos`,
      `${base}/about`,
      `${base}/submit`,
      `${base}/privacy`,
      `${base}/terms`,
      ...all.map((d) => `${base}/designers/${d.slug}`),
      ...firms.map((f) => `${base}/firms/${f.slug}`),
      ...clients.map((c) => `${base}/clients/${c.slug}`),
    ];
    res.setHeader('Cache-Control', 'public, max-age=21600, stale-while-revalidate=86400');
    res.setHeader('Last-Modified', new Date().toUTCString());
    res.type('text/plain').send(urls.join('\n') + '\n');
  } catch (e) { next(e); }
});

module.exports = router;