← back to Interiordesignershowroom

server.js

743 lines

try { require('dotenv').config(); } catch (_) { /* dotenv optional — env may come from pm2/shell */ }
const express = require('express');
const path = require('path');
const fs = require('fs');
const db = require('./lib/db');
const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, breadcrumbNav, breadcrumbJsonld, absUrl, sceneFigure, roomThumbs } = require('./lib/render');
const catalog = require('./lib/catalog');
const rooms = require('./lib/rooms');
const scene = require('./lib/scene');
const hotspots = require('./lib/hotspots');
const COLS = require('./lib/cols');

const app = express();
app.get('/ads.txt', (req, res) => res.type('text/plain').send('google.com, pub-5278231299883833, DIRECT, f08c47fec0942fa0'));
const PORT = process.env.PORT || 9820;

// Security headers on every response (no dependency). A strict Content-Security-Policy
// is deliberately DEFERRED: the storefront still emits inline onerror/style handlers that
// a CSP without 'unsafe-inline' would break — that's a separate refactor. These four are
// non-breaking and close the obvious gaps (MIME sniffing, clickjacking, referrer leakage,
// unused powerful features). Also stop advertising the framework.
app.disable('x-powered-by');
app.use((req, res, next) => {
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('X-Frame-Options', 'SAMEORIGIN');
  res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  res.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), browsing-topics=()');
  // script-src 'self' locks the PUBLIC storefront to same-origin scripts (no inline —
  // the img onerror is now delegated in /js/imgfallback.js). /admin keeps 'unsafe-inline'
  // (behind auth, low XSS risk) so its one inline onsubmit confirm still works without
  // surgery on the concurrently-edited admin shell. ld+json is data, unaffected by script-src.
  const isAdmin = req.path === '/admin' || req.path.indexOf('/admin/') === 0;
  // static.cloudflareinsights.com: CF auto-injects its Web-Analytics beacon on the proxied
  // zone; without this origin it's blocked by script-src and errors in every console.
  const scriptSrc = isAdmin ? "script-src 'self' 'unsafe-inline'" : "script-src 'self' https://www.googletagmanager.com https://static.cloudflareinsights.com https://pagead2.googlesyndication.com https://*.googlesyndication.com";
  const frameSrc = isAdmin ? '' : '; frame-src https://googleads.g.doubleclick.net https://tpc.googlesyndication.com https://www.google.com';
  // NOTE: connect-src is intentionally OMITTED (and there is no default-src), so cross-origin
  // fetch/beacon is unrestricted — this is what lets GA4 send measurement to www.google-analytics.com
  // and the region*.google-analytics.com endpoints. If a default-src is EVER added here, you MUST
  // also add an explicit connect-src for those GA hosts or analytics beacons die silently.
  res.set('Content-Security-Policy', `${scriptSrc}${frameSrc}; frame-ancestors 'self'; object-src 'none'; base-uri 'none'`);
  next();
});

app.use(express.json({ limit: '256kb' }));
// Static caching: css/js get a modest 1h TTL (stable filenames, change on deploy —
// ETag still revalidates after expiry, so post-deploy staleness is bounded to 1h for
// active sessions). Images get 7d — scene/guide images have content-hashed names and
// favicons/og rarely change. Cuts a revalidation round-trip on every asset per page load.
// css/js: NO long cache — filenames aren't content-hashed and the HTML references them
// bare (no ?v=), so any max-age>0 risks serving stale css/js against fresh HTML after a
// deploy. Default (max-age=0 + ETag) makes them cheap 304 revalidations instead. A ?v=
// cache-buster is the future upgrade that would let these be cached hard.
// Hard-cache css/js (their ?v= busts on content change). Default ON so it works even
// when NODE_ENV is unset; opt into dev ergonomics (edit + reload, no restart) with
// NODE_ENV=development, which drops to revalidate-every-load.
const STATIC_CACHE = process.env.NODE_ENV === 'development' ? { maxAge: 0 } : { maxAge: '365d', immutable: true };
app.use('/css', express.static(path.join(__dirname, 'public/css'), STATIC_CACHE));
app.use('/js', express.static(path.join(__dirname, 'public/js'), STATIC_CACHE));
// Images split by staleness profile: /img/rooms scene images are 16-hex CONTENT-HASHED
// (new content = new URL) so they're safely immutable for a year; everything else (guide
// editorial images, favicons, og.png — stable, human-readable names) gets a short 1d TTL
// so an in-place asset swap propagates next day, not next week. Mount order: rooms first.
app.use('/img/rooms', express.static(path.join(__dirname, 'public/img/rooms'), { maxAge: '365d', immutable: true }));
app.use('/img', express.static(path.join(__dirname, 'public/img'), { maxAge: '1d' }));

const { ROOMS } = require('./lib/nav'); // shared with render.js (nav) — one source of truth

const { intOrNull, intIds } = require('./lib/ids');
const { v } = require('./lib/assetv'); // ?v= cache-buster for local css/js // bigint-safe id guards (shared)

// --- tiny markdown-lite for guide bodies (headings, bold, paragraphs) ------
function md(src = '') {
  return src.split(/\n\n+/).map((block) => {
    const b = block.trim();
    if (/^### /.test(b)) return `<h3>${esc(b.slice(4))}</h3>`;
    if (/^## /.test(b)) return `<h2>${esc(b.slice(3))}</h2>`;
    const html = esc(b).replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
    return `<p>${html.replace(/\n/g, '<br>')}</p>`;
  }).join('\n');
}

// ---- Sort + density controls (Steve's standing rule for every product grid) ----
function gridControls(productCount) {
  const countBadge = productCount != null
    ? `<span class="grid-count">${productCount} piece${productCount === 1 ? '' : 's'}</span>`
    : '';
  return `
  <div class="grid-controls" role="toolbar" aria-label="Grid controls">
    <label>Sort
      <select id="sortSel" aria-label="Sort products">
        <option value="newest">Date — Newest</option>
        <option value="color">Color</option>
        <option value="style">Style</option>
        <option value="brand">Brand A→Z</option>
        <option value="title">Title A→Z</option>
        <option value="price-asc">Price ↑</option>
        <option value="price-desc">Price ↓</option>
      </select>
    </label>
    <label class="mb-project-ctl">Mood board
      <select id="mbProjectSel" aria-label="Active mood board project">
        <option value="">Loading…</option>
      </select>
    </label>
    <label class="density">Columns
      <input id="densitySel" type="range" min="2" max="6" step="1" value="4" aria-label="Grid columns">
      <span class="density-val" id="densityVal" aria-hidden="true">4</span>
    </label>
    ${countBadge}
  </div>`;
}

function grid(products) {
  if (!products.length) {
    return `<div class="grid" id="grid"><div class="grid-empty"><strong>No pieces found</strong>Try adjusting your filters or <a href="/shop">browse everything</a>.</div></div>`;
  }
  return `<div class="grid" id="grid">${products.map(productCard).join('')}</div>`;
}

// -------------------------- Routes ----------------------------------------
app.get('/healthz', (_req, res) => res.status(200).send('ok'));

// AdSense removed 2026-08-05 — luxury/commerce surface, not an ad site, and not
// added to the AdSense account (DTD placement rule). No loader, no ads.txt.

app.get('/', async (_req, res, next) => {
  try {
    const [{ rows: featured }, { rows: latest }, { rows: guides }] = await Promise.all([
      db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE featured=TRUE AND in_stock AND NOT suppressed ORDER BY created_at DESC LIMIT 8`),
      db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE in_stock AND NOT suppressed ORDER BY created_at DESC LIMIT 12`),
      db.query(`SELECT slug,title,dek,hero_image,
        (SELECT p.image_url FROM products p WHERE p.id = ANY(guides.product_ids) AND NOT p.suppressed AND p.image_url IS NOT NULL LIMIT 1) AS fallback_image
        FROM guides WHERE published ORDER BY created_at DESC LIMIT 3`),
    ]);
    const roomTiles = ROOMS.slice(0, 6).map(([slug, label]) =>
      `<a class="room-tile" href="/rooms/${slug}"><span>${esc(label)}</span></a>`).join('');
    const guideCards = guides.map((g) =>
      `<a class="guide-card" href="/guides/${esc(g.slug)}">
         ${(g.hero_image || g.fallback_image) ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image || g.fallback_image)}" alt="${esc(g.title)}">` : ''}
         <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
    const body = `
      <section class="hero">
        <span class="section-eyebrow">The Showroom</span>
        <h1>${esc(SITE.name)}</h1>
        <p class="tagline">${esc(SITE.tagline)}</p>
        <a class="cta" href="/shop">Enter the showroom →</a>
      </section>
      <section class="rooms">
        <h2>Shop by room</h2>
        <div class="room-grid">${roomTiles}</div>
      </section>
      ${featured.length ? `<section><h2>Editor's picks</h2>${grid(featured)}</section>` : ''}
      ${guideCards ? `<section class="guides-strip"><h2>Buying guides</h2><div class="guide-grid">${guideCards}</div></section>` : ''}
      <section><h2>New in the showroom</h2>${grid(latest)}</section>`;
    res.send(layout({ title: 'Curated Designer Furniture & Decor', description: SITE.tagline, canonical: SITE.url, jsonld: orgWebsiteJsonld(), body, activeNav: '/' }));
  } catch (e) { next(e); }
});

// Shared faceted catalog renderer for /shop and /rooms. Filters are URL-addressable
// so every facet is a real drillable link (Steve's "data points href to deeper data").
async function renderCatalog(res, { f, basePath, title, description, canonical, activeNav, heading, crumbs }) {
  const [products, counts] = await Promise.all([catalog.fetchProducts(f), catalog.facetCounts(f)]);
  const body = `<section>
      ${breadcrumbNav(crumbs)}
      <h1>${esc(heading)}</h1>
      ${catalog.activeChips(f, basePath)}
      <div class="catalog">
        ${catalog.facetRail(f, counts, basePath)}
        <div class="catalog-main">${gridControls(products.length)}${grid(products)}</div>
      </div>
    </section><script src="${v('/js/grid.js')}"></script>`;
  const jsonld = [productListJsonld(products, canonical), breadcrumbJsonld(crumbs)].filter(Boolean);
  // Social share image: the top product's photo so a shared facet/room link shows real
  // furniture, not the generic logo card. Falls back to og.png in layout() when empty.
  const image = products.length ? products[0].image_url : null;
  res.send(layout({ title, description, canonical, jsonld, body, activeNav, image }));
}

// SEO for /shop under filters. Only a whitelist of "clean" facet shapes — a single
// style/color, or a room × one-of-{style,color} — earns a SELF-canonical + unique
// title/description/H1 (these are exactly the shapes emitted in sitemap.xml, so the
// two signals agree). Every other combo (price, search, network, deep multi-facet)
// consolidates back to /shop so infinite thin variants don't fragment ranking.
const CAP = (s) => String(s || '').replace(/(^|[\s-])\w/g, (m) => m.toUpperCase());
const roomLabel = (r) => CAP(String(r).replace(/-/g, ' '));
// A facet page is a genuine, indexable landing page only when it is DIFFERENTIATED
// from /shop: enough inventory to stand alone (>= MIN) but not so much that it's
// basically the whole catalog re-served (<= MAX_RATIO of the visible total). Modern
// (93.6%) and neutral (49%) fail the ceiling — proven near-duplicates of /shop
// (200/200 and 175/200 shared cards) — so they consolidate back to /shop instead.
const SITEMAP_FACET_MIN = 12;
const SITEMAP_FACET_MAX_RATIO = 0.40;
const ROOM_SLUGS = new Set(ROOMS.map(([s]) => s)); // rooms that have a real nav/category

// SEO for /shop under filters. Only a whitelist of "clean" facet shapes — a single
// style/color, or a whitelisted-room × one-of-{style,color} — earns a SELF-canonical
// + unique title/description/H1, AND only when its count clears MIN and stays under
// MAX_RATIO. This set is byte-identical to what sitemap.xml emits, so the two signals
// agree. Everything else (price, search, network, deep multi-facet, near-total facets,
// orphan rooms) consolidates back to /shop.
async function facetSeo(f) {
  const keys = ['room', 'style', 'color', 'network', 'max', 'q'].filter((k) => f[k]);
  const only = (set) => keys.length === set.length && set.every((k) => f[k]);
  const crumbBase = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Shop', url: `${SITE.url}/shop` }];
  const generic = { canonical: `${SITE.url}/shop`, heading: 'The Showroom',
    title: 'Shop the Showroom', description: 'Filter curated designer pieces by room, style, color, or price.',
    crumbs: crumbBase };
  let heading, title, description, crumbs;
  const canonical = `${SITE.url}/shop${catalog.toQuery(f)}`;
  if ((f.style || f.color) && !f.room && only([f.style ? 'style' : 'color'])) {
    const v = f.style || f.color;
    heading = f.style ? `${CAP(v)}-Style Furniture & Decor` : `${CAP(v)} Furniture & Decor`;
    title = heading;
    description = `Shop a curated selection of ${v} ${f.style ? 'style ' : ''}furniture, lighting and decor from top designer brands.`;
    crumbs = [...crumbBase, { name: CAP(v), url: canonical }];
  } else if (f.room && ROOM_SLUGS.has(f.room) && (f.style || f.color) && only(['room', f.style ? 'style' : 'color'])) {
    const v = f.style || f.color;
    heading = `${CAP(v)} ${roomLabel(f.room)}`;
    title = `${CAP(v)} ${roomLabel(f.room)} — Furniture & Decor`;
    description = `Shop ${v} pieces curated for the ${roomLabel(f.room).toLowerCase()} — designer furniture, lighting and decor.`;
    crumbs = [...crumbBase, { name: roomLabel(f.room), url: `${SITE.url}/rooms/${f.room}` }, { name: CAP(v), url: canonical }];
  } else {
    return generic;
  }
  // Count gate: differentiated-from-/shop check (mirrors the sitemap thresholds).
  const { n, total } = await catalog.matchAndTotal(f);
  if (n < SITEMAP_FACET_MIN || n > total * SITEMAP_FACET_MAX_RATIO) return generic;
  return { canonical, heading, title, description, crumbs };
}

app.get('/shop', async (req, res, next) => {
  try {
    const f = catalog.parseFilters(req.query);
    const seo = await facetSeo(f);
    await renderCatalog(res, { f, basePath: '/shop', activeNav: '/shop',
      heading: seo.heading, title: seo.title, description: seo.description, canonical: seo.canonical, crumbs: seo.crumbs });
  } catch (e) { next(e); }
});

app.get('/rooms/:room', async (req, res, next) => {
  try {
    const room = req.params.room;
    // Only serve rooms that are real nav categories. The wildcard used to mint a
    // self-canonical page for ANY slug the DB happened to hold (kitchen/paint/
    // bathroom), creating orphan pages that compete for authority. 404 the rest.
    const match = ROOMS.find(([s]) => s === room);
    if (!match) return next();
    const label = match[1];
    const f = { ...catalog.parseFilters(req.query), room };
    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Shop', url: `${SITE.url}/shop` },
      { name: label, url: `${SITE.url}/rooms/${room}` }];
    await renderCatalog(res, { f, basePath: '/shop', heading: label,
      title: `${label} Furniture & Decor`, description: `Curated ${label.toLowerCase()} pieces from top design brands.`,
      canonical: `${SITE.url}/rooms/${room}`, activeNav: `/rooms/${room}`, crumbs });
  } catch (e) { next(e); }
});

app.get('/guides', async (_req, res, next) => {
  try {
    const { rows } = await db.query(`SELECT slug,title,dek,hero_image,
      (SELECT p.image_url FROM products p WHERE p.id = ANY(guides.product_ids) AND NOT p.suppressed AND p.image_url IS NOT NULL LIMIT 1) AS fallback_image
      FROM guides WHERE published ORDER BY created_at DESC`);
    const cards = rows.map((g) =>
      `<a class="guide-card" href="/guides/${esc(g.slug)}">
        ${(g.hero_image || g.fallback_image) ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image || g.fallback_image)}" alt="${esc(g.title)}">` : ''}
        <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Guides', url: `${SITE.url}/guides` }];
    const body = `<section>${breadcrumbNav(crumbs)}<h1>Buying Guides</h1><div class="guide-grid">${cards || '<p>Guides coming soon.</p>'}</div></section>`;
    // ItemList of the published guides so the index itself is a structured collection
    // Google can surface (values are raw here — JSON.stringify escapes them, not esc()).
    const itemList = { '@type': 'ItemList', name: 'Interior Design Buying Guides',
      numberOfItems: rows.length,
      itemListElement: rows.map((g, i) => ({ '@type': 'ListItem', position: i + 1, url: `${SITE.url}/guides/${g.slug}`, name: g.title })) };
    const collection = { '@context': 'https://schema.org', '@type': 'CollectionPage', name: 'Interior Design Buying Guides', url: `${SITE.url}/guides`, mainEntity: itemList };
    const jsonld = [collection, breadcrumbJsonld(crumbs)].filter(Boolean);
    res.send(layout({ title: 'Interior Design Buying Guides', description: 'Editorial guides: how to choose, shop-the-look, and the best of every category.', canonical: `${SITE.url}/guides`, jsonld, body, activeNav: '/guides' }));
  } catch (e) { next(e); }
});

app.get('/guides/:slug', async (req, res, next) => {
  try {
    const { rows } = await db.query(`SELECT ${COLS.GUIDE} FROM guides WHERE slug=$1 AND published`, [req.params.slug]);
    if (!rows.length) return next();
    const g = rows[0];
    let picks = [];
    if (g.product_ids && g.product_ids.length) {
      const r = await db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE id = ANY($1) AND NOT suppressed`, [g.product_ids]);
      picks = r.rows;
    }
    // Guide imagery falls back to a representative product image (like rooms fall back to
    // thumbs) so a null hero_image doesn't leave the guide imageless in JSON-LD / OG / hero.
    const guideImg = g.hero_image || (picks[0] && picks[0].image_url) || null;
    const article = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: guideImg ? absUrl(guideImg) : undefined, url: `${SITE.url}/guides/${g.slug}`, datePublished: g.created_at, dateModified: g.updated_at };
    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Guides', url: `${SITE.url}/guides` },
      { name: g.title, url: `${SITE.url}/guides/${g.slug}` }];
    const body = `<article class="guide">
      ${breadcrumbNav(crumbs)}
      <h1>${esc(g.title)}</h1><p class="dek">${esc(g.dek || '')}</p>
      ${guideImg ? `<img class="guide-hero" data-hide-on-error src="${esc(guideImg)}" alt="${esc(g.title)}">` : ''}
      <div class="guide-body">${md(g.body_md || '')}</div>
      ${picks.length ? `<h2>Shop this guide</h2><div class="grid">${picks.map(productCard).join('')}</div>` : ''}
    </article>`;
    const jsonld = [article, breadcrumbJsonld(crumbs)].filter(Boolean);
    res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, image: guideImg, body, activeNav: '/guides', ogType: 'article', ogArticle: { published: new Date(g.created_at).toISOString(), modified: new Date(g.updated_at).toISOString() } }));
  } catch (e) { next(e); }
});

// Affiliate go-through: log the click (analytics + Amazon audit trail), then 302 to the tracked link.
app.get('/go/:id', async (req, res, next) => {
  try {
    // Validate the id BEFORE it reaches the bigint column. A non-numeric id like
    // /go/abc would otherwise throw "invalid input syntax for type bigint" and 500
    // the revenue path (bots + mangled links hit this). Non-int → 404, cleanly.
    const id = intOrNull(req.params.id);
    if (!id) return next();
    const { rows } = await db.query(`SELECT id, network, advertiser, affiliate_url FROM products WHERE id=$1`, [id]);
    if (!rows.length) return next();
    const p = rows[0];
    db.query(`INSERT INTO clicks (product_id, network, advertiser, referer, ua) VALUES ($1,$2,$3,$4,$5)`,
      [p.id, p.network, p.advertiser, req.get('referer') || null, req.get('user-agent') || null]).catch(() => {});
    res.redirect(302, p.affiliate_url);
  } catch (e) { next(e); }
});

app.get('/disclosure', (_req, res) => {
  const body = `<article class="legal"><h1>Affiliate Disclosure</h1>
    <p>Interior Designer's Showroom is a participant in affiliate advertising programs — including the Amazon Associates Program, Commission Junction (CJ), Rakuten Advertising, and ShareASale/Impact — designed to provide a means for sites to earn advertising fees.</p>
    <p><strong>When you click a product link and make a purchase, we may earn a commission at no additional cost to you.</strong> This never changes the price you pay, and it never influences which products we feature — every piece is chosen for design merit first.</p>
    <p>Prices and availability shown are pulled from our partners and are accurate as of the date and time displayed; they are subject to change. As an Amazon Associate we earn from qualifying purchases.</p></article>`;
  res.send(layout({ title: 'Affiliate Disclosure', description: "How Interior Designer's Showroom earns affiliate commissions.", canonical: `${SITE.url}/disclosure`, body }));
});

app.get('/privacy', (_req, res) => {
  const body = `<article class="legal"><h1>Privacy</h1>
    <p>We log anonymous click events (which product link was clicked, referring page, browser type) to understand what our readers find useful. We do not sell personal data. Affiliate partners may set their own cookies when you visit their sites after clicking through.</p>
    <p>We use Google AdSense and related Google advertising services. Google and its partners may use cookies or similar technologies to personalize or measure ads. You can manage personalized advertising in <a href="https://adssettings.google.com" rel="noopener">Google Ads Settings</a>; you can also learn more at <a href="https://policies.google.com/technologies/ads" rel="noopener">Google's advertising privacy page</a>.</p></article>`;
  res.send(layout({ title: 'Privacy', description: "Privacy practices for Interior Designer's Showroom.", canonical: `${SITE.url}/privacy`, body }));
});

app.get('/robots.txt', (_req, res) => {
  // Let crawlers index content; keep them out of the admin + the affiliate redirects
  // (Google dislikes crawling long chains of sponsored/nofollow go-through links).
  res.type('text/plain').send(`User-agent: *\nAllow: /\nDisallow: /admin\nDisallow: /go/\n\nSitemap: ${SITE.url}/sitemap.xml\n`);
});

app.get('/sitemap.xml', async (_req, res, next) => {
  try {
    const GATE = catalog.AFFILIATE_ENABLED; // in-stock/not-suppressed added inline below
    const [{ rows: guides }, { rows: rooms }, { rows: singles }, { rows: pairs }, { rows: totRows }] = await Promise.all([
      db.query(`SELECT slug, updated_at FROM guides WHERE published`),
      db.query(`SELECT slug, updated_at FROM rooms WHERE public`),
      // single-facet landing pages (style, color) above the inventory threshold.
      // Wrapped in a subquery so the threshold applies to BOTH halves of the union
      // (a trailing HAVING would bind only to the last SELECT).
      db.query(`
        SELECT dim, val, n FROM (
          SELECT 'style' AS dim, style AS val, count(*) AS n FROM products
            WHERE in_stock AND NOT suppressed AND style IS NOT NULL AND ${GATE} GROUP BY style
          UNION ALL
          SELECT 'color', color, count(*) FROM products
            WHERE in_stock AND NOT suppressed AND color IS NOT NULL AND ${GATE} GROUP BY color
        ) t WHERE n >= $1`, [SITEMAP_FACET_MIN]),
      // room × {style,color} long-tail pairs above the threshold ("modern bedroom")
      db.query(`
        SELECT 'style' AS dim, room, style AS val, count(*) AS n FROM products
          WHERE in_stock AND NOT suppressed AND room IS NOT NULL AND style IS NOT NULL AND ${GATE}
          GROUP BY room, style HAVING count(*) >= $1
        UNION ALL
        SELECT 'color', room, color, count(*) FROM products
          WHERE in_stock AND NOT suppressed AND room IS NOT NULL AND color IS NOT NULL AND ${GATE}
          GROUP BY room, color HAVING count(*) >= $1`, [SITEMAP_FACET_MIN]),
      db.query(`SELECT count(*) AS n FROM products WHERE in_stock AND NOT suppressed AND ${GATE}`),
    ]);
    const iso = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null);
    // Upper bound: drop facets that are basically the whole catalog (near-dupes of
    // /shop), and drop room×facet pairs for orphan rooms with no nav/category page.
    // This keeps the sitemap set IDENTICAL to facetSeo's self-canonical whitelist.
    const maxN = Number(totRows[0].n) * SITEMAP_FACET_MAX_RATIO;
    const keepSingle = (r) => Number(r.n) <= maxN;
    const keepPair = (r) => ROOM_SLUGS.has(r.room) && Number(r.n) <= maxN;
    // Build facet URLs through catalog.toQuery so they are byte-identical to the
    // canonical links the facet rail emits (same param order) — no duplicate URLs.
    const facetLocs = [
      ...singles.filter(keepSingle).map((r) => `shop${catalog.toQuery({}, { [r.dim]: r.val })}`),
      ...pairs.filter(keepPair).map((r) => `shop${catalog.toQuery({ room: r.room }, { [r.dim]: r.val })}`),
    ];
    // {loc, lastmod?} — static pages first, then room categories, guides, the
    // public visitor-built rooms, and now the indexable facet landing pages.
    const entries = [
      { loc: '' }, { loc: 'shop' }, { loc: 'looks' }, { loc: 'build' },
      { loc: 'guides' }, { loc: 'disclosure' }, { loc: 'privacy' },
      ...ROOMS.map(([s]) => ({ loc: `rooms/${s}` })),
      ...guides.map((g) => ({ loc: `guides/${g.slug}`, lastmod: iso(g.updated_at) })),
      ...rooms.map((r) => ({ loc: `room/${r.slug}`, lastmod: iso(r.updated_at) })),
      ...facetLocs.map((loc) => ({ loc })),
    ];
    const urls = entries.map((e) =>
      `<url><loc>${SITE.url}/${esc(e.loc)}</loc>${e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : ''}</url>`).join('');
    res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
  } catch (e) { next(e); }
});

// ---------------------- Room Builder --------------------------------------
const STYLES = ['modern', 'mid-century', 'traditional', 'farmhouse', 'industrial', 'boho', 'coastal', 'glam', 'scandinavian'];
const ROOM_TYPES = [['living-room', 'Living Room'], ['bedroom', 'Bedroom'], ['dining', 'Dining'], ['office', 'Office'], ['kitchen', 'Kitchen'], ['outdoor', 'Outdoor']];

app.get('/api/catalog/search', async (req, res, next) => {
  try {
    const r = await rooms.searchProducts({
      q: req.query.q, room: req.query.room, style: req.query.style,
      colors: req.query.colors ? String(req.query.colors).split(',').filter(Boolean) : null,
      advertiser: req.query.brand, cat: req.query.cat,
      max: req.query.max ? parseInt(req.query.max, 10) : null, limit: 48,
      offset: req.query.offset ? parseInt(req.query.offset, 10) : 0,
    });
    res.json(r);
  } catch (e) { next(e); }
});

app.get('/api/paints', async (_req, res, next) => {
  try { res.json(await rooms.getPaints(80)); } catch (e) { next(e); }
});

app.get('/api/brands', async (_req, res, next) => {
  try { res.json(await rooms.listBrands(40)); } catch (e) { next(e); }
});

app.post('/api/rooms', async (req, res, next) => {
  try {
    const b = req.body || {};
    // scene_image is rendered raw into <img src> on /room/:slug — only accept our own
    // generated paths (/img/rooms/<16-hex>.png), never an attacker-supplied URL.
    if (b.scene_image && !/^\/img\/rooms\/[a-f0-9]{16}\.png$/.test(b.scene_image)) b.scene_image = null;
    // hotspots from the builder — keep only well-formed {id, box:{x,y,w,h}} entries
    const cleanSpots = Array.isArray(b.hotspots)
      ? b.hotspots.filter((h) => h && Number(h.id) && h.box && ['x', 'y', 'w', 'h'].every((k) => typeof h.box[k] === 'number')).slice(0, 40)
      : [];
    let ids = intIds(b.product_ids).slice(0, 40);
    // "Generate from a vibe alone": back-fill matching pieces, loosening filters
    // until we always land a populated room (room+style -> style -> color -> room -> any).
    if (ids.length === 0) {
      for (const attempt of [{ room: b.room_type, style: b.style }, { style: b.style }, { color: b.color }, { room: b.room_type }, {}]) {
        const filler = await rooms.searchProducts(Object.assign({ limit: 8 }, attempt));
        if (filler.length) { ids = filler.map(p => p.id); break; }
      }
    }
    if (ids.length === 0) return res.status(400).json({ error: 'No products available to build a room.' });
    const slug = await rooms.createRoom({
      title: b.title, room_type: b.room_type, style: b.style,
      wall_paint_id: intOrNull(b.wall_paint_id),
      product_ids: ids, note: b.note, scene_image: b.scene_image || null,
      hotspots: cleanSpots,
      created_by: b.created_by === 'curator' ? 'curator' : 'visitor',
    });
    res.json({ slug, url: `/room/${slug}` });
  } catch (e) { next(e); }
});

const DIMENSIONS = {
  style: STYLES,
  hue: ['warm', 'cool', 'neutral', 'bold', 'muted', 'earthy'],
  color: [], // rendered as swatches (COLORS_12), handled specially
  theme: ['minimalist', 'cozy', 'luxe', 'eclectic', 'organic', 'moody', 'airy', 'playful'],
  period: ['mid-century', 'art deco', 'victorian', 'contemporary', '70s retro', 'bauhaus', 'traditional'],
};
// 12 color swatches, multi-select (1-to-many). `bucket` maps to the catalog color field.
const COLORS_12 = [
  { name: 'Alabaster', hex: '#f2ede3', bucket: 'neutral' }, { name: 'Greige', hex: '#c9bca4', bucket: 'neutral' },
  { name: 'Gray', hex: '#9a948c', bucket: 'gray' }, { name: 'Charcoal', hex: '#33302b', bucket: 'black' },
  { name: 'Walnut', hex: '#6b4a2f', bucket: 'brown' }, { name: 'Camel', hex: '#c2a06a', bucket: 'brown' },
  { name: 'Navy', hex: '#2c4a6e', bucket: 'blue' }, { name: 'Sky', hex: '#6f9fc4', bucket: 'blue' },
  { name: 'Sage', hex: '#8a9a78', bucket: 'green' }, { name: 'Emerald', hex: '#2f6f57', bucket: 'green' },
  { name: 'Ochre', hex: '#caa23e', bucket: 'yellow' }, { name: 'Blush', hex: '#d9a7a9', bucket: 'pink' },
];

// Cost guard: cap paid renders (global + per-IP hourly, plus a hard global
// daily cap) so a public render button can't run up an unbounded Gemini bill.
// PERSISTED to disk (data/render-hits.json) so a pm2 restart / deploy / crash
// does NOT reset the counter to zero — the cap survives restarts, which is the
// whole point of a spend guard on a paid endpoint.
const RENDER_HITS_FILE = path.join(__dirname, 'data', 'render-hits.json');
let _renderHits = [];
try {
  _renderHits = JSON.parse(fs.readFileSync(RENDER_HITS_FILE, 'utf8'));
  if (!Array.isArray(_renderHits)) _renderHits = [];
} catch (_) { _renderHits = []; } // missing/corrupt file -> start clean
function _persistRenderHits() {
  try {
    fs.mkdirSync(path.dirname(RENDER_HITS_FILE), { recursive: true });
    fs.writeFileSync(RENDER_HITS_FILE, JSON.stringify(_renderHits));
  } catch (e) { console.error('[render] could not persist hits:', e.message); }
}
function renderAllowed(ip) {
  const now = Date.now(), hourAgo = now - 3600e3, dayAgo = now - 86400e3;
  // prune anything older than the widest (daily) window
  _renderHits = _renderHits.filter(h => h.t >= dayAgo);
  const dayN = _renderHits.length;
  const hourHits = _renderHits.filter(h => h.t >= hourAgo);
  const globalN = hourHits.length;
  const perIp = hourHits.filter(h => h.ip === ip).length;
  const GLOBAL_CAP = parseInt(process.env.RENDER_HOURLY_CAP || '60', 10);
  const IP_CAP = parseInt(process.env.RENDER_IP_CAP || '8', 10);
  const DAILY_CAP = parseInt(process.env.RENDER_DAILY_CAP || '200', 10);
  if (dayN >= DAILY_CAP || globalN >= GLOBAL_CAP || perIp >= IP_CAP) return false;
  _renderHits.push({ t: now, ip });
  _persistRenderHits();
  return true;
}

// Photoreal AI render of a room scene (paid — Gemini image, ~$0.039/image).
app.post('/api/render', async (req, res) => {
  try {
    // Behind Cloudflare, cf-connecting-ip is the real (un-spoofable-by-header) client;
    // x-forwarded-for is a client-settable list, so only trust it as a fallback.
    const ip = (req.headers['cf-connecting-ip']
      || (req.headers['x-forwarded-for'] || '').split(',')[0]
      || req.ip || '').trim();
    if (!renderAllowed(ip)) return res.status(429).json({ error: 'Render limit reached — try again in a bit.' });
    const b = req.body || {};
    // full product set (order-preserved) for hotspot mapping; scene refs use the first few
    const ids = intIds(b.product_ids).slice(0, 12);
    let products = [];
    if (ids.length) {
      const r = await db.query('SELECT id,title,price,sale_price,image_url,advertiser FROM products WHERE id = ANY($1)', [ids]);
      // products.id is bigint → node-pg returns it as a STRING, so compare type-safe
      // (a strict `p.id === id` against the numeric id silently matched nothing).
      products = ids.map((id) => r.rows.find((p) => String(p.id) === String(id))).filter(Boolean);
    } else {
      // "generate from a vibe alone": back-fill real matching pieces so the room is
      // populated with shoppable products (and shows their thumbs), loosening filters
      // until we land some — same strategy the /api/rooms save uses.
      for (const attempt of [{ room: b.room_type, style: b.style }, { style: b.style }, { color: b.color }, { room: b.room_type }, {}]) {
        const filler = await rooms.searchProducts(Object.assign({ limit: 6 }, attempt));
        // prefer a fuller room; only settle for a sparse result if nothing better turns up
        if (filler.length >= 3) { products = filler; break; }
        if (filler.length > products.length) products = filler;
      }
    }
    let wall = null;
    const wpId = intOrNull(b.wall_paint_id);
    if (wpId) { const w = (await db.query('SELECT title FROM products WHERE id=$1', [wpId])).rows[0]; wall = w && w.title; }
    const out = await scene.generateScene({ style: b.style, color: b.color, theme: b.theme, period: b.period, room_type: b.room_type, wall, products: products.slice(0, 6) });
    // vision-locate the pieces so the builder render is shoppable too (paid Flash, ~$0.001)
    const loc = await hotspots.locateProducts(path.join(__dirname, 'public', out.url), products);
    const cost = (out.cost || 0) + (loc.cost || 0);
    console.log(`[render] $${cost.toFixed(3)} scene (${out.refs} refs) + ${loc.hotspots.length} hotspots`); // cost line per Steve's rule
    // return the products in the room so the right panel can show them (shoppable)
    const roomProducts = products.map((p) => ({ id: p.id, title: p.title, image_url: p.image_url, price: p.price, sale_price: p.sale_price, advertiser: p.advertiser, brand: p.brand }));
    res.json({ ...out, cost, hotspots: loc.hotspots, products: roomProducts });
  } catch (e) { console.error('[render]', e.message); res.status(500).json({ error: 'Render failed. Please try again.' }); }
});

app.get('/build', (_req, res) => {
  const roomOpts = ROOM_TYPES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
  const cap = s => s.replace(/\b\w/g, c => c.toUpperCase());
  const TRENDING = ['Quiet Luxury', 'Japandi', 'Mid-Century Modern', 'Modern Farmhouse', 'Coastal', 'Dark & Moody', 'Boho', 'Scandinavian', 'Maximalist'];
  const CATS = ['Sofas', 'Sectionals', 'Chairs', 'Desks', 'Tables', 'Beds', 'Dressers', 'Bookcases', 'Rugs', 'Lighting', 'Mirrors', 'Wall Art', 'Vases', 'Bar Stools', 'Appliances'];
  const tab = (title, inner, id) =>
    `<details class="rb-tab"${id ? ` id="${id}"` : ''}><summary>${title}</summary><div class="rb-opts">${inner}</div></details>`;
  // left panel: ALL tabs start collapsed; Color tab has the paint-strip inline
  const tabs =
    tab('Trending', TRENDING.map(t => `<button class="rb-opt trend" data-trend="${t}" type="button">${t}</button>`).join('')) +
    Object.entries(DIMENSIONS).map(([dim, vals]) => dim === 'color'
      ? tab('Color <span class="subtle" aria-label="pick one or more">pick 1+</span>',
          COLORS_12.map(c => `<button class="rb-swatch" type="button" data-color="${c.bucket}" data-name="${c.name}" title="${c.name}" aria-label="${c.name}" style="background:${c.hex}"></button>`).join(''))
      : tab(cap(dim), vals.map(v => `<button class="rb-opt" type="button" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
    tab('Categories', CATS.map(c => `<button class="rb-opt" type="button" data-cat="${c}">${c}</button>`).join('')) +
    `<details class="rb-tab"><summary>Brand</summary><div class="rb-opts" id="rbBrands"><span class="empty">Loading…</span></div></details>`;

  const body = `<section class="rb">
    <div class="rb-tray" id="rbTray" role="toolbar" aria-label="Room builder controls">
      <select id="rbRoom" aria-label="Room type"><option value="">Room…</option>${roomOpts}</select>
      <div class="rb-chips" id="rbChips" role="list" aria-label="Selected filters and pieces">
        <span class="rb-hint">Pick a vibe on the left, add pieces from <strong>Products</strong> on the right →</span>
      </div>
      <input id="rbTitle" type="text" placeholder="Room name (optional)" aria-label="Room name">
      <button id="rbGo" class="cta" type="button" aria-label="Save and view this room">GO ▶</button>
    </div>
    <div class="rb-body">
      <aside class="rb-left" aria-label="Style filters">${tabs}
        <details class="rb-tab"><summary>Wall color <span class="subtle">Samplize</span></summary>
          <div class="paint-strip" id="rbPaints" role="list" aria-label="Wall paint options"></div>
        </details>
      </aside>
      <div class="rb-mid" id="rbMid">
        <div class="rb-render-bar">
          <button id="rbRenderBtn" class="cta" type="button" aria-label="Generate an AI room setting image">Generate room setting</button>
          <span class="rb-cost" id="rbCost" aria-live="polite">~$0.039 each · uses your vibe + selected pieces</span>
        </div>
        <div class="rb-renders" id="rbRenders" role="list" aria-label="Generated room settings" aria-live="polite">
          <div class="rb-render-ph">
            <span>Your generated room settings appear here.</span><br>
            <small>Pick a vibe on the left, add <strong>Products</strong> from the right, then hit <strong>Generate</strong>. Make several, pick your favorite, and hit <strong>GO</strong> to save.</small>
          </div>
        </div>
        <h4 class="rb-pieces-h" id="rbPiecesH" hidden>Shop the pieces in this room</h4>
        <div class="rb-pieces" id="rbPieces" role="list" aria-label="Pieces added to this room"></div>
      </div>
      <button class="rb-ham" id="rbHam" type="button" aria-label="Open products panel" aria-expanded="false" aria-controls="rbRight">
        <span aria-hidden="true">☰</span> Products
      </button>
      <aside class="rb-right collapsed" id="rbRight" aria-label="Products panel">
        <div class="rb-right-head">
          <span>Products</span>
          <button class="rb-ham-close" id="rbHamClose" type="button" aria-label="Close products panel" aria-expanded="true" aria-controls="rbRight">✕</button>
        </div>
        <input id="rbSearch" type="search" placeholder="Search products…" aria-label="Search products">
        <div class="rb-products" id="rbProducts" aria-live="polite" aria-label="Product results"><p class="empty">Loading…</p></div>
      </aside>
    </div>
  </section><script src="${v('/js/build.js')}"></script>`;
  res.send(layout({ title: 'Room Builder', description: 'Compose a shoppable room — pick a style, hue, color, theme & period, drag in pieces, and generate.', canonical: `${SITE.url}/build`, body, activeNav: '/build' }));
});

app.get('/looks', async (_req, res, next) => {
  try {
    const list = await rooms.listRooms({ limit: 60 });
    const cards = list.map(r => {
      const thumbs = (r.thumbs || []).slice(0, 4);
      const thumbHtml = thumbs.length
        ? thumbs.map(t => `<img loading="lazy" src="${esc(t)}" alt="">`).join('')
        : '<div class="noimg">No preview</div>';
      const pieceCount = r.piece_count != null ? Number(r.piece_count) : (r.product_ids || []).length;
      const meta = [r.style, r.room_type].filter(Boolean).join(' ');
      return `<a class="look-card" href="/room/${esc(r.slug)}" aria-label="${esc(r.title)}${meta ? ' — ' + meta : ''}">
        <div class="look-thumbs">${thumbHtml}</div>
        <h3>${esc(r.title)}</h3>
        <p class="subtle">${esc(meta)}${meta && pieceCount ? ' · ' : ''}${pieceCount ? `${pieceCount} piece${pieceCount === 1 ? '' : 's'}` : ''}</p>
      </a>`;
    }).join('');

    const emptyState = `<p class="subtle">No rooms yet — <a href="/build">be the first to build one</a>.</p>`;
    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Rooms', url: `${SITE.url}/looks` }];
    const body = `<section>
      ${breadcrumbNav(crumbs)}
      <div class="builder-head">
        <h1>Rooms</h1>
        <a class="cta sm" href="/build" aria-label="Build a new room">+ Build a room</a>
      </div>
      <div class="look-grid">${cards || emptyState}</div>
    </section>`;
    res.send(layout({ title: 'Shop the Look — Curated Rooms', description: 'Browse shoppable rooms built from real designer pieces.', canonical: `${SITE.url}/looks`, jsonld: breadcrumbJsonld(crumbs), body, activeNav: '/looks' }));
  } catch (e) { next(e); }
});

app.get('/room/:slug', async (req, res, next) => {
  try {
    const data = await rooms.getRoom(req.params.slug);
    if (!data) return next();
    const { room, products, paint } = data;
    const itemList = { '@context': 'https://schema.org', '@type': 'ItemList', name: room.title, numberOfItems: products.length };
    const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Rooms', url: `${SITE.url}/looks` },
      { name: room.title, url: `${SITE.url}/room/${room.slug}` }];

    // Wall color (Samplize) block — editorial treatment
    const paintBlock = paint ? `
      <div class="room-paint">
        <a href="/go/${paint.id}" rel="nofollow sponsored" target="_blank" aria-label="${esc(paint.title)} — get the paint sample">
          <img src="${esc(paint.image_url)}" alt="${esc(paint.title)}" width="72" height="72">
        </a>
        <div class="room-paint-info">
          <span class="room-paint-label">Wall color</span>
          <span class="room-paint-name">${esc(paint.title)}</span>
          <a class="buy" href="/go/${paint.id}" rel="nofollow sponsored" target="_blank">Get the sample →</a>
        </div>
      </div>` : '';

    const styleLabel = [room.style, room.room_type].filter(Boolean).join(' ');
    const hasScene = !!room.scene_image;

    // Shoppable visual. With a generated scene: the scene + a side thumb-rail.
    // WITHOUT a scene (e.g. a freshly built room): the large click-to-shop thumbs
    // ARE the shoppable surface — each thumb expands to product info + a Buy
    // button — so a scene-less room no longer dead-ends at a bare card list.
    const sceneOrThumbs = hasScene
      ? `<div class="room-scene-row">${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}${roomThumbs(products)}</div>`
      : (products.length ? `<div class="room-thumbs-hero">${roomThumbs(products)}</div>` : '');

    // Detailed productCard grid: shown alongside a scene as the itemized list.
    // On scene-less rooms it's redundant with the thumbs (which already reveal
    // full info + Buy on click), so we omit it — the thumbs' info cards keep all
    // product text in the HTML for crawlers.
    const shopSection = products.length
      ? (hasScene ? `<h2 class="shop-heading">Shop this room</h2><div class="grid">${products.map(productCard).join('')}</div>` : '')
      : `<div class="room-empty"><p>No shoppable pieces in this room yet.</p></div>`;

    const body = `<article class="room-page">
      ${breadcrumbNav(crumbs)}
      <div class="room-head">
        <h1>${esc(room.title)}</h1>
        <p class="subtle">${esc(styleLabel)}${styleLabel && products.length ? ' · ' : ''}${products.length ? `${products.length} shoppable piece${products.length === 1 ? '' : 's'}` : ''}</p>
      </div>
      ${sceneOrThumbs}
      ${paintBlock}
      ${shopSection}
      <div class="room-cta">
        <a class="cta sm" href="/build">Build your own room →</a>
      </div>
    </article>`;

    res.send(layout({
      title: room.title,
      description: room.note || `A shoppable ${styleLabel || 'room'} — ${products.length} designer pieces.`,
      canonical: `${SITE.url}/room/${room.slug}`,
      jsonld: [itemList, breadcrumbJsonld(crumbs)].filter(Boolean),
      // Scene-less saved rooms fall back to their first product's photo, not the logo.
      image: room.scene_image || (products[0] && products[0].image_url), body, activeNav: '/looks'
    }));
  } catch (e) { next(e); }
});

app.use(require('./routes/admin'));

app.use((_req, res) => res.status(404).send(layout({
  title: 'Page not found',
  description: 'That page moved or never existed — browse the showroom instead.',
  body: `<section style="text-align:center;padding:64px 20px;max-width:640px;margin:0 auto">
  <p class="subtle" style="letter-spacing:.18em;text-transform:uppercase;font-size:.78rem;margin:0">Error 404</p>
  <h1 style="font-family:'DM Serif Display',serif;font-size:2.4rem;line-height:1.15;margin:.25em 0">This page has moved on</h1>
  <p class="subtle" style="margin:0 auto 30px;max-width:44ch">We couldn't find what you were looking for — but there's plenty more to discover in the showroom.</p>
  <div style="display:flex;flex-wrap:wrap;gap:12px;justify-content:center">
    <a href="/shop" style="display:inline-block;padding:12px 22px;border:1px solid currentColor;border-radius:999px;text-decoration:none">Shop everything</a>
    <a href="/looks" style="display:inline-block;padding:12px 22px;border:1px solid currentColor;border-radius:999px;text-decoration:none">Browse rooms</a>
    <a href="/build" style="display:inline-block;padding:12px 22px;border:1px solid currentColor;border-radius:999px;text-decoration:none">Build a room</a>
    <a href="/guides" style="display:inline-block;padding:12px 22px;border:1px solid currentColor;border-radius:999px;text-decoration:none">Read the guides</a>
  </div>
</section>`,
})));
app.use((err, _req, res, _next) => { console.error(err); res.status(500).send(layout({ title: 'Error', body: '<section><h1>Something went wrong</h1></section>' })); });

// Bind to loopback by default (matches the log + keeps the app behind the proxy rather
// than exposed on every interface). Override with HOST=0.0.0.0 if a remote proxy needs it.
const HOST = process.env.HOST || '127.0.0.1';
const server = app.listen(PORT, HOST, () => console.log(`[IDS] listening on http://${HOST}:${PORT}`));
// Fail loud + clean on a listen error instead of an unhandled 'error' event crash.
server.on('error', (err) => {
  if (err.code === 'EADDRINUSE') console.error(`[IDS] port ${PORT} already in use — another instance running? (pm2 list)`);
  else if (err.code === 'EACCES') console.error(`[IDS] permission denied binding ${HOST}:${PORT}`);
  else console.error('[IDS] server listen error:', err);
  process.exit(1);
});