← back to Interiordesignershowroom

lib/render.js

324 lines

// Server-rendered page shell. Server-rendering (not a client SPA) is deliberate:
// affiliate sites live or die on SEO, and crawlers reward real HTML with proper
// <title>/meta/canonical/JSON-LD over JS-hydrated shells.

const { ROOMS } = require('./nav');
const { v } = require('./assetv'); // ?v= cache-buster for local css/js // shared room list — powers the header hamburger

const SITE = {
  name: 'Interior Designer\'s Showroom',
  tagline: 'A curated showroom of designer-worthy pieces — shop the look, room by room.',
  url: process.env.PUBLIC_URL || 'https://interiordesignershowroom.com',
};

const esc = (s = '') => String(s)
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;');

// Returns a formatted "$X,XXX.XX" string or empty string if value is null/undefined.
// Callers MUST check for empty string and hide the price element when blank.
const money = (n) => (n == null ? '' : '$' + Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }));

// Price-freshness trust signal. Uses price_checked_at (NOT updated_at) — the
// column that specifically records when we last confirmed the price against the
// feed — so we never overstate how current a price is. Flags anything older than
// two weeks as `stale` so a drifted feed price is visible rather than silent.
function freshness(ts) {
  if (!ts) return '';
  const days = Math.floor((Date.now() - new Date(ts).getTime()) / 86400e3);
  const label = days <= 0 ? 'today' : days === 1 ? 'yesterday'
    : days < 30 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`;
  const stale = days > 14;
  return `<span class="price-fresh${stale ? ' stale' : ''}" title="Price last checked ${esc(new Date(ts).toISOString())}">`
    + `Price checked ${label}</span>`;
}

// Single source of truth for how a created_at timestamp renders anywhere on the
// site. Both the storefront "Added" line (below) and the admin when() chip
// (routes/admin.js) format through this, so the shopper view and the curation
// view can never drift — they had: this used a hardcoded 'en-US' locale while
// admin used the viewer's locale, despite a comment claiming they matched.
// Returns null for a missing/garbage timestamp so callers render nothing rather
// than the literal "Invalid Date"; `days` is clamped at 0 so a clock-skewed or
// bad feed timestamp in the future never reads as a negative age.
function fmtStamp(ts) {
  if (!ts) return null;
  const d = new Date(ts);
  if (Number.isNaN(d.getTime())) return null;
  return {
    iso: d.toISOString(),
    label: d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }),
    days: Math.max(0, Math.floor((Date.now() - d.getTime()) / 86400e3)),
  };
}

// "Added to the store" signals for a storefront card, derived from created_at.
// Returns two pieces because they live in different parts of the card: a NEW
// badge over the image for recently-added items (≤ NEW_WINDOW_DAYS), and a dated
// "Added …" line (date + time, full ISO in title=) on every card. Formats through
// fmtStamp() so it reads identically to the admin when() chip.
const NEW_WINDOW_DAYS = 14;
function addedStamp(ts) {
  const f = fmtStamp(ts);
  if (!f) return { badge: '', line: '' };
  const iso = esc(f.iso);
  const badge = f.days <= NEW_WINDOW_DAYS ? `<span class="card-new" title="Added ${iso}">New</span>` : '';
  const line = `<div class="card-added" title="Added ${iso}">🕓 Added ${esc(f.label)}</div>`;
  return { badge, line };
}

// FTC 16 CFR Part 255 requires a clear, conspicuous disclosure. This is not optional.
const DISCLOSURE_SHORT =
  'This showroom is reader-supported. When you buy through links on our site, we may earn an affiliate commission at no extra cost to you.';

function disclosureBar() {
  return `<div class="disclosure" role="note">${esc(DISCLOSURE_SHORT)} <a href="/disclosure">How this works.</a></div>`;
}

function productCard(p) {
  // Price block: hide entirely when there's no price (not "$"), sale shows red + struck through original
  let priceHtml = '';
  if (p.sale_price != null) {
    priceHtml = `<span class="price sale">${money(p.sale_price)}</span><span class="price was">${money(p.price)}</span>`;
  } else if (p.price != null) {
    priceHtml = `<span class="price">${money(p.price)}</span>`;
  }
  // card-meta is only rendered when there is a price; empty div suppressed by CSS display:none
  const metaHtml = priceHtml
    ? `<div class="card-meta">${priceHtml}${freshness(p.price_checked_at)}</div>`
    : '';

  // Image: use img with explicit width/height for aspect-ratio + graceful broken-img handling
  const imgHtml = p.image_url
    ? `<img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.title)}">`
    : '';

  // "Added to the store" date+time: NEW badge over the image (recent items) plus a
  // dated line in the body. Per the chosen storefront treatment (2026-08-03).
  const { badge: newBadge, line: addedLine } = addedStamp(p.created_at);

  return `
  <article class="card" data-room="${esc(p.room)}" data-style="${esc(p.style)}" data-color="${esc(p.color)}"
           data-price="${p.sale_price ?? p.price ?? 0}" data-title="${esc(p.title)}" data-network="${esc(p.network)}"
           data-advertiser="${esc(p.advertiser || '')}" data-featured="${p.featured ? 1 : 0}" data-instock="${p.in_stock === false ? 0 : 1}"
           data-id="${p.id}">
    <a class="card-img" href="/go/${p.id}" rel="nofollow sponsored" target="_blank" tabindex="0"
       aria-label="${esc(p.title)} — shop at ${esc(p.advertiser || p.network || '')}">
      ${newBadge}${imgHtml || `<div class="noimg">${esc(p.advertiser || '')}</div>`}
    </a>
    <div class="card-body">
      ${p.brand || p.advertiser ? `<div class="card-brand">${esc(p.brand || p.advertiser || '')}</div>` : ''}
      <h3 class="card-title"><a href="/go/${p.id}" rel="nofollow sponsored" target="_blank">${esc(p.title)}</a></h3>
      ${metaHtml}
      ${addedLine}
      <a class="buy" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(p.advertiser || p.network)} →</a>
    </div>
  </article>`;
}

// Absolute-URL an image path for OG/JSON-LD (crawlers require absolute URLs).
const absUrl = (u) => (!u ? '' : /^https?:\/\//.test(u) ? u : `${SITE.url}${u.startsWith('/') ? '' : '/'}${u}`);

// Organization + WebSite schema for the home page. WebSite carries a SearchAction
// so Google can render a sitelinks search box pointed at /shop.
function orgWebsiteJsonld() {
  return [
    { '@context': 'https://schema.org', '@type': 'Organization', name: SITE.name, url: SITE.url, logo: `${SITE.url}/img/apple-touch-icon.png` },
    { '@context': 'https://schema.org', '@type': 'WebSite', name: SITE.name, url: SITE.url,
      potentialAction: { '@type': 'SearchAction', target: `${SITE.url}/shop?q={q}`, 'query-input': 'required name=q' } },
  ];
}

// ItemList of Product schema for the listing pages (/shop, /rooms/:room). This is
// the structured data that lets the commercial pages surface in rich results — the
// gap that previously left only editorial guides with schema.
// Honest itemCondition from the title (affiliate feeds can carry refurb/used items);
// default New. Keeps the schema factual rather than blanket-asserting New on everything.
function conditionOf(title) {
  const t = String(title || '').toLowerCase();
  if (/refurbish|renewed/.test(t)) return 'https://schema.org/RefurbishedCondition';
  if (/\bused\b|pre-owned|open box/.test(t)) return 'https://schema.org/UsedCondition';
  return 'https://schema.org/NewCondition';
}
function productListJsonld(products = [], listUrl) {
  // Google recommends priceValidUntil on Offer; a rolling 30-day window is the feed norm.
  const priceValidUntil = new Date(Date.now() + 30 * 86400e3).toISOString().slice(0, 10);
  const items = products.slice(0, 60).map((p, i) => {
    const price = p.sale_price ?? p.price;
    const prod = { '@type': 'Product', name: p.title, url: `${SITE.url}/go/${p.id}` };
    if (p.image_url) prod.image = absUrl(p.image_url);
    if (p.brand || p.advertiser) prod.brand = { '@type': 'Brand', name: p.brand || p.advertiser };
    if (price != null) prod.offers = { '@type': 'Offer', price: Number(price).toFixed(2), priceCurrency: 'USD', availability: p.in_stock === false ? 'https://schema.org/OutOfStock' : 'https://schema.org/InStock', itemCondition: conditionOf(p.title), priceValidUntil };
    return { '@type': 'ListItem', position: i + 1, item: prod };
  });
  return { '@context': 'https://schema.org', '@type': 'ItemList', url: listUrl, numberOfItems: items.length, itemListElement: items };
}

// Shoppable room-setting image: the scene photo with a hotspot dot centered on each
// vision-located piece. Tapping a dot opens an INLINE product card (image, title,
// price, tracked Shop link) — inline, never a modal. Pieces the vision pass couldn't
// place still appear as edge chips via the piece grid below, so nothing is unshoppable.
function sceneFigure({ scene_image, title, hotspots = [] }) {
  if (!scene_image) return '';
  const spots = (hotspots || []).map((h, i) => {
    const cx = Math.max(3, Math.min(97, h.box.x + h.box.w / 2)).toFixed(1);
    const cy = Math.max(3, Math.min(97, h.box.y + h.box.h / 2)).toFixed(1);
    const price = h.price != null ? money(h.price) : '';
    return `<button class="hotspot" style="left:${cx}%;top:${cy}%" data-card="hc${i}" aria-label="Shop ${esc(h.title)}" aria-expanded="false"><span class="hotspot-dot"></span></button>
      <div class="hotspot-card" id="hc${i}" style="left:${cx}%;top:${cy}%" role="dialog" aria-label="${esc(h.title)}" hidden>
        ${h.image_url ? `<img src="${esc(h.image_url)}" alt="" loading="lazy">` : ''}
        <div class="hotspot-card-body">
          <div class="hotspot-card-title">${esc(h.title)}</div>
          ${price ? `<div class="hotspot-card-price">${price}</div>` : ''}
          <a class="buy" href="/go/${h.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(h.advertiser || 'store')} →</a>
        </div>
      </div>`;
  }).join('');
  return `<figure class="room-scene-wrap${hotspots.length ? ' has-spots' : ''}">
    <img class="room-scene" data-hide-on-error src="${esc(scene_image)}" alt="AI-generated scene for ${esc(title)}">
    <div class="hotspot-layer">${spots}</div>
    ${hotspots.length ? '<span class="scene-hint" aria-hidden="true">Tap a dot to shop a piece</span>' : ''}
  </figure><script src="${v('/js/hotspots.js')}" defer></script>`;
}

// Large thumbnail rail of the room's pieces — stacks to the RIGHT of the scene on
// desktop, BELOW it on mobile (CSS). Each thumb is a BUTTON: clicking it reveals
// that piece's product info (brand · title · price) plus a tracked Buy button,
// inline right below the thumb — instead of jumping the shopper straight out.
function roomThumbs(products = []) {
  const withImg = (products || []).filter((p) => p.image_url);
  if (!withImg.length) return '';
  const items = withImg.map((p, i) => {
    let priceHtml = '';
    if (p.sale_price != null) priceHtml = `<span class="price sale">${money(p.sale_price)}</span> <span class="price was">${money(p.price)}</span>`;
    else if (p.price != null) priceHtml = `<span class="price">${money(p.price)}</span>`;
    const brand = p.brand || p.advertiser || '';
    return `<div class="room-thumb-item">
      <button type="button" class="room-thumb" aria-expanded="false" aria-controls="rt${i}" aria-label="Show details for ${esc(p.title)}">
        <img src="${esc(p.image_url)}" alt="${esc(p.title)}" loading="lazy">
      </button>
      <div class="room-thumb-info" id="rt${i}" role="group" aria-label="${esc(p.title)}" hidden>
        ${brand ? `<div class="rt-brand">${esc(brand)}</div>` : ''}
        <div class="rt-title">${esc(p.title)}</div>
        ${priceHtml ? `<div class="rt-price">${priceHtml}</div>` : ''}
        <a class="buy" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">Buy${p.advertiser ? ' at ' + esc(p.advertiser) : ''} &rarr;</a>
      </div>
    </div>`;
  }).join('');
  return `<div class="room-thumbs" role="list" aria-label="Items in this room">${items}</div><script src="${v('/js/room-thumbs.js')}" defer></script>`;
}

// Breadcrumbs from a single [{name, url}] trail — the LAST item is the current page
// (rendered as plain text, no link). Returns BOTH the visible <nav> and the matching
// BreadcrumbList JSON-LD so the two never disagree. url is absolute for the schema.
function breadcrumbNav(items = []) {
  if (items.length < 2) return '';
  // Visible links are root-relative (strip the origin) so internal nav never leaves
  // the current host on staging/localhost; the JSON-LD keeps absolute URLs per spec.
  const rel = (u) => (u || '').replace(SITE.url, '') || '/';
  const parts = items.map((it, i) => (i === items.length - 1)
    ? `<span aria-current="page">${esc(it.name)}</span>`
    : `<a href="${esc(rel(it.url))}">${esc(it.name)}</a>`);
  return `<nav class="breadcrumbs" aria-label="Breadcrumb">${parts.join('<span class="crumb-sep" aria-hidden="true">›</span>')}</nav>`;
}
function breadcrumbJsonld(items = []) {
  if (items.length < 2) return null;
  return {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: items.map((it, i) => ({
      '@type': 'ListItem', position: i + 1, name: it.name,
      item: absUrl(it.url) || it.url,
    })),
  };
}

function layout({ title, description, canonical, jsonld, image, body, activeNav, ogType, ogArticle }) {
  const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
  const nav = [
    ['/shop', 'Shop'],
    ['/looks', 'Rooms'],
    ['/build', 'Build a Room'],
    ['/guides', 'Guides'],
  ].map(([href, label]) =>
    `<a href="${href}"${activeNav === href ? ' class="active" aria-current="page"' : ''}>${label}</a>`).join('');

  // Upper-right hamburger of ALL room types. Native <details> => collapsed on load
  // with zero JS, focusable + keyboard-toggleable (Enter/Space on the summary),
  // and it closes on outside-click / Escape via a tiny enhancement in moodboard.js.
  const roomLinks = ROOMS.map(([slug, label]) => {
    const href = `/rooms/${slug}`;
    return `<a role="menuitem" href="${href}"${activeNav === href ? ' aria-current="page"' : ''}>${esc(label)}</a>`;
  }).join('');
  const roomMenu = `<details class="room-menu" id="roomMenu">
    <summary class="room-menu__btn" aria-label="Browse room categories" title="Shop by room">
      <span class="hamburger" aria-hidden="true"></span>
      <span class="room-menu__label">Rooms</span>
    </summary>
    <div class="room-menu__panel" role="menu" aria-label="Shop by room">
      <p class="room-menu__heading">Shop by Room</p>
      ${roomLinks}
    </div>
  </details>`;

  return `<!doctype html>
<html lang="en">
<head>
    <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-5278231299883833" crossorigin="anonymous"></script>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Google tag (gtag.js) — GA4 property "Interior Designers Showroom" (G-DH9G5HL7YJ) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-DH9G5HL7YJ"></script>
<script src="${v('/js/ga.js')}" defer></script>
<title>${esc(title)} · ${esc(SITE.name)}</title>
<meta name="description" content="${esc(description || SITE.tagline)}">
<link rel="canonical" href="${esc(canonical || SITE.url)}">
<meta property="og:site_name" content="${esc(SITE.name)}">
<meta property="og:title" content="${esc(title)}">
<meta property="og:description" content="${esc(description || SITE.tagline)}">
<meta property="og:type" content="${esc(ogType || 'website')}">${ogType === 'article' && ogArticle ? `<meta property="article:published_time" content="${esc(ogArticle.published)}"><meta property="article:modified_time" content="${esc(ogArticle.modified)}">` : ''}
<meta property="og:image" content="${esc(ogImage)}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="${esc(ogImage)}">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32.png">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500&family=DM+Serif+Display&display=swap">
<link rel="stylesheet" href="${v('/css/site.css')}">
<script src="${v('/js/imgfallback.js')}"></script>
${(Array.isArray(jsonld) ? jsonld : [jsonld]).filter(Boolean).map((j) => `<script type="application/ld+json">${JSON.stringify(j).replace(/</g, '\\u003c')}</script>`).join('')}
</head>
<body>
<a class="skip-link" href="#main-content">Skip to content</a>
${disclosureBar()}
<header class="site-header">
  <a class="brand" href="/" aria-label="${esc(SITE.name)} — home">
    <span class="brand-mark" aria-hidden="true">IDS</span>
    <span class="brand-word">${esc(SITE.name)}</span>
  </a>
  <nav class="site-nav" aria-label="Main navigation">${nav}</nav>
  ${roomMenu}
</header>
<main id="main-content" tabindex="-1">${body}</main>
<footer class="site-footer">
  <nav aria-label="Footer navigation">
    <a href="/shop">Shop</a>
    <a href="/looks">Rooms</a>
    <a href="/build">Build</a>
    <a href="/guides">Guides</a>
    <a href="/disclosure">Disclosure</a>
    <a href="/privacy">Privacy</a>
  </nav>
  <p style="margin:0 0 6px">${esc(DISCLOSURE_SHORT)}</p>
  <p class="fine">© ${new Date().getFullYear()} ${esc(SITE.name)}. Prices and availability are accurate as of the date/time shown and are subject to change. As an Amazon Associate we earn from qualifying purchases.</p>
</footer>
<script src="${v('/js/moodboard.js')}" defer></script>
<script src="${v('/js/roommenu.js')}" defer></script>
<script src="${v('/js/adminbar.js')}" defer></script>
</body>
</html>`;
}

module.exports = { SITE, esc, money, fmtStamp, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, breadcrumbNav, breadcrumbJsonld, absUrl, sceneFigure, roomThumbs };