// Collections — three-tier customer journey:
//
//   1.  CollectionsHub           ← `navigate('collections')`
//       Four-axis picker. The reception desk of the catalogue. Customer
//       chooses how they want to shop: by Room / Colour / Style / Shape&Size.
//
//   2.  CollectionsAxis           ← `navigate('collections-axis', 'room')`
//       The options under one axis. e.g. picking "By Room" lands here with
//       Bathroom / Kitchen / Living / Outdoor / etc. Each option shows a
//       thumbnail picked from the matching tiles + a live tile-count.
//
//   3.  CollectionDetail          ← `navigate('collection', { axis, value, label })`
//       The actual filtered grid of tiles. Big editorial hero, sticky
//       finish/size sub-filters, the tile cards. (Same component as before,
//       refactored to take a filter spec rather than a category object.
//       Still accepts the legacy `{id: 'bath', label: 'Bath Tiles'}` shape
//       so the nav menu and home cards keep working without edits.)
//
// Tiles gain four new fields via scripts/tag-tiles.mjs:
//   room: string[]   colour: string   style: string   shape: string
// Old fields (cat / finish / size / img / …) are untouched.

const { useState, useEffect, useMemo } = React;

// ─── Global RowCard styles ────────────────────────────────────────────
// Injected once into document.head when this module loads, so the .row-card
// layout works on every page that renders <RowCard/> — both the listing
// (CollectionDetail) and the product page's "You might also like"
// suggestions strip. Previously these rules were buried inside
// CollectionDetail's inline <style>, which meant on the product page they
// never reached the DOM at all — every row card collapsed to display:block
// with all children stacked as full-width strips.
(function injectRowCardStyles() {
  if (typeof document === 'undefined') return;
  if (document.getElementById('venoraa-rowcard-styles')) return;
  const style = document.createElement('style');
  style.id = 'venoraa-rowcard-styles';
  style.textContent = [
    '.row-card { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; background: white; padding: 12px; border: 1px solid transparent; cursor: pointer; transition: border-color 0.3s var(--ease-out), box-shadow 0.4s var(--ease-out), transform 0.4s var(--ease-out); }',
    '.row-card:hover { border-color: var(--dark); box-shadow: 0 14px 36px rgba(20,16,12,0.09); transform: translateY(-2px); }',
    '.row-card-big { position: relative; aspect-ratio: 1; overflow: hidden; background: radial-gradient(120% 100% at 50% 0%, #FFFFFF 0%, #F4F4F2 55%, #E8E6E2 100%); box-shadow: inset 0 0 0 1px rgba(15,15,15,0.06), inset 0 30px 60px -28px rgba(15,15,15,0.12), 0 8px 22px -12px rgba(15,15,15,0.18), 0 1px 2px rgba(15,15,15,0.04); transition: box-shadow 0.4s var(--ease-out); }',
    '.row-card-big:hover { box-shadow: inset 0 0 0 1px rgba(15,15,15,0.08), inset 0 30px 60px -28px rgba(15,15,15,0.10), 0 16px 36px -16px rgba(15,15,15,0.22), 0 2px 4px rgba(15,15,15,0.06); }',
    /* BIG cell: stack of cycling sight images. Position absolute so
       multiple images layer at full bleed; only the active one has
       opacity:1 (set inline via React) and the rest crossfade. */
    '.row-card-image { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; will-change: opacity, transform; }',
    '.row-card-big:hover .row-card-image { transform: scale(1.04); }',
    /* Side column: thumbs row above + details strip below. */
    '.row-card-side { display: flex; flex-direction: column; gap: 12px; min-width: 0; }',
    /* Thumbs: 3 static squares side-by-side. */
    '.row-card-thumbs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }',
    /* Letterbox: plain white for now. A future iteration may replace
       this with a tile-colour-aware backdrop or an editorial image. */
    '.row-card-thumb-wrap { position: relative; aspect-ratio: 1; overflow: hidden; background: #ffffff; }',
    /* contain (not cover) so non-square photography fits in full,
       then rotated 90° so the photo's vertical edges become the new
       top + bottom — meaning the image touches top + bottom of the
       cell, and the white wrapper background shows as side bands.
       Sight/hero stays cover (full bleed). */
    '.row-card-thumb-image { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity 0.35s var(--ease-out); }',
    /* Simple thumb hover: a subtle opacity dim — no zoom/scale, no
       extra movement. The card's own lift (translateY -2px) covers
       the "this is hoverable" feedback; the thumbs stay still. */
    '.row-card-thumb-wrap:hover .row-card-thumb-image { opacity: 0.85; }',
    '.row-card-details { flex: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 4px 4px; min-width: 0; }',
    /* text-align: left wins over the inherited centre from
       .product-suggestions, so the eyebrow ("Hermes") aligns to the
       same left edge as the tile name ("HERMES WHITE") below it. */
    '.row-card-info-left { min-width: 0; text-align: left; }',
    '.row-card-eyebrow { font-family: var(--sans); font-size: 10px; font-weight: 500; letter-spacing: 0.24em; text-transform: uppercase; color: var(--terracotta); margin-bottom: 4px; }',
    /* Italic serif has a slight optical left-bearing (slanted letters
       sit visually right of their box edge). text-indent pulls the
       first letter back so its left edge aligns with the eyebrow above. */
    /* Italic serif clipping fix:
        Italic Fraunces has a right optical bearing — the last
        letter's stroke (especially on M, K, W, etc.) extends past
        the glyph box. With `overflow: hidden` + `text-overflow:
        ellipsis`, that overhang gets sliced off at the box edge.
        `padding-right: 0.2em` adds a tiny gutter so the trailing
        italic stroke renders cleanly. We also drop the negative
        letter-spacing (-0.01em → 0) so glyphs don't crowd into
        each other's terminals on long names like "HERMES CREAM". */
    '.row-card-name { font-family: var(--serif); font-weight: 400; font-style: italic; font-size: clamp(22px, 1.9vw, 30px); line-height: 1.1; letter-spacing: 0; text-indent: -0.06em; padding-right: 0.2em; color: var(--dark); transition: letter-spacing 0.4s var(--ease-out); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }',
    '.row-card:hover .row-card-name { letter-spacing: 0em; }',
    '.row-card-cta-row { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }',
    '.row-card-fav { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; background: white; border: 1px solid var(--cream-deep); cursor: pointer; font-size: 13px; line-height: 1; transition: border-color 0.25s, color 0.25s, transform 0.25s; }',
    '.row-card-fav:hover { border-color: var(--terracotta); color: var(--terracotta); transform: scale(1.05); }',
    '.row-card-cta { display: inline-flex; align-items: center; gap: 10px; background: none; border: none; cursor: pointer; padding: 0 0 3px; font-family: var(--sans); font-size: 11px; font-weight: 500; letter-spacing: 0.22em; text-transform: uppercase; color: var(--dark); border-bottom: 1px solid var(--dark); white-space: nowrap; }',
    '.row-card-cta-arrow { display: inline-block; transition: transform 0.3s var(--ease-out); }',
    '.row-card:hover .row-card-cta-arrow { transform: translateX(6px); }',
    '.tile-grid { display: grid; }',
    '.tile-grid.view-row { grid-template-columns: 1fr; gap: 18px; }',
    '@media (max-width: 860px) { .row-card-thumbs { grid-template-columns: repeat(2, 1fr) !important; } .row-card-thumb-wrap:nth-child(3) { display: none; } }',
    '@media (max-width: 560px) { .row-card-thumbs { grid-template-columns: 1fr !important; } .row-card-thumb-wrap:nth-child(2), .row-card-thumb-wrap:nth-child(3) { display: none; } }',

  ].join('\n');
  document.head.appendChild(style);
})();

// ─── Axis configuration ──────────────────────────────────────────────
// Single source of truth for what the customer can pick. Adding a new
// option (e.g. another colour) is just one entry here + a tagger rule.
const AXES = {
  room: {
    key: 'room',
    label: 'By Room',
    eyebrow: 'Where will it live?',
    intro: 'Bathroom, kitchen, hallway — every space has its own demands. Start with where, narrow down everything else.',
    options: [
      { id: 'bathroom',     label: 'Bathroom' },
      { id: 'kitchen',      label: 'Kitchen' },
      { id: 'living',       label: 'Living Room' },
      { id: 'bedroom',      label: 'Bedroom' },
      { id: 'hallway',      label: 'Hallway' },
      { id: 'outdoor',      label: 'Outdoor' },
      { id: 'fireplace',    label: 'Fireplace' },
      { id: 'feature-wall', label: 'Feature Wall' },
    ],
  },
  colour: {
    key: 'colour',
    label: 'By Colour',
    eyebrow: 'Pick a palette',
    intro: 'Match the room\'s tone — or strike against it. Every shade we stock, on one page.',
    options: [
      { id: 'white',      label: 'White',           swatch: '#F4F1EC' },
      { id: 'beige',      label: 'Beige & Cream',   swatch: '#D8C8B0' },
      { id: 'grey',       label: 'Grey',            swatch: '#9A9893' },
      { id: 'black',      label: 'Black',           swatch: '#1F1E1C' },
      { id: 'brown',      label: 'Brown',           swatch: '#7A5A3A' },
      { id: 'green',      label: 'Green',           swatch: '#5A7A52' },
      { id: 'blue',       label: 'Blue',            swatch: '#3A5878' },
      { id: 'pink',       label: 'Pink',            swatch: '#C49992' },
      { id: 'terracotta', label: 'Terracotta',      swatch: '#B0552A' },
      { id: 'metallic',   label: 'Metallic & Gold', swatch: 'linear-gradient(135deg,#C9A864 0%,#7A5A2C 100%)' },
    ],
  },
  style: {
    key: 'style',
    label: 'By Style',
    eyebrow: 'A look in mind?',
    intro: 'Marble, wood, stone — the natural material your tile aspires to be.',
    options: [
      { id: 'marble',    label: 'Marble Effect' },
      { id: 'onyx',      label: 'Onyx Effect' },
      { id: 'stone',     label: 'Stone Effect' },
      { id: 'wood',      label: 'Wood Effect' },
      { id: 'concrete',  label: 'Concrete' },
      { id: 'terrazzo',  label: 'Terrazzo' },
      { id: 'patterned', label: 'Patterned' },
      { id: 'handmade',  label: 'Zellige & Handmade' },
      { id: 'metallic',  label: 'Metallic' },
    ],
  },
  shape: {
    key: 'shape',
    label: 'By Shape & Size',
    eyebrow: 'Format & dimension',
    intro: 'From mosaic accents to large-format floors — pick the geometry that fits.',
    options: [
      { id: 'mosaic',       label: 'Mosaic',        sub: '20 × 20 cm' },
      { id: 'subway',       label: 'Subway',        sub: '30 × 60 cm' },
      { id: 'square-60',    label: 'Square 60',     sub: '60 × 60 cm' },
      { id: 'square-large', label: 'Square Large',  sub: '90 × 90 cm',   coming: true },
      { id: 'plank',        label: 'Plank',         sub: '60 × 120 cm+', coming: true },
    ],
  },
};

// Order of the hub cards. Each pulls a representative thumbnail from
// the catalogue at render time so we never need a separate asset.
//
// `direct: true` cards skip the axis-options sub-page and navigate
// straight to a filtered tile listing — used for the Luxury card
// (single curated grouping, no sub-options to choose between).
// When `direct`, `axis` is also the filter axis used by filterTiles.
const HUB_CARDS = [
  { axis: 'room',   pickThumb: t => (t.rooms   || t.room   || []).includes('bathroom') },
  { axis: 'colour', pickThumb: t => (t.colours || [t.colour]).filter(Boolean).some(c => c === 'beige' || c === 'white') },
  { axis: 'style',  pickThumb: t => (t.styles  || [t.style ]).filter(Boolean).includes('marble') },
  { axis: 'shape',  pickThumb: t => (t.shapes  || [t.shape ]).filter(Boolean).includes('subway') },
  {
    axis: 'luxury',
    direct: true,
    // Pick a representative luxury thumbnail. If no tile is yet
    // tagged as luxury (early days), fall back to a marble shot so
    // the card never renders empty.
    pickThumb: t => t.luxury === true,
    cfg: {
      key: 'luxury',
      label: 'Luxury',
      eyebrow: 'The curated edit',
      intro: "A small, deliberate selection — premium imported pieces and statement formats kept aside for the projects that ask for something extraordinary.",
    },
  },
];

// ─── Data hooks ──────────────────────────────────────────────────────
// Reactive viewport-width detector. Returns true when window is at or
// below `breakpoint` px wide. Used to swap to mobile-first layouts
// (force grid view on the listing, surface a sticky CTA on the
// product page, hide the desktop filter sidebar, etc.).
function useIsMobile(breakpoint = 760) {
  const [mobile, setMobile] = useState(() =>
    typeof window !== 'undefined' && window.innerWidth <= breakpoint
  );
  useEffect(() => {
    const on = () => setMobile(window.innerWidth <= breakpoint);
    window.addEventListener('resize', on, { passive: true });
    return () => window.removeEventListener('resize', on);
  }, [breakpoint]);
  return mobile;
}

function useAllTiles() {
  const [tiles, setTiles] = useState([]);
  useEffect(() => {
    let cancelled = false;
    fetch('/api/tiles')
      .then(r => r.json())
      .then(data => {
        if (cancelled) return;
        const list = Array.isArray(data) ? data : (data && data.tiles) || [];
        setTiles(list);
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);
  return tiles;
}

// Collection metadata — admin-managed variant order + hidden list per
// collection. Fetched once on mount; storefront uses it to drive the
// "The colours" section on each product page.
function useCollectionsMeta() {
  const [meta, setMeta] = useState({});
  useEffect(() => {
    let cancelled = false;
    fetch('/api/collections')
      .then(r => r.json())
      .then(data => {
        if (cancelled) return;
        setMeta((data && data.collections) || {});
      })
      .catch(() => {});
    return () => { cancelled = true; };
  }, []);
  return meta;
}

// Apply a filter spec to a tile list. Spec shape is:
//   { axis: 'room' | 'colour' | 'style' | 'shape', value: string }
// Plus a legacy escape hatch:
//   { axis: 'cat', value: 'Bath' | 'Wall' | … }
// for the nav menu and home cards which still pass cat-based objects.
// Read a multi-value field with fallback to its single-value sibling.
// Used by the storefront's filter logic so new-schema multi-value tiles
// AND any legacy single-value records both filter correctly.
function tileHas(tile, field, value) {
  // Plural form (new schema): rooms, colours, styles, shapes, finishes, sizes
  const plural = field === 'room' ? tile.rooms
              : field === 'cat'   ? tile.cats   // (cat removed but kept for safety)
              : tile[field + 's'];
  if (Array.isArray(plural) && plural.length) return plural.includes(value);
  // Legacy single-value fallbacks
  const legacy = field === 'room' ? tile.room : tile[field];
  if (Array.isArray(legacy)) return legacy.includes(value);
  return legacy === value;
}

function filterTiles(list, spec) {
  if (!spec || !spec.axis || !spec.value) return list;
  switch (spec.axis) {
    case 'cat': {
      // Legacy escape hatch — old nav cards still pass cat-based specs.
      // Map "Bath" → bathroom room, "Wall" → feature-wall, etc., for
      // graceful fallback. Anything else just falls through with no filter.
      const v = String(spec.value).toLowerCase();
      const catToRoom = { bath: 'bathroom', floor: 'floor', wall: 'feature-wall', outdoor: 'outdoor' };
      const r = catToRoom[v];
      if (r) return list.filter(t => tileHas(t, 'room', r));
      return list;
    }
    case 'room':   return list.filter(t => tileHas(t, 'room',   spec.value));
    case 'colour': return list.filter(t => tileHas(t, 'colour', spec.value));
    case 'style':  return list.filter(t => tileHas(t, 'style',  spec.value));
    case 'shape':  return list.filter(t => tileHas(t, 'shape',  spec.value));
    // Luxury is a single boolean flag on the tile (set in the admin
    // Identity tab). Spec.value is unused — we only check truthiness.
    case 'luxury': return list.filter(t => t.luxury === true);
    default:       return list;
  }
}

// Normalise the various legacy shapes into the new filter spec.
//
// `axis: 'all'` is the no-filter sentinel — used when the customer lands
// on `/collections` directly (clicking the nav pill). The page renders
// every tile with an empty sidebar so they can refine from a clean slate.
function resolveFilter(prop) {
  if (!prop) return { axis: 'all', value: '', label: 'All Tiles' };
  // New shape
  if (prop.axis && prop.value) return prop;
  // Legacy cat-based shape: { id: 'bath', label: 'Bath Tiles', … }
  if (prop.id) {
    const v = prop.id;
    const map = { bath: 'Bath', wall: 'Wall', floor: 'Floor', outdoor: 'Outdoor', decor: 'Decor' };
    return { axis: 'cat', value: map[v] || v, label: prop.label || `${v[0].toUpperCase()}${v.slice(1)} Tiles` };
  }
  return { axis: 'all', value: '', label: 'All Tiles' };
}

// ─── 1.  HUB ─────────────────────────────────────────────────────────
function CollectionsHub({ navigate }) {
  const tiles = useAllTiles();
  useReveal();

  // Pick a thumbnail per hub card, deterministically from the catalogue.
  // For `direct` cards (Luxury), cfg is inlined on the hub config since
  // they don't live in AXES (no sub-options).
  const cards = HUB_CARDS.map(c => {
    const matched = tiles.find(c.pickThumb) || tiles[0];
    return {
      axis:   c.axis,
      direct: !!c.direct,
      cfg:    c.cfg || AXES[c.axis],
      thumb:  matched?.img || '',
    };
  });

  return (
    <div style={{ paddingTop: 'var(--nav-h)' }}>
      {/* Editorial intro band */}
      <div style={{ padding: '90px 40px 56px', textAlign: 'center', maxWidth: '900px', margin: '0 auto' }}>
        <p className="t-label reveal" style={{ color: 'var(--terracotta)', marginBottom: '18px' }}>The collection</p>
        <h1 className="t-display reveal" style={{ fontSize: 'clamp(48px, 7vw, 100px)', lineHeight: 1.04 }}>
          Five ways <em>to start</em>
        </h1>
        <p className="t-body reveal reveal-delay-1" style={{ maxWidth: '540px', margin: '22px auto 0' }}>
          Browse by where the tile will live, the palette you're working with, the look you have in mind, the format that fits your space — or step straight into the curated Luxury edit.
        </p>
      </div>

      {/* Hub grid — 4 axis cards on top row, Luxury as a wide
          featured card spanning all 4 columns on the second row.
          The grid stays repeat(4, 1fr); only the Luxury card
          (matched by .hub-card--feature) overrides its column span. */}
      <div style={{ padding: '0 24px 100px' }}>
        <div className="hub-grid" style={{
          display: 'grid', gap: '16px',
          gridTemplateColumns: 'repeat(4, 1fr)',
          maxWidth: '1640px', margin: '0 auto',
        }}>
          {cards.map((c, i) => {
            const isFeature = c.direct;
            const onPick = () => {
              if (c.direct) {
                // Skip the axis-options sub-page — go straight to the
                // filtered listing. App.jsx's 'collections' route
                // forwards `filter` to CollectionDetail, which calls
                // filterTiles with axis: 'luxury'.
                navigate('collections', {
                  axis: c.axis, value: 'true', label: c.cfg.label,
                });
              } else {
                navigate('collections-axis', c.axis);
              }
            };
            return (
              <button
                key={c.axis}
                onClick={onPick}
                className={'hub-card reveal' + (isFeature ? ' hub-card--feature' : '')}
                style={{
                  position: 'relative', overflow: 'hidden',
                  // Featured card is wider and shorter, like a banner.
                  aspectRatio: isFeature ? '16 / 5' : '4 / 3',
                  background: '#1A1A1A',
                  border: 'none', cursor: 'pointer',
                  color: 'white', textAlign: 'left',
                  transition: 'transform 0.6s var(--ease-out)',
                }}
              >
                {/* Thumbnail */}
                <img src={c.thumb} alt="" loading="lazy"
                  style={{
                    position: 'absolute', inset: 0,
                    width: '100%', height: '100%', objectFit: 'cover',
                    transition: 'transform 1s var(--ease-out)',
                    filter: isFeature ? 'brightness(0.6)' : 'brightness(0.78)',
                  }}
                  className="hub-img"
                  onError={e => { e.currentTarget.style.display = 'none'; }}
                />
                {/* Gradient for legibility */}
                <div style={{
                  position: 'absolute', inset: 0, pointerEvents: 'none',
                  background: 'linear-gradient(180deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.2) 50%, rgba(0,0,0,0.78) 100%)',
                }}/>
                {/* Label block */}
                <div style={{
                  position: 'absolute',
                  left:   isFeature ? '48px' : '40px',
                  right:  isFeature ? '48px' : '40px',
                  bottom: isFeature ? '36px' : '40px',
                }}>
                  <p style={{
                    fontFamily: 'var(--sans)', fontSize: '11px',
                    letterSpacing: '0.22em', textTransform: 'uppercase',
                    color: 'rgba(255,255,255,0.75)', marginBottom: '14px',
                  }}>0{i + 1} · {c.cfg.eyebrow}</p>
                  <h2 style={{
                    fontFamily: 'var(--serif)', fontWeight: 300,
                    fontSize: isFeature
                      ? 'clamp(44px, 5.6vw, 84px)'
                      : 'clamp(40px, 5vw, 72px)',
                    lineHeight: 1.0, letterSpacing: '-0.02em',
                    marginBottom: '14px', color: 'white',
                  }}>{c.cfg.label.replace('By ', 'By ')}</h2>
                  <p style={{
                    fontFamily: 'var(--sans)', fontSize: '14px',
                    lineHeight: 1.55, fontWeight: 300,
                    color: 'rgba(255,255,255,0.82)',
                    maxWidth: isFeature ? '620px' : '440px',
                  }}>{c.cfg.intro}</p>
                  <p style={{
                    marginTop: '22px',
                    fontFamily: 'var(--sans)', fontSize: '12px',
                    letterSpacing: '0.18em', textTransform: 'uppercase',
                    color: 'white',
                  }}>
                    {c.direct
                      ? 'View the edit  →'
                      : `Browse ${c.cfg.options.length} options  →`}
                  </p>
                </div>
              </button>
            );
          })}
        </div>
      </div>

      <style>{`
        .hub-card:hover .hub-img { transform: scale(1.06); }
        /* Luxury / feature card stretches the full grid width on its
           own row, regardless of how many columns the breakpoint
           uses. Lives below the axis cards. */
        .hub-card--feature { grid-column: 1 / -1; }
        @media (max-width: 1280px) { .hub-grid { grid-template-columns: repeat(2, 1fr) !important; } }
        @media (max-width: 720px)  {
          .hub-grid { grid-template-columns: 1fr !important; }
          /* On phones the feature card reverts to a normal 4:3 — full
             span is already implied by 1 column. */
          .hub-card--feature { aspect-ratio: 4 / 3 !important; }
        }
      `}</style>
    </div>
  );
}

// ─── 2.  AXIS PAGE ───────────────────────────────────────────────────
function CollectionsAxis({ axis, navigate }) {
  const tiles = useAllTiles();
  const cfg = AXES[axis] || AXES.room;
  useReveal();

  // For each option: count tiles + pick a representative image.
  const enrichedOptions = useMemo(() => cfg.options.map(opt => {
    const matched = filterTiles(tiles, { axis: cfg.key, value: opt.id });
    return { ...opt, count: matched.length, thumb: matched[0]?.img || '' };
  }), [tiles, cfg]);

  return (
    <div style={{ paddingTop: 'var(--nav-h)' }}>
      {/* Header */}
      <div style={{ padding: '78px 40px 56px', textAlign: 'center', maxWidth: '880px', margin: '0 auto' }}>
        <button onClick={() => navigate('collections')} style={{
          background: 'none', border: 'none', cursor: 'pointer',
          fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)',
          letterSpacing: '0.14em', textTransform: 'uppercase', marginBottom: '24px',
        }}>← All four ways</button>
        <p className="t-label reveal" style={{ color: 'var(--terracotta)', marginBottom: '14px' }}>{cfg.eyebrow}</p>
        <h1 className="t-display reveal" style={{ fontSize: 'clamp(40px, 6vw, 84px)' }}>
          {cfg.label.replace('By ', 'Shop by ')}
        </h1>
        <p className="t-body reveal reveal-delay-1" style={{ maxWidth: '540px', margin: '22px auto 0' }}>
          {cfg.intro}
        </p>
      </div>

      {/* Options grid */}
      <div style={{ padding: '0 24px 100px' }}>
        <div className="axis-grid" style={{
          display: 'grid', gap: '20px',
          gridTemplateColumns: 'repeat(4, 1fr)',
          maxWidth: '1640px', margin: '0 auto',
        }}>
          {enrichedOptions.map(opt => {
            const empty = opt.count === 0;
            const isColour = cfg.key === 'colour';
            return (
              <button
                key={opt.id}
                disabled={empty || opt.coming}
                onClick={() => navigate('collection', { axis: cfg.key, value: opt.id, label: opt.label })}
                className={'option-card reveal' + (empty || opt.coming ? ' is-disabled' : '')}
                style={{
                  position: 'relative', overflow: 'hidden',
                  background: 'var(--cream-mid)',
                  border: '1px solid var(--cream-deep)',
                  padding: 0, textAlign: 'left',
                  cursor: empty || opt.coming ? 'default' : 'pointer',
                  display: 'flex', flexDirection: 'column',
                  opacity: empty || opt.coming ? 0.5 : 1,
                  transition: 'transform 0.4s var(--ease-out), box-shadow 0.4s',
                }}
              >
                {/* Visual */}
                <div
                  className={isColour ? undefined : 'tile-frame'}
                  style={{
                    position: 'relative', aspectRatio: '1',
                    background: isColour ? (opt.swatch || '#eee') : undefined,
                    overflow: 'hidden',
                  }}>

                  {/* Tile thumb (skipped for colour-only swatches if no tile) */}
                  {opt.thumb && (
                    <img src={opt.thumb} alt={opt.label}
                      loading="lazy"
                      className="option-img"
                      style={{
                        width: '100%', height: '100%', objectFit: 'cover',
                        display: 'block',
                        transition: 'transform 0.8s var(--ease-out)',
                        // For colour, blend the tile thumb at reduced opacity
                        // over the swatch — gives both the abstract colour
                        // cue AND a realistic preview of one tile in that family.
                        opacity: isColour ? 0.85 : 1,
                      }}
                      onError={e => { e.currentTarget.style.display = 'none'; }}
                    />
                  )}
                  {opt.coming && (
                    <span style={{
                      position: 'absolute', top: 12, right: 12,
                      fontFamily: 'var(--sans)', fontSize: '9px',
                      letterSpacing: '0.18em', textTransform: 'uppercase',
                      background: 'rgba(255,255,255,0.92)', color: 'var(--dark)',
                      padding: '5px 10px',
                    }}>Coming soon</span>
                  )}
                </div>
                {/* Label */}
                <div style={{ padding: '18px 18px 22px' }}>
                  <p style={{
                    fontFamily: 'var(--serif)', fontSize: '22px', fontWeight: 400,
                    color: 'var(--dark)', letterSpacing: '-0.01em', marginBottom: '4px',
                  }}>{opt.label}</p>
                  <p style={{
                    fontFamily: 'var(--sans)', fontSize: '11px',
                    letterSpacing: '0.16em', textTransform: 'uppercase',
                    color: 'var(--dark-mid)',
                  }}>
                    {opt.sub
                      ? opt.sub
                      : empty
                        ? 'No tiles yet'
                        : `${opt.count} tile${opt.count === 1 ? '' : 's'}`}
                  </p>
                </div>
              </button>
            );
          })}
        </div>
      </div>

      <style>{`
        .option-card:not(.is-disabled):hover { transform: translateY(-3px); box-shadow: 0 12px 32px rgba(0,0,0,0.08); }
        .option-card:not(.is-disabled):hover .option-img { transform: scale(1.06); }
        @media (max-width: 1100px) { .axis-grid { grid-template-columns: repeat(3, 1fr) !important; } }
        @media (max-width: 760px)  { .axis-grid { grid-template-columns: repeat(2, 1fr) !important; } }
        @media (max-width: 460px)  { .axis-grid { grid-template-columns: 1fr !important; } }
      `}</style>
    </div>
  );
}

// ─── Legacy alias ────────────────────────────────────────────────────
// `CollectionsList` is what App.jsx and others used to render. Now it is
// just the new Hub. Anything still calling it gets the new behaviour.
const CollectionsList = CollectionsHub;

// ─── 3.  FILTERED DETAIL ─────────────────────────────────────────────
// Story copy keyed by (axis, value). Only a few are explicitly written;
// everything else falls back to a generic line that still reads well.
const STORIES = {
  'cat:bath':    'Bathrooms demand tiles that handle moisture, steam and daily use while never compromising on beauty. Our bath range spans sleek subway glazes, precision large formats and handmade mosaics — all tested to slip resistance and frost standards.',
  'cat:wall':    'Wall tiles set the tone for a room before anything else is in it. We\'ve chosen glazes with depth, texture and movement — tiles that reward close attention and look better with age.',
  'cat:floor':   'A great floor tile disappears into the background while defining the whole room. Our floor collection prioritises hardwearing finishes, generous formats and natural stone looks that don\'t date.',
  'cat:outdoor': 'Outdoor tiles carry the full weight of the London climate. Every tile in this range is tested to frost resistance, anti-slip ratings and UV stability — and selected because they look as good in winter as in summer.',
  'cat:decor':   'Feature tiles are the exclamation mark of an interior. Zellige, encaustic and artisan glazes for splashbacks, niches and statement walls. Handmade by small ateliers and finished in our London studio.',
  'room:bathroom': 'Specified for steam, splashes and bare feet. Slip-rated porcelains, low-porosity glazes, and large formats that minimise grout lines.',
  'room:kitchen':  'Hard-working surfaces for the busiest room in the house. Heat- and stain-resistant, easy to wipe, and chosen to flatter both daylight and warm evening light.',
  'room:living':   'The room your guests see first — and where you spend the most time. We pick floors that hold their character for decades, not just seasons.',
  'room:outdoor':  'Frost-rated, UV-stable and anti-slip. The tiles in this range are tested to the London climate and selected for terraces, balconies and patio renovations.',
  'colour:white':  'White tiles read as clean, calm and architectural. Calacatta marbles, Bianco onyxes and pure ceramic glazes — every shade of white we stock.',
  'colour:beige':  'Warm neutrals that flatter every wood, every stone, every fabric. Cream, almond, peach, pearl — the tonal range that grounds an interior without dating it.',
  'colour:green':  'From sage to olive to deep emerald — green tiles bring a softness no neutral can. Excellent in bathrooms, kitchen splashbacks, garden rooms.',
  'colour:metallic':'Gold veins, bronze chromats, onyx with metallic tracery. Used sparingly, these tiles do all the work in a room.',
  'style:marble':  'Calacatta, Arabescato, Lava — the great marbles reproduced in porcelain so they survive bathrooms, kitchens and the London winter.',
  'style:onyx':    'Translucent, layered, dramatic. Onyx-effect porcelains for feature walls, fireplace surrounds and bathroom interiors that need to feel set apart.',
  'style:stone':   'Bahia, Stone, Apulia — quarried looks rendered in modern porcelain. The tactile depth of natural stone, with none of the maintenance.',
  'shape:mosaic':  'Twenty-by-twenty handglazed pieces. Used as splashback accents, niches inside larger walls, or to dress a kitchen island.',
  'shape:subway':  'Thirty-by-sixty rectified rectangles. The classic format — and our best-selling shape on bathroom walls.',
  'shape:square-60':'Sixty-by-sixty square format. Generous enough to keep grout lines minimal, restrained enough to suit any room.',
};

const FALLBACK_STORY = 'Curated tiles in this range, hand-selected by our London studio. Every tile in our catalogue is held to the same brief: aesthetic depth, technical credibility, and longevity.';

// Hero imagery per axis-value. Falls back to the first matching tile if no
// hand-picked asset exists.
const HERO_BY_KEY = {
  'cat:bath':    '/assets/generated/category-hero-bath.jpg',
  'cat:wall':    '/assets/generated/category-hero-wall.jpg',
  'cat:floor':   '/assets/generated/category-hero-floor.jpg',
  'cat:outdoor': '/assets/generated/category-hero-outdoor.jpg',
  'cat:decor':   '/assets/generated/category-hero-decor.jpg',
  'room:bathroom':    '/assets/generated/category-hero-bath.jpg',
  'room:kitchen':     '/assets/generated/lifestyle-floor-wide.jpg',
  'room:living':      '/assets/generated/lifestyle-floor-wide.jpg',
  'room:outdoor':     '/assets/categories/outdoor.jpg',
  'room:feature-wall':'/assets/categories/statement.jpg',
  'style:marble':  '/assets/categories/luxury-meteorite.jpg',
  'style:stone':   '/assets/categories/heritage.jpg',
  'style:patterned':'/assets/categories/style-frozen-up.jpg',
};

// Durstone-style listing layout.
//
//   ┌────────────────────────────────────────────────────────────┐
//   │ Collections / Bathroom                Sort ▾   13 products │
//   │ [Bathroom ×] [Polished ×]                                  │
//   ├──────────────┬─────────────────────────────────────────────┤
//   │ FILTERS      │  ┌────┐ ┌────┐ ┌────┐                       │
//   │  Reset       │  │img │ │img │ │img │                       │
//   │              │  └────┘ └────┘ └────┘                       │
//   │ ▾ Room       │  Apulia    Jatoba    Grotta                 │
//   │   ☑ Bathroom │  Gold      Brown     Oro                    │
//   │   ☐ Kitchen  │  30×60·…   30×60·…   30×60·…                │
//   │ ▸ Colour     │                                             │
//   │ ▸ Style      │  ...                                        │
//   │ ▸ Finish     │                                             │
//   │ ▸ Format     │                                             │
//   └──────────────┴─────────────────────────────────────────────┘
//
// Sidebar is sticky under the nav. Each filter section is multi-select.
// The primary axis arrives pre-checked + that section auto-expanded;
// other sections start collapsed.

// Filter sections rendered in the sidebar, in order. Mirrors the four
// browse axes shown in the nav mega-menu (Room / Colour / Style / Shape
// & Size) plus Finish — so what the customer sees in nav, axis page, and
// sidebar all use the same vocabulary.
const SIDEBAR_SECTIONS = [
  { key: 'room',   title: 'Room',          getOptions: () => AXES.room.options.map(o => ({ id: o.id, label: o.label })) },
  { key: 'colour', title: 'Colour',        getOptions: () => AXES.colour.options.map(o => ({ id: o.id, label: o.label, swatch: o.swatch })) },
  { key: 'style',  title: 'Style',         getOptions: () => AXES.style.options.map(o => ({ id: o.id, label: o.label })) },
  { key: 'shape',  title: 'Shape & Size',  getOptions: () => AXES.shape.options.filter(o => !o.coming).map(o => ({ id: o.id, label: o.sub ? `${o.label} · ${o.sub}` : o.label })) },
  { key: 'finish', title: 'Finish',        getOptions: (tiles) => uniqueValues(tiles, 'finishes').map(v => ({ id: v, label: v })) },
];

// Collect unique values from a multi-value array field across the
// tile catalogue (e.g. every distinct finish that appears on any tile).
function uniqueValues(tiles, field) {
  const set = new Set();
  for (const t of tiles) {
    const arr = t[field];
    if (Array.isArray(arr)) for (const v of arr) { if (v) set.add(v); }
    else if (t[field]) set.add(t[field]);
  }
  return Array.from(set).sort();
}

// Apply ALL active sidebar filters (multi-select per axis, intersected).
// Each axis is multi-value on the tile side; we check ANY overlap
// between the tile's array and the selected set.
function applySidebar(tiles, sel) {
  const overlaps = (arr, set) => arr.some(v => set.has(v));
  const tileArr = (tile, field) => {
    const plural = field === 'room' ? tile.rooms : tile[field + 's'];
    if (Array.isArray(plural) && plural.length) return plural;
    const legacy = field === 'room' ? tile.room : tile[field];
    return Array.isArray(legacy) ? legacy : (legacy ? [legacy] : []);
  };
  return tiles.filter(t => {
    if (sel.room.size   && !overlaps(tileArr(t, 'room'),   sel.room))   return false;
    if (sel.colour.size && !overlaps(tileArr(t, 'colour'), sel.colour)) return false;
    if (sel.style.size  && !overlaps(tileArr(t, 'style'),  sel.style))  return false;
    if (sel.shape.size  && !overlaps(tileArr(t, 'shape'),  sel.shape))  return false;
    if (sel.finish.size && !overlaps(tileArr(t, 'finish'), sel.finish)) return false;
    return true;
  });
}

function CollectionDetail({ filter, collection, navigate }) {
  // Accept new `filter` prop OR legacy `collection` prop (cat-based).
  const spec = useMemo(() => resolveFilter(filter || collection), [filter, collection]);
  const tiles = useAllTiles();
  // Mobile detection — drives layout overrides (grid view forced,
  // sidebar hidden behind a Filter drawer, view switcher hidden).
  const isMobile = useIsMobile(760);
  const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
  useEffect(() => {
    if (filterDrawerOpen) {
      document.body.style.overflow = 'hidden';
      return () => { document.body.style.overflow = ''; };
    }
  }, [filterDrawerOpen]);

  // Multi-select state per filter axis. Sets are easier to mutate than
  // arrays for membership flips.
  const [selected, setSelected] = useState(() => emptySelection());

  // Sort + view
  const [sort, setSort] = useState('featured');
  // View mode: 'row' (default — full-width row per tile, supports
  // multiple images), 'grid' (3-up squares), 'compact' (5-up small).
  // Persists per-user via localStorage.
  const [viewMode, setViewMode] = useState(() => {
    try { return localStorage.getItem('venoraa-view') || 'row'; } catch { return 'row'; }
  });
  useEffect(() => {
    try { localStorage.setItem('venoraa-view', viewMode); } catch {}
  }, [viewMode]);

  // When the spec changes (customer arrives with a new pre-filter from
  // the axis page), reset the selection to ONLY that pre-filter. Cat-
  // legacy specs don't pre-check anything (no Cat section in sidebar).
  useEffect(() => {
    const next = emptySelection();
    if (['room', 'colour', 'style', 'shape'].includes(spec.axis)) {
      next[spec.axis] = new Set([spec.value]);
    }
    setSelected(next);
  }, [spec.axis, spec.value]);

  // Pipeline: start with all tiles → apply the page-level axis lock
  // (cat / luxury) → apply sidebar filters → sort.
  //
  // The axes `room` / `colour` / `style` / `shape` are NOT applied
  // here because the same axis has a sidebar pill row — the useEffect
  // above pre-selects the relevant pill from `spec.value`, and the
  // sidebar then drives the actual filtering through `applySidebar`.
  //
  // The axes `cat` and `luxury` have NO sidebar option (cat is
  // legacy; luxury is a boolean flag on the tile, not a pillable
  // taxonomy). So we must apply them at the base-tile stage,
  // otherwise the page would show the entire catalogue.
  const baseTiles = useMemo(() => {
    if (spec.axis === 'cat' || spec.axis === 'luxury') return filterTiles(tiles, spec);
    return tiles;
  }, [tiles, spec]);

  const filtered = useMemo(() => applySidebar(baseTiles, selected), [baseTiles, selected]);
  const products = useMemo(() => {
    const list = filtered.slice();
    if (sort === 'name-asc')  list.sort((a, b) => a.name.localeCompare(b.name));
    if (sort === 'name-desc') list.sort((a, b) => b.name.localeCompare(a.name));
    return list;
  }, [filtered, sort]);

  // ─── Stage 4: progressive load ─────────────────────────────────
  // Render PAGE_SIZE tiles at first; user clicks "Load more" to
  // reveal the next batch. Reset whenever the filter pipeline
  // changes so the user always starts at the top of a fresh result.
  const PAGE_SIZE = 24;
  const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
  useEffect(() => { setVisibleCount(PAGE_SIZE); }, [spec.axis, spec.value, selected, sort, viewMode]);
  const visibleProducts = products.slice(0, visibleCount);
  const hasMore = visibleCount < products.length;

  // Helpers exposed to the sidebar + chip row
  const toggle = (axis, id) => {
    setSelected(prev => {
      const next = cloneSelection(prev);
      if (next[axis].has(id)) next[axis].delete(id); else next[axis].add(id);
      return next;
    });
  };
  const resetAll = () => setSelected(emptySelection());

  // Build the active-chip list across all axes (so user can see + remove
  // any single filter from one place).
  const activeChips = [];
  for (const sec of SIDEBAR_SECTIONS) {
    for (const id of selected[sec.key]) {
      const opts = sec.getOptions(tiles);
      const opt  = opts.find(o => o.id === id);
      activeChips.push({ axis: sec.key, id, label: opt?.label || id });
    }
  }

  // Section open/closed state — primary axis open by default.
  const [openSections, setOpenSections] = useState(() => {
    const o = {};
    SIDEBAR_SECTIONS.forEach(s => { o[s.key] = (s.key === spec.axis); });
    return o;
  });
  useEffect(() => {
    setOpenSections(prev => ({ ...prev, [spec.axis]: true }));
  }, [spec.axis]);

  // Page title + breadcrumb tail
  const crumbTail = spec.label || 'All tiles';

  return (
    <div style={{ paddingTop: 'var(--nav-h)', background: '#FFFFFF', minHeight: '100vh' }}>
      {/* Header bar — breadcrumb + sort + count */}
      <div style={{
        display: 'flex', alignItems: 'center', justifyContent: 'space-between',
        gap: '20px', flexWrap: 'wrap',
        padding: 'clamp(14px, 4vw, 20px) clamp(16px, 4vw, 40px) 0',
        maxWidth: '1640px', margin: '0 auto',
      }}>
        {/* Breadcrumb */}
        <nav style={{ fontFamily: 'var(--sans)', fontSize: '12px', letterSpacing: '0.08em', color: 'var(--dark-mid)' }}>
          <button onClick={() => navigate('home')} style={crumbBtn}>Home</button>
          <span style={crumbSep}>/</span>
          {spec.axis === 'all'
            ? <span style={{ color: 'var(--dark)' }}>Collections</span>
            : <button onClick={() => navigate('collections')} style={crumbBtn}>Collections</button>}
          {/* Axis link only when we know which axis we came from. 'all'
              and legacy 'cat' specs skip this. */}
          {AXES[spec.axis] && (
            <>
              <span style={crumbSep}>/</span>
              <span style={{ color: 'var(--dark)' }}>{(AXES[spec.axis].label || '').replace('By ', '')}: {crumbTail}</span>
            </>
          )}
          {spec.axis === 'cat' && (
            <>
              <span style={crumbSep}>/</span>
              <span style={{ color: 'var(--dark)' }}>{crumbTail}</span>
            </>
          )}
        </nav>
        {/* Sort + count + view switcher. On mobile the view-switcher
            hides (forced grid mode) and a "Filter" button takes its
            place to open the bottom-sheet drawer instead of the
            sidebar (which is hidden at this width). */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 'clamp(10px, 2vw, 18px)', flexWrap: 'nowrap', whiteSpace: 'nowrap' }}>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)' }}>
            {products.length} {products.length === 1 ? 'product' : 'products'}
          </p>
          <SortDropdown value={sort} onChange={setSort}/>
          {!isMobile && <ViewSwitcher value={viewMode} onChange={setViewMode}/>}
          {isMobile && (
            <button
              type="button"
              onClick={() => setFilterDrawerOpen(true)}
              style={{
                padding: '9px 14px',
                background: activeChips.length > 0 ? 'var(--dark)' : 'white',
                color: activeChips.length > 0 ? 'white' : 'var(--dark)',
                border: `1px solid ${activeChips.length > 0 ? 'var(--dark)' : 'var(--cream-deep)'}`,
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.14em', textTransform: 'uppercase',
                cursor: 'pointer', minHeight: '38px',
              }}
            >Filter{activeChips.length > 0 ? ` · ${activeChips.length}` : ''}</button>
          )}
        </div>
      </div>

      {/* On mobile, render the page title ABOVE the grid (the sidebar
          which hosts the desktop title is hidden). */}
      {isMobile && (
        <h1 style={{
          fontFamily: 'var(--serif)', fontWeight: 300,
          fontSize: 'clamp(28px, 7vw, 38px)',
          color: 'var(--dark)', letterSpacing: '-0.015em', lineHeight: 1.1,
          margin: '0 clamp(16px, 4vw, 40px) 8px',
          maxWidth: '1640px',
        }}>{crumbTail}</h1>
      )}

      {/* Body — sidebar (with title at top) + grid. Active filter chips
          + Clear all live BELOW the grid (see after the body close), so
          they don't push the body's top edge down when filters are
          active and disturb the title↔first-row alignment. */}
      <div className="durstone-body" style={{
        display: 'grid',
        gridTemplateColumns: isMobile ? '1fr' : '280px 1fr',
        gap: 'clamp(20px, 4vw, 40px)', padding: 'clamp(14px, 4vw, 14px) clamp(16px, 4vw, 40px) clamp(60px, 8vw, 100px)',
        maxWidth: '1640px', margin: '0 auto',
      }}>
        {/* ── Sidebar (desktop only — hidden on mobile, replaced by
            the bottom-sheet drawer triggered from the "Filter" button
            in the header bar). ─────────────────────────────────── */}
        {!isMobile && (
        <aside className="durstone-sidebar" style={{
          position: 'sticky', top: 'calc(var(--nav-h) + 14px)',
          alignSelf: 'start',
          maxHeight: 'calc(100vh - var(--nav-h) - 28px)', overflowY: 'auto',
          paddingRight: '8px',
        }}>
          {/* Page title — sits at the top of the sidebar so its top
              edge aligns with the first tile row on the right. */}
          <h1 style={{
            fontFamily: 'var(--serif)', fontWeight: 300,
            fontSize: 'clamp(26px, 2.4vw, 34px)',
            color: 'var(--dark)', letterSpacing: '-0.015em', lineHeight: 1.1,
            marginBottom: '20px',
          }}>{crumbTail}</h1>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '14px' }}>
            <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', fontWeight: 600, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'var(--dark)' }}>Filters</p>
            {activeChips.length > 0 && (
              <button onClick={resetAll} style={{
                background: 'none', border: 'none', cursor: 'pointer',
                fontFamily: 'var(--sans)', fontSize: '11px',
                color: 'var(--terracotta)', letterSpacing: '0.06em',
                padding: 0,
              }}>Reset all</button>
            )}
          </div>
          {SIDEBAR_SECTIONS.map(sec => (
            <FilterSection
              key={sec.key}
              title={sec.title}
              open={!!openSections[sec.key]}
              onToggle={() => setOpenSections(s => ({ ...s, [sec.key]: !s[sec.key] }))}
              options={sec.getOptions(tiles)}
              selected={selected[sec.key]}
              onPick={(id) => toggle(sec.key, id)}
              countOf={(id) => baseTiles.filter(t => tileHas(t, sec.key, id)).length}
            />
          ))}
        </aside>
        )}

        {/* ── Grid ────────────────────────────────────────────── */}
        <main>
          {products.length === 0 ? (
            <div style={{ textAlign: 'center', padding: '80px 20px' }}>
              <p style={{ fontFamily: 'var(--serif)', fontSize: '24px', fontWeight: 300, color: 'var(--dark-mid)', marginBottom: '16px' }}>
                No tiles match every selected filter.
              </p>
              <button onClick={resetAll} style={{
                background: 'none', border: '1px solid var(--dark)', cursor: 'pointer',
                padding: '12px 22px', fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--dark)',
              }}>Clear all filters</button>
            </div>
          ) : (
            // Key on viewMode so the grid remounts when the user toggles
            // — letting each card replay its fade-in animation. The
            // class drives layout-specific grid columns + responsive.
            <div key={isMobile ? 'm-grid' : viewMode} className={`tile-grid view-${isMobile ? 'grid' : viewMode}`}>
              {visibleProducts.map((p, i) => {
                // Mobile always uses the grid card (compact, 2-col, big
                // tap target). Row view's big tile + 3 thumb layout
                // doesn't suit a 360px viewport.
                if (isMobile)               return <GridCard    key={p.id} tile={p} index={i} navigate={navigate}/>;
                if (viewMode === 'row')     return <RowCard     key={p.id} tile={p} index={i} navigate={navigate}/>;
                if (viewMode === 'compact') return <CompactCard key={p.id} tile={p} index={i} navigate={navigate}/>;
                return                              <GridCard    key={p.id} tile={p} index={i} navigate={navigate}/>;
              })}
            </div>
          )}
          {/* ── Stage 4: Load more ───────────────────────────────── */}
          {hasMore && (
            <div style={{
              display: 'flex', flexDirection: 'column', alignItems: 'center',
              gap: '10px', padding: '40px 0 10px',
            }}>
              <p style={{
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.18em', textTransform: 'uppercase',
                color: 'var(--dark-mid)',
              }}>
                Showing {visibleProducts.length} of {products.length}
              </p>
              <button
                onClick={() => setVisibleCount(c => c + PAGE_SIZE)}
                style={{
                  background: 'var(--dark)', color: 'var(--cream)',
                  border: '1px solid var(--dark)', cursor: 'pointer',
                  padding: '14px 32px',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  fontWeight: 500, letterSpacing: '0.22em',
                  textTransform: 'uppercase',
                  transition: 'all 0.2s',
                }}
                onMouseEnter={e => { e.currentTarget.style.background = 'var(--terracotta)'; e.currentTarget.style.borderColor = 'var(--terracotta)'; }}
                onMouseLeave={e => { e.currentTarget.style.background = 'var(--dark)';      e.currentTarget.style.borderColor = 'var(--dark)'; }}
              >
                Load {Math.min(PAGE_SIZE, products.length - visibleCount)} more tiles
              </button>
            </div>
          )}
        </main>
      </div>

      {/* ── Mobile filter drawer ──────────────────────────────────
          Slides up from the bottom of the screen on mobile when the
          "Filter" button in the header is tapped. Contains every
          filter section the desktop sidebar holds. The whole content
          area scrolls; a sticky footer shows "Apply" + "Reset". */}
      {isMobile && filterDrawerOpen && (
        <div
          role="dialog"
          aria-modal="true"
          style={{
            position: 'fixed', inset: 0, zIndex: 200,
            background: 'rgba(0,0,0,0.45)',
            display: 'flex', alignItems: 'flex-end',
          }}
          onClick={() => setFilterDrawerOpen(false)}
        >
          <div
            onClick={(e) => e.stopPropagation()}
            style={{
              background: 'white', width: '100%',
              maxHeight: '88vh',
              borderRadius: '16px 16px 0 0',
              display: 'flex', flexDirection: 'column',
              overflow: 'hidden',
              animation: 'venoraaSheetUp 0.28s var(--ease-out)',
            }}
          >
            {/* Sheet header */}
            <div style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '14px 20px 12px', borderBottom: '1px solid var(--cream-deep)',
            }}>
              <div style={{ width: '36px', height: '4px', background: 'var(--cream-deep)', borderRadius: '999px', position: 'absolute', top: '8px', left: '50%', transform: 'translateX(-50%)' }}/>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', fontWeight: 600, letterSpacing: '0.22em', textTransform: 'uppercase', color: 'var(--dark)', margin: 0 }}>
                Filter{activeChips.length > 0 ? ` · ${activeChips.length}` : ''}
              </p>
              <button
                type="button"
                onClick={() => setFilterDrawerOpen(false)}
                aria-label="Close filters"
                style={{
                  background: 'none', border: 'none', cursor: 'pointer',
                  fontSize: '22px', color: 'var(--dark-mid)', padding: 0,
                }}
              >✕</button>
            </div>
            {/* Scrollable filter sections */}
            <div style={{ flex: 1, overflowY: 'auto', padding: '8px 20px 16px' }}>
              {SIDEBAR_SECTIONS.map(sec => (
                <FilterSection
                  key={sec.key}
                  title={sec.title}
                  open={!!openSections[sec.key]}
                  onToggle={() => setOpenSections(s => ({ ...s, [sec.key]: !s[sec.key] }))}
                  options={sec.getOptions(tiles)}
                  selected={selected[sec.key]}
                  onPick={(id) => toggle(sec.key, id)}
                  countOf={(id) => baseTiles.filter(t => tileHas(t, sec.key, id)).length}
                />
              ))}
            </div>
            {/* Sticky footer — Reset + Apply */}
            <div style={{
              display: 'flex', gap: '10px',
              padding: '14px 20px calc(14px + env(safe-area-inset-bottom, 0px))',
              borderTop: '1px solid var(--cream-deep)',
              background: 'white',
            }}>
              <button
                type="button"
                onClick={() => { resetAll(); }}
                style={{
                  flex: '0 0 auto', padding: '14px 20px',
                  background: 'white', color: 'var(--dark)',
                  border: '1px solid var(--cream-deep)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.16em', textTransform: 'uppercase',
                  cursor: 'pointer',
                }}
              >Reset</button>
              <button
                type="button"
                onClick={() => setFilterDrawerOpen(false)}
                style={{
                  flex: 1, padding: '14px 20px',
                  background: 'var(--dark)', color: 'white',
                  border: '1px solid var(--dark)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  fontWeight: 500, letterSpacing: '0.18em', textTransform: 'uppercase',
                  cursor: 'pointer',
                }}
              >Show {products.length} tile{products.length === 1 ? '' : 's'}</button>
            </div>
          </div>
          <style>{`@keyframes venoraaSheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }`}</style>
        </div>
      )}

      {/* Active filter chips — moved BELOW the body so they don't push
          the title and first-row alignment down when filters are
          active. Renders only when at least one filter is selected. */}
      {activeChips.length > 0 && (
        <div style={{
          display: 'flex', flexWrap: 'wrap', gap: '8px',
          alignItems: 'center', justifyContent: 'center',
          padding: '8px clamp(16px, 4vw, 40px) 60px', maxWidth: '1640px', margin: '0 auto',
        }}>
          <span style={{
            fontFamily: 'var(--sans)', fontSize: '10px', fontWeight: 500,
            letterSpacing: '0.22em', textTransform: 'uppercase',
            color: 'var(--dark-mid)', marginRight: '6px',
          }}>Active filters</span>
          {activeChips.map(c => (
            <button key={c.axis + ':' + c.id} onClick={() => toggle(c.axis, c.id)} style={{
              display: 'inline-flex', alignItems: 'center', gap: '8px',
              background: 'var(--cream-mid)', color: 'var(--dark)',
              border: '1px solid var(--cream-deep)',
              padding: '6px 10px', cursor: 'pointer',
              fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.08em',
            }}>
              {c.label}
              <span aria-hidden style={{ color: 'var(--dark-mid)', fontSize: '14px', lineHeight: 1 }}>×</span>
            </button>
          ))}
          <button onClick={resetAll} style={{
            background: 'none', border: 'none', cursor: 'pointer',
            fontFamily: 'var(--sans)', fontSize: '11px',
            color: 'var(--terracotta)', letterSpacing: '0.1em',
            textTransform: 'uppercase', padding: '6px 10px',
          }}>Clear all</button>
        </div>
      )}

      <style>{`
        /* ── Tile-grid layouts (3 view modes) ─────────────────── */
        .tile-grid           { display: grid; }
        .tile-grid.view-grid    { grid-template-columns: repeat(3, 1fr); gap: 32px 18px; }
        .tile-grid.view-row     { grid-template-columns: 1fr;            gap: 18px;       }
        .tile-grid.view-compact { grid-template-columns: repeat(5, 1fr); gap: 24px 12px;  }

        /* Each card fades up in sequence on layout switch */
        .tile-grid > * {
          animation: tileIn 0.55s var(--ease-out) both;
        }
        @keyframes tileIn {
          from { opacity: 0; transform: translateY(10px); }
          to   { opacity: 1; transform: translateY(0);    }
        }

        /* ── Sidebar/grid responsive ─────────────────────────── */
        @media (max-width: 1280px) {
          .tile-grid.view-compact { grid-template-columns: repeat(4, 1fr) !important; }
        }
        @media (max-width: 1100px) {
          .durstone-body          { grid-template-columns: 240px 1fr !important; }
          .tile-grid.view-grid    { grid-template-columns: repeat(2, 1fr) !important; }
          .tile-grid.view-compact { grid-template-columns: repeat(3, 1fr) !important; }
        }
        @media (max-width: 860px) {
          .durstone-body          { grid-template-columns: 1fr !important; }
          .durstone-sidebar       { position: static !important; max-height: none !important; }
          .tile-grid.view-grid    { grid-template-columns: repeat(2, 1fr) !important; }
          .tile-grid.view-compact { grid-template-columns: repeat(3, 1fr) !important; }
          /* Phone: drop the thumb row from 3 → 2 cells. Big tile +
             side column structure stays. */
          .row-card-thumbs                  { grid-template-columns: repeat(2, 1fr) !important; }
          .row-card-thumb-wrap:nth-child(3) { display: none; }
        }
        @media (max-width: 560px) {
          /* Very narrow: 1 thumb above the details strip */
          .row-card-thumbs                  { grid-template-columns: 1fr !important; }
          .row-card-thumb-wrap:nth-child(2),
          .row-card-thumb-wrap:nth-child(3) { display: none; }
        }
        @media (max-width: 520px) {
          .tile-grid.view-grid    { grid-template-columns: 1fr !important; }
          .tile-grid.view-compact { grid-template-columns: repeat(2, 1fr) !important; }
        }

        /* ── ROW CARD ───────────────────────────────────────────
              ┌─────────┬─────┬─────┬─────┐
              │         │ T1  │ T2  │ T3  │   3 STATIC thumbs.
              │  BIG    ├─────┴─────┴─────┤
              │ cycling │ STONE   ♡  →   │   Details strip below.
              │ (sights)│                 │
              └─────────┴─────────────────┘

           Outer: 2-col grid (1fr 2fr) → big | side. Big cycles
           through every sight image; thumbs stay static and source
           from extras. */
        .row-card {
          display: grid;
          grid-template-columns: 1fr 2fr;
          gap: 12px;
          background: white;
          padding: 12px;
          border: 1px solid transparent;
          cursor: pointer;
          transition: border-color 0.3s var(--ease-out), box-shadow 0.4s var(--ease-out), transform 0.4s var(--ease-out);
        }
        .row-card:hover {
          box-shadow: 0 14px 36px rgba(20,16,12,0.09);
          transform: translateY(-2px);
        }

        /* Big tile — own column, aspect-ratio: 1 reliably forces
           square. Cycles through sight images via stacked <img>s.
           Soft radial gradient + inset vignette + outer drop shadow
           so the tile reads as a lit ceramic surface even when the
           image is loading or letter-boxed inside the frame. */
        .row-card-big {
          position: relative;
          aspect-ratio: 1;
          overflow: hidden;
          background: radial-gradient(120% 100% at 50% 0%, #FFFFFF 0%, #F4F4F2 55%, #E8E6E2 100%);
          box-shadow:
            inset 0 0 0 1px rgba(15,15,15,0.06),
            inset 0 30px 60px -28px rgba(15,15,15,0.12),
            0 8px 22px -12px rgba(15,15,15,0.18),
            0 1px 2px rgba(15,15,15,0.04);
          transition: box-shadow 0.4s var(--ease-out);
        }
        .row-card:hover .row-card-big {
          box-shadow:
            inset 0 0 0 1px rgba(15,15,15,0.08),
            inset 0 30px 60px -28px rgba(15,15,15,0.10),
            0 16px 36px -16px rgba(15,15,15,0.22),
            0 2px 4px rgba(15,15,15,0.06);
        }
        /* Cycling sight images stacked at full bleed inside the big
           cell. Only the active one is opaque (set inline via React);
           the rest crossfade. */
        .row-card-image {
          position: absolute;
          inset: 0;
          width: 100%; height: 100%;
          object-fit: cover; display: block;
          will-change: opacity, transform;
        }
        .row-card-big:hover .row-card-image {
          transform: scale(1.04);
        }

        /* Side column — flex column matches big tile height. */
        .row-card-side {
          display: flex;
          flex-direction: column;
          gap: 12px;
          min-width: 0;
        }

        /* Thumb row — 3 STATIC squares side-by-side. Sourced from
           extraImages first (admin's gallery uploads), falling back
           to legacy images[1..]. They don't cycle — only the big
           tile does. */
        .row-card-thumbs {
          display: grid;
          grid-template-columns: repeat(3, 1fr);
          gap: 12px;
        }
        .row-card-thumb-wrap {
          position: relative;
          aspect-ratio: 1;
          overflow: hidden;
          /* Plain white frame — the depth lives on the IMG itself
             (filter: drop-shadow) so the shadow follows the actual
             rotated/contained tile photo and the frame's side bands
             stay clean. */
          background: #ffffff;
        }
        /* Thumb fitting rule:
           Thumbs fill their square cell with cover, in natural
           orientation, so square-format tiles read square and
           rectangular ones are centre-cropped — matching the big
           cell and the product-page swatches. */
        .row-card-thumb-image {
          width: 100%; height: 100%;
          object-fit: cover; display: block;
          transition: opacity 0.35s var(--ease-out);
        }
        /* Simple thumb hover — subtle opacity dim, no zoom/scale.
           The card's own translateY lift handles "this is hoverable"
           feedback so the thumbs stay still. */
        .row-card-thumb-wrap:hover .row-card-thumb-image { opacity: 0.85; }

        /* Details strip — fills the remaining height of side col. */
        .row-card-details {
          flex: 1;
          display: flex;
          align-items: center;
          justify-content: space-between;
          gap: 16px;
          padding: 6px 4px 4px;
          min-width: 0;
        }
        /* text-align: left explicitly so we don't inherit the centre
           alignment from .product-suggestions when row cards render
           inside the "You may also like" section — short eyebrow text
           otherwise looks centred under the longer name. */
        .row-card-info-left { min-width: 0; text-align: left; }
        .row-card-eyebrow {
          font-family: var(--sans);
          font-size: 10px; font-weight: 500;
          letter-spacing: 0.24em; text-transform: uppercase;
          color: var(--terracotta);
          margin-bottom: 4px;
        }
        .row-card-name {
          font-family: var(--serif);
          font-weight: 400; font-style: italic;
          font-size: clamp(22px, 1.9vw, 30px);
          line-height: 1.1; letter-spacing: -0.01em;
          /* Compensate for italic optical left-bearing so the first
             letter's visual edge aligns with the eyebrow above. */
          text-indent: -0.06em;
          color: var(--dark);
          transition: letter-spacing 0.4s var(--ease-out);
          white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
        }
        .row-card:hover .row-card-name {
          letter-spacing: 0em;
        }
        .row-card-cta-row {
          display: flex; align-items: center; gap: 14px;
          flex-shrink: 0;
        }
        .row-card-fav {
          width: 32px; height: 32px;
          display: flex; align-items: center; justify-content: center;
          background: white;
          border: 1px solid var(--cream-deep);
          cursor: pointer;
          font-size: 13px; line-height: 1;
          transition: border-color 0.25s, color 0.25s, transform 0.25s;
        }
        .row-card-fav:hover { border-color: var(--terracotta); color: var(--terracotta); transform: scale(1.05); }
        .row-card-cta {
          display: inline-flex; align-items: center; gap: 10px;
          background: none; border: none; cursor: pointer;
          padding: 0 0 3px; font-family: var(--sans);
          font-size: 11px; font-weight: 500;
          letter-spacing: 0.22em; text-transform: uppercase;
          color: var(--dark);
          border-bottom: 1px solid var(--dark);
          white-space: nowrap;
        }
        .row-card-cta-arrow {
          display: inline-block;
          transition: transform 0.3s var(--ease-out);
        }
        .row-card:hover .row-card-cta-arrow {
          transform: translateX(6px);
        }

        /* ── GRID CARD (square) ──────────────────────────────── */
        .grid-card {
          cursor: pointer; outline: none;
          transition: opacity 0.3s;
        }
        .grid-card-image-wrap {
          position: relative; aspect-ratio: 1; overflow: hidden;
          background: var(--cream-mid);
        }
        .grid-card-image {
          width: 100%; height: 100%; object-fit: cover; display: block;
          transition: transform 0.7s var(--ease-out);
        }
        .grid-card:hover .grid-card-image { transform: scale(1.05); }
        .grid-card-info { padding: 14px 0 0; }
        .grid-card-eyebrow {
          font-family: var(--sans);
          font-size: 10px; font-weight: 500;
          letter-spacing: 0.22em; text-transform: uppercase;
          color: var(--dark-mid);
          margin-bottom: 6px;
        }
        .grid-card-name {
          font-family: var(--serif);
          font-size: 19px; font-weight: 400; font-style: italic;
          color: var(--dark); letter-spacing: -0.005em;
          line-height: 1.15;
        }
        .grid-card:hover .grid-card-name { color: var(--terracotta); }
        .grid-card-fav {
          position: absolute; top: 10px; right: 10px;
          width: 30px; height: 30px;
          background: rgba(255,255,255,0.86); border: none; cursor: pointer;
          display: flex; align-items: center; justify-content: center;
          font-size: 14px; line-height: 1;
          opacity: 0; transition: opacity 0.2s;
        }
        /* Mobile: tighter card padding, slightly smaller name so two
           cards per row don't crowd. Favourite icon is always visible
           because there's no hover state on touch devices. */
        @media (max-width: 760px) {
          .grid-card-info { padding: 10px 0 0; }
          .grid-card-eyebrow { font-size: 9px; letter-spacing: 0.18em; margin-bottom: 4px; }
          .grid-card-name { font-size: 15px; }
          .grid-card-fav { opacity: 1; width: 34px; height: 34px; top: 8px; right: 8px; background: rgba(255,255,255,0.94); }
          /* The 2-column listing grid gets tighter gaps on phones */
          .tile-grid.view-grid { gap: 16px 12px !important; }
        }
        .grid-card:hover .grid-card-fav, .grid-card-fav.is-faved { opacity: 1; }
        .grid-card-fav.is-faved { color: var(--terracotta); background: rgba(255,255,255,0.96); }

        /* ── COMPACT CARD ────────────────────────────────────── */
        .compact-card {
          cursor: pointer; outline: none;
        }
        .compact-card-image-wrap {
          position: relative; aspect-ratio: 1; overflow: hidden;
          background: var(--cream-mid);
        }
        .compact-card-image {
          width: 100%; height: 100%; object-fit: cover; display: block;
          transition: transform 0.6s var(--ease-out);
        }
        .compact-card:hover .compact-card-image { transform: scale(1.06); }
        .compact-card-name {
          font-family: var(--serif);
          font-size: 14px; font-weight: 400; font-style: italic;
          color: var(--dark); letter-spacing: -0.005em;
          margin: 10px 0 0;
          text-align: center;
          line-height: 1.2;
        }
        .compact-card:hover .compact-card-name { color: var(--terracotta); }

        /* ── View switcher ───────────────────────────────────── */
        .view-switcher {
          display: inline-flex; align-items: center;
          border: 1px solid var(--cream-deep);
          background: white;
        }
        .view-switcher button {
          width: 32px; height: 30px;
          background: none; border: none; cursor: pointer;
          padding: 0; display: flex; align-items: center; justify-content: center;
          color: var(--dark-mid);
          transition: background 0.2s, color 0.2s;
          border-right: 1px solid var(--cream-deep);
        }
        .view-switcher button:last-child { border-right: none; }
        .view-switcher button:hover { color: var(--dark); }
        .view-switcher button[aria-pressed="true"] {
          background: var(--dark);
          color: white;
        }
      `}</style>
    </div>
  );
}

// ─── Selection helpers ───────────────────────────────────────────────
function emptySelection() {
  return {
    room:   new Set(),
    colour: new Set(),
    style:  new Set(),
    shape:  new Set(),
    finish: new Set(),
  };
}
function cloneSelection(s) {
  return {
    room:   new Set(s.room),
    colour: new Set(s.colour),
    style:  new Set(s.style),
    shape:  new Set(s.shape),
    finish: new Set(s.finish),
  };
}

// ─── Sidebar filter section ──────────────────────────────────────────
function FilterSection({ title, open, onToggle, options, selected, onPick, countOf }) {
  return (
    <div style={{ borderTop: '1px solid var(--cream-deep)', padding: '14px 0' }}>
      <button onClick={onToggle} style={{
        background: 'none', border: 'none', cursor: 'pointer', padding: 0,
        width: '100%', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
        fontFamily: 'var(--sans)', fontSize: '12px', fontWeight: 500,
        letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--dark)',
      }}>
        <span>{title}</span>
        <span aria-hidden style={{
          display: 'inline-block', width: 8, height: 8,
          borderRight: '1.5px solid currentColor',
          borderBottom: '1.5px solid currentColor',
          transform: open ? 'rotate(225deg) translate(-1px,-1px)' : 'rotate(45deg)',
          transition: 'transform 0.25s var(--ease-out)',
          marginRight: 4,
        }}/>
      </button>
      {open && (
        <ul style={{ listStyle: 'none', padding: 0, margin: '14px 0 4px' }}>
          {options.map(opt => {
            const checked = selected.has(opt.id);
            const c = countOf(opt.id);
            const dim = c === 0 && !checked;
            return (
              <li key={opt.id} style={{ marginBottom: 6 }}>
                <label style={{
                  display: 'flex', alignItems: 'center', gap: '10px',
                  cursor: dim ? 'default' : 'pointer',
                  opacity: dim ? 0.4 : 1,
                  fontFamily: 'var(--sans)', fontSize: '13px',
                  color: 'var(--dark)',
                }}>
                  <input
                    type="checkbox"
                    checked={checked}
                    disabled={dim}
                    onChange={() => onPick(opt.id)}
                    style={{ width: 14, height: 14, accentColor: 'var(--dark)', cursor: 'inherit' }}
                  />
                  {opt.swatch && (
                    <span aria-hidden style={{
                      display: 'inline-block', width: 14, height: 14,
                      background: opt.swatch,
                      border: '1px solid var(--cream-deep)',
                      flex: '0 0 auto',
                    }}/>
                  )}
                  <span style={{ flex: 1 }}>{opt.label}</span>
                  <span style={{
                    fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)',
                  }}>{c}</span>
                </label>
              </li>
            );
          })}
        </ul>
      )}
    </div>
  );
}

// ─── Sort dropdown ───────────────────────────────────────────────────
function SortDropdown({ value, onChange }) {
  return (
    <label style={{
      display: 'inline-flex', alignItems: 'center', gap: '8px',
      flexShrink: 0, whiteSpace: 'nowrap',
      background: 'transparent', cursor: 'pointer',
      fontFamily: 'var(--sans)', fontSize: '11px',
      letterSpacing: '0.14em', textTransform: 'uppercase',
      color: 'var(--dark)',
    }}>
      <span style={{ whiteSpace: 'nowrap' }}>Sort by</span>
      <select value={value} onChange={e => onChange(e.target.value)} style={{
        border: '1px solid var(--cream-deep)', background: 'white',
        padding: '7px 10px', fontFamily: 'var(--sans)', fontSize: '11px',
        letterSpacing: '0.08em', color: 'var(--dark)', cursor: 'pointer',
        outline: 'none', textTransform: 'capitalize', whiteSpace: 'nowrap',
      }}>
        <option value="featured">Featured</option>
        <option value="name-asc">Name A–Z</option>
        <option value="name-desc">Name Z–A</option>
      </select>
    </label>
  );
}

// ─── Card helpers ────────────────────────────────────────────────────
// Pull the "style" label off the tile via the shared AXES vocabulary so
// the eyebrow on every card variant uses the same words as the sidebar
// + mega-menu (Marble Effect, Stone Effect, etc).
function styleLabelOf(tile) {
  if (!tile?.style) return '';
  // Resolves the human-readable style label. Reads from styles[0] in
  // the new schema (falls back to legacy single `style`).
  const styleId = firstOf(tile, 'styles') || tile?.style;
  if (!styleId) return null;
  return AXES.style.options.find(o => o.id === styleId)?.label || styleId;
}
// Tiles only carry a single `img` today. The row layout supports up to
// 4 images for when admin uploads variants (per-tile `images: []`); for
// now this just returns `[tile.img]` so single-image tiles still render
// cleanly without faking detail shots.
function imagesOf(tile) {
  // New schema: sightImages + galleryImages. Falls through to legacy
  // fields so any not-yet-migrated record still renders something.
  const sights = Array.isArray(tile?.sightImages) ? tile.sightImages : [];
  const gallery = Array.isArray(tile?.galleryImages) ? tile.galleryImages : [];
  if (sights.length || gallery.length) return [...sights, ...gallery].filter(Boolean);
  if (Array.isArray(tile?.images) && tile.images.length) return tile.images.filter(Boolean);
  return tile?.img ? [tile.img] : [];
}
// Resolve the colour swatch image(s) for "The colours" cells.
// Strict rule: never a sight image.
//
// Schema history — `colourImagesOf` understands ALL of these:
//   1) tile.colourImages = [{url, name?}, …]  ← current; per-swatch name
//   2) tile.colourImages = [url, …]            ← mid-state; URL strings
//   3) tile.colourImage  = "url"               ← legacy single
//   4) tile.galleryImages[0] / extraImages[0]  ← image fallback
//
// `colourImagesOf(tile)` always returns a uniform shape:
//   [{ url: string, name?: string }, …]
// where `name` is optional. Callers fall back to tile.name when
// `name` is absent. `colourImageOf(tile)` returns just the first
// URL — used for SIBLING cells where each sibling represents ONE
// other tile / variant and the sibling's own name is the label.
function colourImagesOf(tile) {
  if (Array.isArray(tile?.colourImages) && tile.colourImages.length) {
    return tile.colourImages
      .map(item => (
        typeof item === 'string'
          ? { url: item }
          : { url: item?.url, ...(item?.name ? { name: item.name } : {}) }
      ))
      .filter(o => o.url);
  }
  if (tile?.colourImage) return [{ url: tile.colourImage }];
  if (Array.isArray(tile?.galleryImages) && tile.galleryImages.length) return [{ url: tile.galleryImages[0] }];
  if (Array.isArray(tile?.extraImages)   && tile.extraImages.length)   return [{ url: tile.extraImages[0] }];
  return [];
}
function colourImageOf(tile) {
  return colourImagesOf(tile)[0]?.url || null;
}
// Single-field readers — every legacy `tile.style`, `tile.finish`,
// `tile.size`, `tile.colour` now resolves to the first element of the
// multi-value array. Keeps the storefront code paths concise.
const firstOf = (tile, key) => {
  const arr = tile?.[key];
  return Array.isArray(arr) ? arr[0] : null;
};
// One-liner editorial copy keyed by style — used as a sub-headline on
// the row card. Falls back to a generic line if no match.
const ROW_TAGLINES = {
  marble:    'Classic marble veining rendered in low-maintenance porcelain.',
  onyx:      'Translucent layers, dramatic on a feature wall.',
  stone:     'Quarried character with none of the upkeep.',
  wood:      'Plank-format porcelain — warmth without the maintenance.',
  concrete:  'Industrial calm, urban quiet.',
  terrazzo:  'Mid-century pattern, modern porcelain body.',
  patterned: 'Made for splashbacks, niches and feature panels.',
  handmade:  'Hand-glazed in small ateliers — variation is part of the pleasure.',
  metallic:  'Used sparingly — does the whole job in a room.',
  plain:     'A quiet surface that lets the architecture speak.',
};
function taglineOf(tile) {
  return ROW_TAGLINES[tile?.style] || 'Hand-selected by our London studio.';
}

// ─── ROW CARD (default) — big cycling tile + 3 static thumbs ────────
// 2-column layout: BIG square tile (left, 1fr) + side column (right, 2fr)
// containing 3 thumb squares above + a details strip below.
//
// Big tile: stacks every sight image the admin uploaded and crossfades
// between them on a 3.8s timer (staggered per card via `index` so the
// listing feels alive rather than synchronised). Hover pauses cycling.
//
// Thumbs: STATIC. Source from `extraImages[]` first (the gallery shots
// the admin explicitly uploaded for the in-sight section), falling back
// to `images.slice(1)` for legacy tiles. They never cycle — only the
// big tile does.
function RowCard({ tile, navigate, index }) {
  const { favourites, toggleFavourite } = useAccount();
  const faved = favourites?.includes(tile.id);
  const images = imagesOf(tile);

  // Cycling pool for the BIG cell. Reads tile.sightImages from the
  // new schema; falls back to legacy fields for any not-yet-migrated
  // record. Tiles with one sight render statically (no timer).
  const sights = React.useMemo(() => {
    if (Array.isArray(tile.sightImages) && tile.sightImages.length) return tile.sightImages;
    if (Array.isArray(tile.images)      && tile.images.length)      return [tile.images[0]];
    return tile.img ? [tile.img] : [];
  }, [tile.sightImages, tile.images, tile.img]);

  // Static thumbs — galleryImages in the new schema, with legacy
  // `extraImages` and `images.slice(1)` fallbacks. Always 3 cells:
  // missing slots fall back to the first available image so we never
  // render an empty box.
  const thumbSource = React.useMemo(() => {
    if (Array.isArray(tile.galleryImages) && tile.galleryImages.length) return tile.galleryImages;
    if (Array.isArray(tile.extraImages)   && tile.extraImages.length)   return tile.extraImages;
    return images.slice(1);
  }, [tile.galleryImages, tile.extraImages, images]);
  const thumbs = [0, 1, 2].map(i => thumbSource[i] || thumbSource[0] || images[0]);

  const [active, setActive] = React.useState(0);
  const [paused, setPaused] = React.useState(false);
  React.useEffect(() => {
    if (sights.length <= 1 || paused) return;
    const offset = (index % 5) * 700;
    let intervalId;
    const startId = setTimeout(() => {
      intervalId = setInterval(() => {
        setActive(s => (s + 1) % sights.length);
      }, 3800);
    }, offset);
    return () => { clearTimeout(startId); if (intervalId) clearInterval(intervalId); };
  }, [sights.length, index, paused]);

  const eyebrow   = styleLabelOf(tile) || tile.collection || '';
  const goProduct = () => navigate('product', tile);

  return (
    <article
      className="row-card"
      onClick={goProduct}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goProduct(); } }}
      role="button"
      tabIndex={0}
      aria-label={`${tile.name} — view details`}
      style={{ animationDelay: `${index * 0.04}s` }}
    >
      {/* Left col: BIG cycling square. Multiple <img> tags stacked at
          full bleed via .row-card-image (position: absolute) — only the
          active one is opaque, the rest crossfade. */}
      <div
        className="row-card-big"
        onMouseEnter={() => setPaused(true)}
        onMouseLeave={() => setPaused(false)}
      >
        {sights.map((src, i) => (
          <img
            key={src + ':' + i}
            src={src}
            alt={i === 0 ? tile.name : ''}
            draggable={false}
            loading="lazy"
            className="row-card-image"
            style={{
              opacity: i === active ? 1 : 0,
              transition: 'opacity 1.1s var(--ease-out), transform 1.0s var(--ease-out)',
            }}
            onError={e => { e.target.style.display = 'none'; }}
          />
        ))}
      </div>

      {/* Right col: 3 static thumbs across the top + details strip
          below. Same proportions as the original layout. */}
      <div className="row-card-side">
        <div className="row-card-thumbs">
          {thumbs.map((src, i) => (
            <div key={i} className="row-card-thumb-wrap">
              <img
                src={src}
                alt=""
                draggable={false}
                loading="lazy"
                className="row-card-thumb-image"
                onError={e => { e.target.style.display = 'none'; }}
              />
            </div>
          ))}
        </div>
        <div className="row-card-details">
          <div className="row-card-info-left">
            {eyebrow && <p className="row-card-eyebrow">{eyebrow}</p>}
            <h3 className="row-card-name">{tile.name}</h3>
          </div>
          <div className="row-card-cta-row">
            <button
              type="button"
              className="row-card-fav"
              onClick={(e) => { e.stopPropagation(); toggleFavourite(tile.id); }}
              aria-label={faved ? 'Remove from favourites' : 'Add to favourites'}
              style={{ color: faved ? 'var(--terracotta)' : 'var(--dark)', borderColor: faved ? 'var(--terracotta)' : 'var(--cream-deep)' }}
            >{faved ? '♥' : '♡'}</button>
            <button
              type="button"
              className="row-card-cta"
              onClick={(e) => { e.stopPropagation(); goProduct(); }}
            >
              View <span aria-hidden className="row-card-cta-arrow">→</span>
            </button>
          </div>
        </div>
      </div>
    </article>
  );
}

// ─── GRID CARD (square) ──────────────────────────────────────────────
// Compact: square image + style eyebrow + serif italic name. NO size
// line below the listing — that detail belongs on the product page.
function GridCard({ tile, navigate, index }) {
  const { favourites, toggleFavourite } = useAccount();
  const faved = favourites?.includes(tile.id);
  const eyebrow = styleLabelOf(tile) || tile.collection || '';
  const heroImg = imagesOf(tile)[0];

  return (
    <div
      className="grid-card"
      onClick={() => navigate('product', tile)}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate('product', tile); } }}
      role="button"
      tabIndex={0}
      style={{ animationDelay: `${index * 0.03}s` }}
    >
      <div className="grid-card-image-wrap">
        <img
          src={heroImg}
          alt={tile.name}
          draggable={false}
          loading="lazy"
          className="grid-card-image"
          onError={e => e.target.style.display = 'none'}
        />
        <button
          type="button"
          className={'grid-card-fav' + (faved ? ' is-faved' : '')}
          onClick={(e) => { e.stopPropagation(); toggleFavourite(tile.id); }}
          aria-label={faved ? 'Remove from favourites' : 'Add to favourites'}
        >{faved ? '♥' : '♡'}</button>
      </div>
      <div className="grid-card-info">
        {eyebrow && <p className="grid-card-eyebrow">{eyebrow}</p>}
        <p className="grid-card-name">{tile.name}</p>
      </div>
    </div>
  );
}

// ─── COMPACT CARD (small, dense) ─────────────────────────────────────
// Just the image + the name. For when the customer wants to scan many
// tiles at once. No eyebrow, no meta — keeps each cell light.
function CompactCard({ tile, navigate, index }) {
  const heroImg = imagesOf(tile)[0];
  return (
    <div
      className="compact-card"
      onClick={() => navigate('product', tile)}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate('product', tile); } }}
      role="button"
      tabIndex={0}
      style={{ animationDelay: `${index * 0.02}s` }}
    >
      <div className="compact-card-image-wrap">
        <img
          src={heroImg}
          alt={tile.name}
          draggable={false}
          loading="lazy"
          className="compact-card-image"
          onError={e => e.target.style.display = 'none'}
        />
      </div>
      <p className="compact-card-name">{tile.name}</p>
    </div>
  );
}

// ─── View-mode switcher ──────────────────────────────────────────────
// Three-icon segmented control. Icons are inline SVGs so they scale
// crisply and inherit currentColor (so the active state's white fill
// works without extra CSS).
function ViewSwitcher({ value, onChange }) {
  const modes = [
    { id: 'row',     label: 'Row view',     icon: <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><rect x="1" y="2"  width="12" height="2.5"/><rect x="1" y="5.75" width="12" height="2.5"/><rect x="1" y="9.5"  width="12" height="2.5"/></svg> },
    { id: 'grid',    label: 'Grid view',    icon: <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"><rect x="1" y="1" width="5" height="5"/><rect x="8" y="1" width="5" height="5"/><rect x="1" y="8" width="5" height="5"/><rect x="8" y="8" width="5" height="5"/></svg> },
    { id: 'compact', label: 'Compact view', icon: <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="1"  y="1"  width="3" height="3"/><rect x="5.5" y="1" width="3" height="3"/><rect x="10" y="1" width="3" height="3"/><rect x="1" y="5.5" width="3" height="3"/><rect x="5.5" y="5.5" width="3" height="3"/><rect x="10" y="5.5" width="3" height="3"/><rect x="1" y="10" width="3" height="3"/><rect x="5.5" y="10" width="3" height="3"/><rect x="10" y="10" width="3" height="3"/></svg> },
  ];
  return (
    <div className="view-switcher" role="group" aria-label="View mode">
      {modes.map(m => (
        <button
          key={m.id}
          type="button"
          aria-pressed={value === m.id}
          aria-label={m.label}
          title={m.label}
          onClick={() => onChange(m.id)}
        >{m.icon}</button>
      ))}
    </div>
  );
}

// Breadcrumb visual atoms
const crumbBtn = {
  background: 'none', border: 'none', cursor: 'pointer', padding: 0,
  fontFamily: 'inherit', fontSize: 'inherit', letterSpacing: 'inherit',
  color: 'var(--dark-mid)',
};
const crumbSep = { margin: '0 8px', color: 'var(--cream-deep)' };

// ─── Hero (used inside CollectionDetail) ─────────────────────────────
function CollectionHero({ heroImg, spec, count, navigate, backToAxis }) {
  const [src, setSrc] = useState(heroImg);
  const [parallax, setParallax] = useState(0);
  useEffect(() => { setSrc(heroImg); }, [heroImg]);
  useEffect(() => {
    const onScroll = () => setParallax(window.scrollY);
    window.addEventListener('scroll', onScroll, { passive: true });
    return () => window.removeEventListener('scroll', onScroll);
  }, []);

  // What does the eyebrow say? Map axis to a short label.
  const axisLabel = ({
    cat: 'Tile collection',
    room: 'Shop by room',
    colour: 'Shop by colour',
    style: 'Shop by style',
    shape: 'Shop by shape & size',
  })[spec.axis] || 'Tile collection';

  // The big italic word at the end of the headline. For cat we keep the
  // legacy "Tiles", for everything else we use the axis term.
  const trailingWord = spec.axis === 'cat' ? 'tiles' : axisLabel.replace('Shop by ', '');

  return (
    <div style={{ position: 'relative', height: '68vh', minHeight: '520px', overflow: 'hidden', background: 'var(--dark)' }}>
      <img
        src={src}
        alt={spec.label}
        onError={() => setSrc('/assets/categories/luxury-meteorite.jpg')}
        style={{
          position: 'absolute', inset: 0,
          width: '100%', height: '115%', objectFit: 'cover', display: 'block',
          transform: `translateY(${parallax * 0.25}px) scale(1.02)`,
          filter: 'brightness(0.85) saturate(1.05)',
        }}
      />
      <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(180deg, rgba(20,16,12,0.45) 0%, rgba(20,16,12,0.1) 35%, rgba(20,16,12,0.82) 100%)' }}/>
      <div style={{
        position: 'absolute', inset: 0, pointerEvents: 'none',
        backgroundImage: 'radial-gradient(rgba(255,255,255,0.04) 1px, transparent 1px)',
        backgroundSize: '3px 3px', mixBlendMode: 'overlay', opacity: 0.55,
      }}/>
      <div style={{
        position: 'absolute', top: '110px', right: '40px',
        fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.22em',
        textTransform: 'uppercase', color: 'rgba(255,255,255,0.55)', textAlign: 'right', lineHeight: 1.9,
      }} className="cat-hero-meta">
        {spec.axis === 'cat' ? 'Collection' : 'Selection'} N° {String(Math.abs(hashCode(`${spec.axis}:${spec.value}`)) % 99 + 1).padStart(2, '0')}<br/>
        London
      </div>
      <div style={{ position: 'absolute', bottom: '72px', left: '40px', right: '40px' }}>
        <button onClick={backToAxis} style={{ background: 'none', border: 'none', cursor: 'pointer', fontFamily: 'var(--sans)', fontSize: '11px', color: 'rgba(255,255,255,0.62)', letterSpacing: '0.14em', textTransform: 'uppercase', marginBottom: '18px', padding: 0 }}>← {spec.axis === 'cat' ? 'All collections' : 'Back to options'}</button>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.24em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.62)', marginBottom: '14px' }}>
          <span style={{ display: 'inline-block', width: '28px', height: '1px', background: 'var(--terracotta-light)', verticalAlign: 'middle', marginRight: '12px' }}/>
          {axisLabel}
        </p>
        <h1 style={{ fontFamily: 'var(--serif)', fontWeight: 300, fontSize: 'clamp(56px, 8.5vw, 120px)', color: 'white', lineHeight: 0.96, letterSpacing: '-0.02em' }}>
          {spec.label} <em style={{ color: 'var(--terracotta-light)' }}>{trailingWord}</em>
        </h1>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'rgba(255,255,255,0.72)', marginTop: '18px' }}>
          {count} {count === 1 ? 'tile' : 'tiles'} · Hand-picked in London
        </p>
      </div>
      <style>{`@media (max-width: 760px) { .cat-hero-meta { display: none !important; } }`}</style>
    </div>
  );
}

// Tiny string→int hash for the "Selection N° xx" badge — stable per spec.
function hashCode(s) {
  let h = 0; for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0; return h;
}

// ─── Filter pill ─────────────────────────────────────────────────────
function FilterPill({ label, value, options, onChange }) {
  const norm = options.map(o => Array.isArray(o) ? o : [o, o === 'all' ? `All ${label.toLowerCase()}` : o]);
  return (
    <label style={{ display: 'inline-flex', alignItems: 'center', gap: '6px', background: 'white', border: '1px solid var(--cream-deep)', padding: '6px 12px', cursor: 'pointer' }}>
      <span style={{ fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.1em', textTransform: 'uppercase', color: 'var(--dark-mid)' }}>{label}</span>
      <select value={value} onChange={e => onChange(e.target.value)} style={{ border: 'none', background: 'transparent', padding: 0, fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark)', outline: 'none', cursor: 'pointer' }}>
        {norm.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
      </select>
    </label>
  );
}

// ─── Tile card (unchanged from the previous design) ──────────────────
function TileCard({ tile, navigate }) {
  const { favourites, toggleFavourite, addToCart } = useAccount();
  const faved = favourites?.includes(tile.id);
  const [hover, setHover] = useState(false);

  const goToProduct = () => navigate('product', tile);
  const heroImg = imagesOf(tile)[0];
  const finishLabel = firstOf(tile, 'finishes') || tile.finish || '';
  const sizeLabel   = firstOf(tile, 'sizes')    || tile.size   || '';

  return (
    <div
      onMouseEnter={() => setHover(true)}
      onMouseLeave={() => setHover(false)}
      onClick={goToProduct}
      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goToProduct(); } }}
      role="button"
      tabIndex={0}
      aria-label={`${tile.name} — view details`}
      style={{ position: 'relative', cursor: 'pointer', outline: 'none' }}
    >
      <div className="tile-frame" style={{ aspectRatio: '1', overflow: 'hidden', position: 'relative' }}>
        <img
          src={heroImg}
          alt={tile.name}
          draggable={false}
          style={{
            width: '100%', height: '100%', objectFit: 'cover', display: 'block',
            transition: 'transform 0.8s var(--ease-out)',
            transform: hover ? 'scale(1.06)' : 'scale(1)',
            imageRendering: '-webkit-optimize-contrast',
          }}
          onError={e => e.target.style.display = 'none'}
        />
        <div style={{
          position: 'absolute', inset: 0, pointerEvents: 'none',
          background: 'linear-gradient(180deg, transparent 55%, rgba(20,16,12,0.55) 100%)',
          opacity: hover ? 1 : 0, transition: 'opacity 0.35s',
        }}/>
        <button
          type="button"
          onClick={(e) => { e.stopPropagation(); toggleFavourite(tile.id); }}
          aria-label={faved ? 'Remove from favourites' : 'Add to favourites'}
          style={{
            position: 'absolute', top: '12px', right: '12px', zIndex: 3,
            width: '34px', height: '34px', borderRadius: '50%',
            background: 'rgba(255,255,255,0.94)', border: 'none', cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: faved ? 'var(--terracotta)' : 'var(--dark)',
            fontSize: '16px', lineHeight: 1, transition: 'all 0.2s',
            boxShadow: '0 2px 8px rgba(20,16,12,0.08)',
          }}
        >{faved ? '♥' : '♡'}</button>
        <div style={{
          position: 'absolute', left: '12px', right: '12px', bottom: '12px', zIndex: 2,
          display: 'flex', gap: '6px',
          opacity: hover ? 1 : 0,
          transform: hover ? 'translateY(0)' : 'translateY(10px)',
          transition: 'opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out)',
          pointerEvents: hover ? 'auto' : 'none',
        }}>
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              addToCart(tile.id, 5);
              if (typeof window.QW_openCart === 'function') window.QW_openCart();
            }}
            style={actionBtnDark}
          >Add to basket</button>
        </div>
      </div>
      <div style={{ padding: '22px 2px 2px' }}>
        {tile.collection && (
          <p style={{
            fontFamily: 'var(--sans)', fontSize: '10px', fontWeight: 500,
            letterSpacing: '0.28em', textTransform: 'uppercase',
            color: 'var(--terracotta)', marginBottom: '12px',
          }}>— {tile.collection}</p>
        )}
        <h3 style={{
          fontFamily: 'var(--serif)',
          fontSize: 'clamp(24px, 2vw, 34px)',
          fontWeight: 400, fontStyle: 'italic',
          color: 'var(--dark)',
          letterSpacing: '-0.02em', lineHeight: 1.1,
          marginBottom: '16px',
        }}>{tile.name}</h3>
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
          paddingTop: '14px', borderTop: '1px solid var(--cream-deep)', gap: '10px',
        }}>
          <span style={{
            fontFamily: 'var(--sans)', fontSize: '10.5px', fontWeight: 400,
            color: 'var(--dark-mid)', letterSpacing: '0.18em', textTransform: 'uppercase',
            overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
          }}>{[finishLabel, sizeLabel].filter(Boolean).join(' · ') || 'See details'}</span>
          <span style={{
            fontFamily: 'var(--serif)', fontSize: '18px', fontWeight: 500,
            color: 'var(--dark)', whiteSpace: 'nowrap', letterSpacing: '-0.01em',
          }}>{tile.price || 'POA'}</span>
        </div>
      </div>
    </div>
  );
}

const actionBtnDark  = { flex: 1, padding: '12px 8px', border: 'none', background: 'var(--dark)', color: 'white', cursor: 'pointer', fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.12em', textTransform: 'uppercase', fontWeight: 500 };

// ─── Product detail (unchanged) ──────────────────────────────────────
// ─── PRODUCT DETAIL PAGE ─────────────────────────────────────────────
// A scroll-driven sequence — no floating boxes, no chrome on the hero.
// Each section reveals as the customer scrolls down:
//
//   1. HERO        full-viewport ambience (the tile image), tile name
//                  centred, animated scroll-cue line at the bottom.
//   2. DETAIL      a row of up to 5 product images side-by-side.
//                  Centred when fewer (3 images render in slots 2-4).
//   3. SIZES       a single outlined box that morphs shape with the
//                  selected size, plus an m² → tiles calculator.
//   4. COLOURS     siblings in the same collection (Noor White, Noor
//                  Berry...) as clickable thumb + name cells.
//   5. FINISHES    available finishes as pill buttons.
//   6. ACTIONS     small ♡ + Request-a-quote row, centred.
//   7. SUGGESTIONS the same row layout as the listing page, headed
//                  "You might also like".
//
// useReveal is the existing IntersectionObserver hook — adds .visible
// when an element scrolls into view. CSS in this file handles the
// fade-up animation on `.reveal → .reveal.visible`.

// ─── FinishIcon — small symbolic SVG per finish type ─────────────────
// Each icon is a 20×20 inline SVG that hints at the finish texture so
// the customer reads the FINISH section visually before reading text.
//   · Matt        a quiet solid disc (no shine)
//   · Polished    disc with a soft highlight
//   · Natural     two soft waves (organic surface variation)
//   · Honed       smooth rounded square (refined matt)
//   · Handglazed  a single brush-stroke arc
function FinishIcon({ name }) {
  const map = {
    Matt: (
      <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden>
        <circle cx="10" cy="10" r="7" fill="currentColor" opacity="0.85"/>
      </svg>
    ),
    Polished: (
      <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden>
        <circle cx="10" cy="10" r="7" fill="currentColor"/>
        <ellipse cx="7" cy="6.5" rx="2.6" ry="1.4" fill="white" opacity="0.55"
                 transform="rotate(-30 7 6.5)"/>
      </svg>
    ),
    Natural: (
      <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
        <path d="M3 8 Q 6 4 10 8 T 17 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
        <path d="M3 13 Q 6 9 10 13 T 17 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"/>
      </svg>
    ),
    Honed: (
      <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden>
        <rect x="3" y="3" width="14" height="14" rx="2" fill="currentColor" opacity="0.7"/>
      </svg>
    ),
    Handglazed: (
      <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden>
        <path d="M3 11 C 5 6, 8 14, 11 8 S 15 12, 17 9"
              stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"/>
      </svg>
    ),
  };
  return map[name] || (
    // Generic fallback for unknown finish names
    <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden>
      <circle cx="10" cy="10" r="6" fill="none" stroke="currentColor" strokeWidth="1.5"/>
    </svg>
  );
}

function Product({ product, navigate }) {
  const allTiles = useAllTiles();
  const collectionsMeta = useCollectionsMeta();
  const { favourites, toggleFavourite } = useAccount();
  const faved = favourites?.includes(product.id);
  // Mobile detection — drives the sticky bottom CTA bar and the
  // beefier touch target sizes on size/finish pills.
  const isProductMobile = useIsMobile(760);

  // Sibling tiles = same product name AND same collection (used to
  // surface available size + finish options for THIS specific product).
  // Filtering on collection too matters because tile names can collide
  // across collections (e.g. "PEARL" exists in ALLOY, ARABESCATO and
  // AT. STONE — three different products that share a colour name).
  const siblings = useMemo(() => {
    if (!product?.name || !allTiles.length) return product ? [product] : [];
    return allTiles.filter(t => t.name === product.name && t.collection === product.collection);
  }, [allTiles, product?.name, product?.collection]);

  // Collection-prefix display (used in the hero "X collection" line).
  // Reads the first-class `collection` field from the new schema;
  // falls back to first-word of the name for legacy records.
  const collectionPrefix = useMemo(
    () => product.collection || (product.name || '').split(' ')[0] || '',
    [product.collection, product.name]
  );
  // Colour variants in the same range. Matches on the first-class
  // `collection` field. Order + hidden are pulled from
  // collectionsMeta (admin-managed via the Collections admin page) —
  // tiles not in the order list fall to the end in catalogue order,
  // and tiles in the hidden list are dropped entirely.
  const colourSwatches = useMemo(() => {
    if (!product.collection || !allTiles.length) return [];
    const meta = collectionsMeta[product.collection] || {};
    const hidden = new Set(Array.isArray(meta.hidden) ? meta.hidden : []);
    const order  = Array.isArray(meta.order)  ? meta.order  : [];

    // 1) Gather all tiles in this collection except the current one
    //    and any explicitly hidden by admin.
    const seen = new Set();
    const pool = [];
    for (const t of allTiles) {
      if (t.id === product.id) continue;
      if (t.collection !== product.collection) continue;
      if (hidden.has(t.id)) continue;
      if (seen.has(t.name)) continue; // dedupe variants that share a name
      seen.add(t.name);
      pool.push(t);
    }
    // 2) Sort: ids in `order` come first in that order, then everything
    //    else in original catalogue order.
    const indexOf = (id) => {
      const i = order.indexOf(id);
      return i < 0 ? Number.POSITIVE_INFINITY : i;
    };
    pool.sort((a, b) => indexOf(a.id) - indexOf(b.id));
    return pool.slice(0, 12);
  }, [allTiles, collectionsMeta, product.collection, product.id]);

  // Multi-value primary values for the active-size / active-finish state.
  // Reads sizes[0] / finishes[0] from the new schema; legacy single-
  // value `tile.size` / `tile.finish` work as the final fallback.
  const initialSize   = firstOf(product, 'sizes')    || product.size   || '';
  const initialFinish = firstOf(product, 'finishes') || product.finish || '';
  const [activeSize, setActiveSize]     = useState(initialSize);
  const [activeFinish, setActiveFinish] = useState(initialFinish);
  useEffect(() => {
    setActiveSize(firstOf(product, 'sizes') || product.size || '');
    setActiveFinish(firstOf(product, 'finishes') || product.finish || '');
  }, [product.id]);

  // Sizes — combine the multi-value `sizes[]` array on the current
  // tile (admin upgrade — the customer-visible source of truth) with
  // anything from sibling tiles, de-duplicated. Same pattern as the
  // finishList below: multi-value array first, sibling-derived fallback.
  const sizes = useMemo(() => {
    const out = new Set();
    if (Array.isArray(product.sizes)) product.sizes.forEach(s => s && out.add(s));
    siblings.forEach(t => { if (t.size) out.add(t.size); });
    return Array.from(out);
  }, [product.sizes, siblings]);
  const finishes = useMemo(() => {
    const set = new Set();
    for (const t of siblings) {
      if (Array.isArray(t.finishes)) t.finishes.forEach(f => set.add(f));
      else if (t.finish) set.add(t.finish);
    }
    return Array.from(set);
  }, [siblings]);

  // ─── Size shape groups ───────────────────────────────────────
  // Parse each size string ("30×60cm", "60x60cm", "Ø80cm" → first
  // 2 numbers) and group by aspect ratio. Each group renders as a
  // single visual box (square or rectangle of the right ratio) with
  // every dimension in that group listed beside it as a clickable
  // pill. Square sizes group together (1:1), every distinct rectangle
  // ratio gets its own group.
  const sizeGroups = useMemo(() => {
    const parse = (s) => {
      const m = String(s || '').match(/(\d+(?:\.\d+)?)\s*[×xX*]\s*(\d+(?:\.\d+)?)/);
      if (!m) return null;
      return { a: Number(m[1]), b: Number(m[2]) };
    };
    const map = new Map();
    for (const s of sizes) {
      const p = parse(s);
      if (!p) continue;
      const long  = Math.max(p.a, p.b);
      const short = Math.min(p.a, p.b);
      const ratio = long / short;
      // Bucket key — round to 2 dp so 1.99 and 2.01 collapse together.
      const key = ratio.toFixed(2);
      if (!map.has(key)) map.set(key, { ratio, long, short, items: [] });
      map.get(key).items.push(s);
    }
    // Sort: 1:1 (square) first, then ratios ascending (gentlest rect → most extreme).
    const grouped = Array.from(map.values()).sort((a, b) => a.ratio - b.ratio);
    // Cap each box at TWO sizes — the client wants no more than two
    // dimensions per visual box. A ratio group with more than two
    // sizes splits into multiple boxes of the SAME shape, each holding
    // up to two. (e.g. four 1:1 squares → two square boxes.)
    const out = [];
    for (const g of grouped) {
      for (let i = 0; i < g.items.length; i += 2) {
        out.push({ ...g, items: g.items.slice(i, i + 2) });
      }
    }
    return out;
  }, [sizes]);

  // Finish PILLS for the spec section. Prefer the admin-uploaded
  // `finishes` array on the current product. Fall back to the
  // sibling-derived list, and finally to the legacy single `finish`.
  const finishList = useMemo(() => {
    if (Array.isArray(product.finishes) && product.finishes.length) return product.finishes;
    if (finishes.length) return finishes;
    return product.finish ? [product.finish] : [];
  }, [product.finishes, product.finish, finishes]);

  // Calculator — parse "30×60cm" → 0.18 m² per tile, etc.
  const tileArea = useMemo(() => {
    const m = (activeSize || '').match(/(\d+)\D+(\d+)/);
    if (!m) return 0.18;
    return (Number(m[1]) / 100) * (Number(m[2]) / 100);
  }, [activeSize]);

  const [sqm, setSqm]  = useState(10);
  const tilesNeeded    = Math.ceil(sqm / Math.max(tileArea, 0.01));
  const cartons        = Math.ceil(sqm / 1.2);

  // Suggestions — every other tile that shares this tile's styles,
  // colours, or rooms, ranked by best match first. No upper cap.
  //
  // Ranking pass (each tile gets the FIRST bucket it qualifies for):
  //   1. shares BOTH a style and a colour   (closest match)
  //   2. shares a colour                    (same palette family)
  //   3. shares a style                     (same look — marble, wood…)
  //   4. shares a room                      (same context)
  //   5. anything else                      (broad relevance)
  // Each tile appears only ONCE, in its highest-priority bucket.
  const suggestions = useMemo(() => {
    if (!allTiles.length) return [];
    const others = allTiles.filter(t => t.id !== product.id);
    const seen = new Set();
    const buckets = [[], [], [], [], []];
    const arr = (v) => Array.isArray(v) ? v : (v ? [v] : []);
    const productColours = new Set(arr(product.colours).concat(arr(product.colour)).filter(Boolean));
    const productStyles  = new Set(arr(product.styles ).concat(arr(product.style )).filter(Boolean));
    const productRooms   = new Set(arr(product.rooms  ).concat(arr(product.room  )).filter(Boolean));
    for (const t of others) {
      if (seen.has(t.name)) continue;
      const tColours = new Set(arr(t.colours).concat(arr(t.colour)).filter(Boolean));
      const tStyles  = new Set(arr(t.styles ).concat(arr(t.style )).filter(Boolean));
      const tRooms   = new Set(arr(t.rooms  ).concat(arr(t.room  )).filter(Boolean));
      const colourMatch = [...productColours].some(c => tColours.has(c));
      const styleMatch  = [...productStyles ].some(s => tStyles.has(s));
      const roomMatch   = [...productRooms  ].some(r => tRooms.has(r));
      let bucket;
      if (styleMatch && colourMatch)      bucket = 0;
      else if (colourMatch)               bucket = 1;
      else if (styleMatch)                bucket = 2;
      else if (roomMatch)                 bucket = 3;
      else                                bucket = 4;
      buckets[bucket].push(t);
      seen.add(t.name);
    }
    return buckets.flat();
  }, [allTiles, product.id, product.name, product.styles, product.colours, product.rooms]);

  // Suggestions pagination — start with 6, "Show more" reveals the
  // next 6 each time the user clicks. State resets when the customer
  // navigates to a different tile.
  const SUGGESTIONS_PAGE_SIZE = 6;
  const [suggestionsCount, setSuggestionsCount] = useState(SUGGESTIONS_PAGE_SIZE);
  useEffect(() => { setSuggestionsCount(SUGGESTIONS_PAGE_SIZE); }, [product.id]);
  const visibleSuggestions = suggestions.slice(0, suggestionsCount);
  const hasMoreSuggestions = suggestionsCount < suggestions.length;

  const styleLabel = styleLabelOf(product) || product.collection || '';
  // Combined images list (sights + gallery) — used as a fallback in a
  // few places. New schema reads sightImages[] + galleryImages[];
  // legacy tiles still surface via the imagesOf() helper.
  const images = imagesOf(product);

  // ─── HERO SIGHT IMAGES (cycling, like the home page) ───────────
  // Reads tile.sightImages from the new schema; falls back through
  // legacy fields so any not-yet-migrated record still renders.
  const sightImages = useMemo(() => {
    if (Array.isArray(product.sightImages) && product.sightImages.length) return product.sightImages;
    if (Array.isArray(product.images)      && product.images.length)      return [product.images[0]];
    return product.img ? [product.img] : [];
  }, [product.sightImages, product.images, product.img]);
  // Gallery in the "In sight" section reads from galleryImages[];
  // legacy extraImages and images.slice(1) are honoured as fallbacks.
  const galleryImages = useMemo(() => {
    if (Array.isArray(product.galleryImages) && product.galleryImages.length) return product.galleryImages;
    if (Array.isArray(product.extraImages)   && product.extraImages.length)   return product.extraImages;
    return images.slice(1);
  }, [product.galleryImages, product.extraImages, images]);

  const [activeHero, setActiveHero] = useState(0);
  const [heroPaused, setHeroPaused] = useState(false);
  // Reset to the first sight whenever the product changes.
  useEffect(() => { setActiveHero(0); }, [product.id]);
  // Auto-advance every 6.5s (matches home page rhythm). Pauses on hover.
  useEffect(() => {
    if (heroPaused || sightImages.length <= 1) return;
    const id = setInterval(() => {
      setActiveHero(s => (s + 1) % sightImages.length);
    }, 6500);
    return () => clearInterval(id);
  }, [heroPaused, sightImages.length]);
  const prevHero = () => setActiveHero(s => (s - 1 + sightImages.length) % sightImages.length);
  const nextHero = () => setActiveHero(s => (s + 1) % sightImages.length);
  const hasMultipleSights = sightImages.length > 1;

  // Tile aspect for the size visualisation. Always longer-side ÷ shorter-
  // side, so 30×60 reads as a wide rectangle (aspect 2), 60×60 a square
  // (aspect 1), 20×20 a small square. The visualisation box is set to a
  // fixed max-width and uses CSS aspect-ratio to derive its height.
  const tileAspect = useMemo(() => {
    const m = (activeSize || '').match(/(\d+)\D+(\d+)/);
    if (!m) return 1;
    const a = Number(m[1]); const b = Number(m[2]);
    return Math.max(a, b) / Math.min(a, b);
  }, [activeSize]);

  // No scroll-triggered fade-ins — sections render visible. The earlier
  // .reveal/.visible dance was hiding async-loaded sections (the
  // suggestions strip especially) when the IntersectionObserver had
  // already finished observing what was in the DOM at first paint.

  return (
    <div className="product-page">
      {/* ── 1. HERO — full-viewport ambience + name + scroll cue ───
              Multiple sight images crossfade automatically (6.5s) and
              also flip on click of the left/right nav zones (45% wide
              each, 10% safe gap in the middle). Cursor.jsx detects
              [data-hero-nav] and swaps the cursor to a big chevron —
              identical to the home page. */}
      <section className="product-hero">
        {/* Stacked sight images. Only the active one has opacity:1; the
            rest fade. Single-sight legacy tiles render exactly one
            <img> here and skip the nav zones entirely. */}
        <div
          className="product-hero-stack"
          onMouseEnter={() => setHeroPaused(true)}
          onMouseLeave={() => setHeroPaused(false)}
          aria-hidden
        >
          {sightImages.map((src, i) => (
            <img
              key={src + ':' + i}
              src={src}
              alt={i === 0 ? product.name : ''}
              className="product-hero-bg"
              draggable={false}
              loading={i === 0 ? 'eager' : 'lazy'}
              style={{
                opacity: i === activeHero ? 1 : 0,
                transition: 'opacity 1.4s var(--ease-out)',
              }}
              onError={e => { e.target.style.display = 'none'; }}
            />
          ))}
        </div>
        <div className="product-hero-overlay" aria-hidden/>

        {/* Hero NAV ZONES — only when more than one sight image exists.
            45% wide left/right click areas with a 10% safe gap in the
            middle, just like the home page. data-hero-nav triggers the
            chevron cursor via Cursor.jsx. */}
        {hasMultipleSights && (
          <>
            <button
              type="button"
              data-hero-nav="left"
              onClick={prevHero}
              aria-label="Previous sight"
              className="product-hero-nav product-hero-nav-left"
            />
            <button
              type="button"
              data-hero-nav="right"
              onClick={nextHero}
              aria-label="Next sight"
              className="product-hero-nav product-hero-nav-right"
            />
            <div className="product-hero-pips" aria-hidden>
              {sightImages.map((_, i) => (
                <button
                  key={i}
                  type="button"
                  className={'product-hero-pip' + (i === activeHero ? ' is-active' : '')}
                  onClick={() => setActiveHero(i)}
                  aria-label={`Sight ${i + 1}`}
                />
              ))}
            </div>
          </>
        )}

        <div className="product-hero-content">
          <div className="product-hero-content-inner">
            {styleLabel && <p className="product-hero-eyebrow">{styleLabel}</p>}
            <h1 className="product-hero-title">{product.name}</h1>
            {collectionPrefix && (
              <p className="product-hero-collection">{collectionPrefix} collection</p>
            )}
            {product.description && (
              <p className="product-hero-description">{product.description}</p>
            )}
          </div>
        </div>

        <div className="product-hero-scroll" aria-hidden>
          <span>Scroll</span>
          <div className="product-hero-scroll-line"/>
        </div>
      </section>

      {/* ── 2. DETAIL — up to 5 images side-by-side, centred. The
              sight images now cycle in the hero, so the gallery shows
              ONLY the admin-uploaded additional shots (extraImages),
              never the sight images. Section hides if there are no
              additional images. */}
      {galleryImages.length > 0 && (
      <section className="product-section product-section-gallery">
        <p className="product-section-eyebrow">Detail</p>
        <h2 className="product-section-title">In sight</h2>
        <div className="product-gallery">
          {galleryImages.map((src, i) => (
            <div key={i} className="product-gallery-cell">
              <img
                src={src}
                alt=""
                draggable={false}
                loading="lazy"
                onError={e => { e.target.style.display = 'none'; }}
              />
            </div>
          ))}
        </div>
      </section>
      )}

      {/* ── 3. SPECIFICATION — two halves split by a centre line.
              Content anchors at the centre and grows outward toward
              the screen edges, using the wide canvas symmetrically.
                · LEFT half: SIZE — dropdown + visual side-by-side
                · RIGHT half: FINISH — icon + name pills, max 5
              Calculator moved to the ACTIONS section below. */}
      <section className="product-section product-section-spec">
        <h2 className="product-section-title">Size &amp; finish</h2>

        <div className="product-spec-row">
          {/* LEFT half — Size. Anchored at the centre line on its
              right edge (kisses the divider); content extends
              leftward. One visual box per shape group; dimensions
              live INSIDE the box as plain clickable text.
              Visual order (left → right): widest rectangle first,
              square last (closest to the centre divider). */}
          <div className="product-spec-half product-spec-size">
            <p className="product-control-label">Size</p>
            {sizeGroups.length === 0 ? (
              <p className="product-spec-fallback">{activeSize || '—'}</p>
            ) : (
              <div className="product-size-groups">
                {sizeGroups.map((g, gi) => (
                  <div
                    key={gi}
                    className="product-size-shape-box"
                    style={{ aspectRatio: `${g.long} / ${g.short}` }}
                  >
                    <ul className="product-size-shape-list">
                      {g.items.map(s => (
                        <li key={s}>
                          <button
                            type="button"
                            className={'product-size-pill' + (s === activeSize ? ' is-active' : '')}
                            onClick={() => setActiveSize(s)}
                          >{s}</button>
                        </li>
                      ))}
                    </ul>
                  </div>
                ))}
              </div>
            )}
          </div>

          {/* RIGHT half — Finish, anchored at the centre line on the
              left edge of the half, content extends rightward. */}
          <div className="product-spec-half product-spec-finish">
            <p className="product-control-label">Finish</p>
            <div className="product-finish-pills">
              {finishList.slice(0, 5).map(f => (
                <button
                  key={f}
                  type="button"
                  className={'product-finish-pill' + (f === activeFinish ? ' is-active' : '')}
                  onClick={() => setActiveFinish(f)}
                >
                  <FinishIcon name={f}/>
                  <span>{f}</span>
                </button>
              ))}
            </div>
          </div>
        </div>
      </section>

      {/* ── 4. COLOURS — every swatch for the current tile first,
              then siblings (other tiles in the same collection).
              Strict rule: cells NEVER show a sight image. They
              use admin-uploaded colour swatches, falling back to
              gallery / extra images. The section hides only if no
              usable image exists for current or any sibling. */}
      {(() => {
        const currentSwatches = colourImagesOf(product);
        const usableSiblings = colourSwatches
          .map(c => ({ tile: c, img: colourImageOf(c) }))
          .filter(s => s.img);
        if (currentSwatches.length === 0 && usableSiblings.length === 0) return null;
        return (
          <section className="product-section product-section-colours">
            <h2 className="product-section-title">The colours</h2>
            <div className="product-colours-grid">
              {currentSwatches.map((swatch, i) => {
                // Every swatch on a single-tile multi-colour product
                // (e.g. Era's six colours, Alabastri's five) belongs
                // to the same product record — none is more "current"
                // than another, so they all render identically. The
                // visible label is the admin-typed swatch name; we fall
                // back to "<Product> · Variant N" only when no name
                // was supplied.
                const displayName = swatch.name
                  || (i === 0 ? product.name : `${product.name} · Variant ${i + 1}`);
                return (
                  <div key={'cur-' + i} className="product-colour-cell">
                    <div className="product-colour-cell-thumb">
                      <img
                        src={swatch.url}
                        alt={displayName}
                        draggable={false}
                        loading="lazy"
                      />
                    </div>
                    <p className="product-colour-cell-name">{displayName}</p>
                  </div>
                );
              })}
              {usableSiblings.map(({ tile: c, img }) => (
                <button
                  key={c.id}
                  type="button"
                  className="product-colour-cell"
                  onClick={() => navigate('product', c)}
                >
                  <div className="product-colour-cell-thumb">
                    <img
                      src={img}
                      alt={c.name}
                      draggable={false}
                      loading="lazy"
                    />
                  </div>
                  <p className="product-colour-cell-name">{c.name}</p>
                </button>
              ))}
            </div>
          </section>
        );
      })()}

      {/* ── 5. ACTIONS — area calculator (moved here from the spec
              section) + ♡ + Request a quote, all in one block. */}
      <section className="product-section product-section-actions">
        <div className="product-actions-block">
          <p className="product-control-label" style={{ textAlign: 'center' }}>How much do you need?</p>
          <div className="product-calc-large-row">
            <button type="button" onClick={() => setSqm(Math.max(1, sqm - 1))} aria-label="Decrease area">−</button>
            <input
              type="number"
              value={sqm}
              min={1}
              onChange={e => setSqm(Math.max(1, Number(e.target.value) || 1))}
              aria-label="Area in square metres"
            />
            <span className="product-calc-large-unit">m²</span>
            <button type="button" onClick={() => setSqm(sqm + 1)} aria-label="Increase area">+</button>
          </div>
          <p className="product-calc-large-result">
            ≈ {tilesNeeded.toLocaleString()} tiles &nbsp;·&nbsp; {cartons} carton{cartons === 1 ? '' : 's'}
          </p>
          <div className="product-actions-row">
            <button
              type="button"
              className={'product-action-fav' + (faved ? ' is-faved' : '')}
              onClick={() => toggleFavourite(product.id)}
              aria-label={faved ? 'Remove from favourites' : 'Add to favourites'}
            >
              <span aria-hidden>{faved ? '♥' : '♡'}</span>
              {faved ? 'In favourites' : 'Add to favourites'}
            </button>
            <button
              type="button"
              className="product-action-quote"
              onClick={() => navigate('quote', { tiles: [{ name: product.name, size: activeSize, finish: activeFinish, sqm }] })}
            >
              Request a quote <span aria-hidden className="product-action-quote-arrow">→</span>
            </button>
          </div>
        </div>
      </section>

      {/* ── 7. SUGGESTIONS — single prominent title, then row layout
              identical to the listing page (same RowCard + same
              tile-grid.view-row class chain). Paginated: starts at 6
              tiles, "Show more" reveals the next batch each click. ─── */}
      {suggestions.length > 0 && (
        <section className="product-suggestions">
          <h2 className="product-suggestions-title">
            You might <em>also like</em>
          </h2>
          <div className="tile-grid view-row product-suggestions-grid">
            {visibleSuggestions.map((s, i) => (
              <RowCard key={s.id} tile={s} navigate={navigate} index={i}/>
            ))}
          </div>
          {hasMoreSuggestions && (
            <div className="product-suggestions-more">
              <p className="product-suggestions-more-count">
                Showing {visibleSuggestions.length} of {suggestions.length}
              </p>
              <button
                type="button"
                onClick={() => setSuggestionsCount(c => c + SUGGESTIONS_PAGE_SIZE)}
                className="product-suggestions-more-btn"
              >
                Show {Math.min(SUGGESTIONS_PAGE_SIZE, suggestions.length - suggestionsCount)} more
              </button>
            </div>
          )}
        </section>
      )}

      {/* ── Sticky mobile CTA bar ─────────────────────────────────
          Always-visible action bar at the bottom of the viewport on
          mobile — the canonical e-commerce pattern. Favourite toggle
          on the left, primary "Request a quote" button on the right.
          Adds bottom padding to the page below so content isn't
          hidden behind the bar. */}
      {isProductMobile && (
        <>
          <div aria-hidden style={{ height: '76px' }}/>
          <div className="product-mobile-cta" style={{
            position: 'fixed',
            left: 0, right: 0, bottom: 0,
            zIndex: 90,
            display: 'flex', alignItems: 'center', gap: '10px',
            padding: '10px 16px calc(10px + env(safe-area-inset-bottom, 0px))',
            background: 'rgba(255,255,255,0.96)',
            backdropFilter: 'saturate(140%) blur(14px)',
            WebkitBackdropFilter: 'saturate(140%) blur(14px)',
            borderTop: '1px solid var(--cream-deep)',
          }}>
            <button
              type="button"
              onClick={() => toggleFavourite(product.id)}
              aria-label={faved ? 'Remove from favourites' : 'Add to favourites'}
              style={{
                width: '52px', height: '52px',
                background: 'white',
                border: `1px solid ${faved ? 'var(--terracotta)' : 'var(--cream-deep)'}`,
                color:  faved ? 'var(--terracotta)' : 'var(--dark)',
                fontSize: '20px', cursor: 'pointer',
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                flexShrink: 0,
              }}
            >{faved ? '♥' : '♡'}</button>
            <button
              type="button"
              onClick={() => navigate('quote', { tiles: [{ name: product.name, size: activeSize, finish: activeFinish, sqm: 10 }] })}
              style={{
                flex: 1, height: '52px',
                background: 'var(--dark)', color: 'var(--cream)',
                border: 'none', cursor: 'pointer',
                fontFamily: 'var(--sans)', fontSize: '12px', fontWeight: 500,
                letterSpacing: '0.2em', textTransform: 'uppercase',
              }}
            >Request a quote</button>
          </div>
        </>
      )}

      <style>{`
        .product-page { background: white; }

        /* ── 1. HERO ────────────────────────────────────────────── */
        .product-hero {
          position: relative;
          height: 100vh;
          min-height: 640px;
          overflow: hidden;
          /* Subtle dark vignette so a letter-boxed or partially-loaded
             sight image still reads as a lit ceramic surface — never
             a flat cream rectangle when the photo doesn't cover. */
          background: radial-gradient(110% 90% at 50% 30%, #2A2825 0%, #14130F 70%, #0A0908 100%);
        }
        .product-hero::after {
          /* Inset shadow halo. Sits above the image stack (z-index 1
             below the chrome at z-index 4) so it darkens the rim of
             the sight image without dimming the centre. */
          content: '';
          position: absolute;
          inset: 0;
          pointer-events: none;
          box-shadow: inset 0 0 120px 20px rgba(0,0,0,0.45);
          z-index: 1;
        }
        /* Stack of layered sight images. The container holds them all at
           full-bleed; only the active one has opacity:1, the rest fade
           via inline style. Breathing scale animation lives on the
           container so it doesn't reset on every slide change. */
        .product-hero-stack {
          position: absolute;
          inset: 0;
          z-index: 0;
          animation: heroBreathe 30s ease-in-out infinite alternate;
        }
        .product-hero-bg {
          position: absolute;
          inset: 0;
          width: 100%;
          height: 100%;
          object-fit: cover;
          /* Images are decorative — never absorb pointer events,
             otherwise the nav zones below the stack lose hover. */
          pointer-events: none;
        }
        @keyframes heroBreathe {
          from { transform: scale(1);    }
          to   { transform: scale(1.04); }
        }
        .product-hero-overlay {
          position: absolute;
          inset: 0;
          z-index: 1;
          background: linear-gradient(180deg, rgba(0,0,0,0.18) 0%, rgba(0,0,0,0.04) 30%, rgba(0,0,0,0.04) 60%, rgba(0,0,0,0.45) 100%);
          pointer-events: none;
        }
        /* Hero left/right click zones — 45% wide each, with a 10% safe
           gap in the middle. Cursor.jsx watches data-hero-nav and swaps
           in a chevron arrow over these zones (same cursor as on the
           home page). z-index 2 keeps them above the image and gradient
           but below the content (z-index 3) so the title + scroll cue
           stay clickable. */
        .product-hero-nav {
          position: absolute;
          top: var(--nav-h);
          bottom: 0;
          width: 45%;
          /* Above the content layer so hover reliably hits the nav
             zone and Cursor.jsx can swap in the chevron arrow. */
          z-index: 4;
          background: transparent;
          border: none;
          padding: 0;
        }
        .product-hero-nav-left  { left: 0; }
        .product-hero-nav-right { right: 0; }
        /* Slide-position pips — small dots pinned to the very bottom
           of the hero, BELOW the scroll cue (which spans 36px–118px
           from the bottom). Sitting at bottom:14px keeps them clear
           of the scroll cue's vertical line and the scroll text. */
        .product-hero-pips {
          position: absolute;
          left: 50%;
          bottom: 14px;
          transform: translateX(-50%);
          z-index: 5;
          display: flex;
          gap: 8px;
          padding: 4px 10px;
        }
        .product-hero-pip {
          width: 5px;
          height: 5px;
          border-radius: 999px;
          background: rgba(255,255,255,0.45);
          border: none;
          padding: 0;
          cursor: pointer;
          transition: width 0.4s var(--ease-out), background 0.4s var(--ease-out);
        }
        .product-hero-pip.is-active {
          width: 18px;
          background: rgba(255,255,255,0.95);
        }
        .product-back {
          position: absolute;
          top: calc(var(--nav-h) + 28px);
          left: 40px;
          z-index: 3;
          background: rgba(255,255,255,0.86);
          backdrop-filter: blur(12px);
          -webkit-backdrop-filter: blur(12px);
          border: 1px solid rgba(255,255,255,0.6);
          padding: 9px 18px;
          font-family: var(--sans);
          font-size: 11px;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          color: var(--dark);
          cursor: pointer;
          transition: background 0.25s, transform 0.25s;
        }
        .product-back:hover { background: white; transform: translateX(-2px); }

        .product-hero-content {
          position: absolute;
          inset: 0;
          z-index: 3;
          display: flex;
          align-items: center;
          justify-content: center;
          color: white;
          animation: heroIn 1.1s var(--ease-out) 0.1s both;
          /* Critical: this layer fills the whole hero, but it must NOT
             intercept hover so the chevron-cursor nav zones below
             receive pointer events. Children that need clicks
             (none today, but futureproof) can opt back in with
             pointer-events: auto. */
          pointer-events: none;
        }
        /* Inner wrapper mirrors .product-section's 1180px max-width
           with the same 40px horizontal padding, so the hero title
           sits on the exact same vertical centre line as every
           section heading below. */
        .product-hero-content-inner {
          width: 100%;
          max-width: 1180px;
          padding: 0 40px;
          margin: 0 auto;
          text-align: center;
        }
        @keyframes heroIn {
          from { opacity: 0; transform: translateY(14px); }
          to   { opacity: 1; transform: translateY(0);    }
        }
        .product-hero-eyebrow {
          font-family: var(--sans);
          font-size: 11px;
          font-weight: 500;
          letter-spacing: 0.32em;
          text-transform: uppercase;
          color: rgba(255,255,255,0.82);
          margin: 0 0 26px;
        }
        .product-hero-title {
          font-family: var(--serif);
          font-style: italic;
          font-weight: 300;
          font-size: clamp(56px, 9vw, 130px);
          line-height: 0.96;
          /* Loosened from -0.025em to -0.012em so italic terminals
             don't crowd each other on long tile names. */
          letter-spacing: -0.012em;
          color: white;
          margin: 0;
          /* Small horizontal gutter so the rightmost italic glyph's
             optical overhang (the slanted right-side stroke on
             letters like M, R, K) isn't clipped by the centred
             container's edge. Left padding balances the right so
             the text stays optically centred. */
          padding: 0 0.08em;
          text-shadow: 0 2px 30px rgba(0,0,0,0.28);
        }
        .product-hero-collection {
          font-family: var(--sans);
          font-size: 12px;
          letter-spacing: 0.22em;
          text-transform: uppercase;
          color: rgba(255,255,255,0.78);
          margin: 24px 0 0;
        }
        .product-hero-description {
          font-family: var(--serif);
          font-style: italic;
          font-weight: 300;
          font-size: clamp(14px, 1.2vw, 17px);
          line-height: 1.55;
          color: rgba(255,255,255,0.85);
          max-width: 580px;
          margin: 22px auto 0;
          text-shadow: 0 1px 8px rgba(0,0,0,0.18);
        }

        /* Scroll cue — small "Scroll" label + a vertical line with a
           white segment travelling downward. Container spans full
           width (left:0; right:0) and uses flex to centre its
           children — more reliable than left:50% + translateX, which
           can drift by a sub-pixel on some browsers. */
        .product-hero-scroll {
          position: absolute;
          bottom: 36px;
          left: 0;
          right: 0;
          z-index: 2;
          display: flex;
          flex-direction: column;
          align-items: center;
          gap: 14px;
          color: rgba(255,255,255,0.78);
          animation: heroIn 1.1s var(--ease-out) 0.5s both;
          pointer-events: none;
        }
        .product-hero-scroll span {
          font-family: var(--sans);
          font-size: 10px;
          letter-spacing: 0.32em;
          text-transform: uppercase;
        }
        .product-hero-scroll-line {
          position: relative;
          width: 1px;
          height: 56px;
          background: rgba(255,255,255,0.22);
          overflow: hidden;
        }
        .product-hero-scroll-line::after {
          content: '';
          position: absolute;
          left: 0; right: 0;
          top: -50%;
          height: 50%;
          background: white;
          animation: scrollCueTravel 2.2s ease-in-out infinite;
        }
        @keyframes scrollCueTravel {
          0%   { top: -50%; opacity: 0; }
          20%  { opacity: 1; }
          80%  { opacity: 1; }
          100% { top: 100%;  opacity: 0; }
        }

        /* ── Generic section chrome ───────────────────────────────── */
        /* Outer wrapper used to position the full-page hairline. The
           separator can't live on .product-section itself because
           sections have a max-width; the hairline needs to span the
           viewport. So each section is wrapped (via the ::before of a
           full-width band) with a hairline that runs edge to edge.

           Section padding: 56px above the eyebrow + 36px below the
           previous section's content gives the line room to breathe
           on both sides. A small 14px terracotta tick sits in the
           middle of the line as a visual mark, like a chapter break
           in a magazine. */
        .product-section,
        .product-suggestions {
          position: relative;
        }
        .product-section {
          /* padding-top - padding-bottom = 60 keeps section content
             vertically centred between the separator line above
             (this section's ::before at top:30) and the line below
             (next section's ::before at top:30). With 80/20, content
             has ~50px of breathing room on each side. */
          padding: 80px 40px 20px;
          max-width: 1180px;
          margin: 0 auto;
          text-align: center;
          display: flex;
          flex-direction: column;
          align-items: center;
        }
        /* Full-page hairline + centred terracotta tick. Width: 100vw
           via translateX(-50%) so the line escapes the 1180px max-
           width and runs edge to edge. The ::after holds a 14px
           terracotta segment that sits ON the line at its centre. */
        .product-section::before,
        .product-suggestions::before {
          content: '';
          position: absolute;
          left: 50%; top: 30px;
          transform: translateX(-50%);
          width: 100vw;
          height: 1px;
          background: var(--cream-deep);
          pointer-events: none;
        }
        .product-section::after,
        .product-suggestions::after {
          content: '';
          position: absolute;
          left: 50%; top: 30px;
          transform: translate(-50%, -50%);
          width: 14px; height: 1px;
          background: var(--terracotta);
          pointer-events: none;
        }
        /* The first section after the hero (gallery) doesn't need a
           separator above it — the hero's own bottom edge does that work.
           Reduced padding-top so the gallery sits closer to the hero. */
        .product-hero + .product-section::before,
        .product-hero + .product-section::after { display: none; }
        .product-hero + .product-section { padding-top: 32px; }

        .product-section-eyebrow {
          font-family: var(--sans);
          font-size: 10px;
          font-weight: 500;
          letter-spacing: 0.3em;
          text-transform: uppercase;
          color: var(--terracotta);
          margin: 0 0 8px;
        }
        .product-section-title {
          font-family: var(--serif);
          font-style: italic;
          font-weight: 300;
          font-size: clamp(24px, 3vw, 38px);
          line-height: 1.02;
          letter-spacing: -0.02em;
          color: var(--dark);
          margin: 0 0 22px;
        }
        .product-section-title em {
          color: var(--terracotta);
        }

        /* ── 2. GALLERY — centred flex row. Any number of detail shots
              centre cleanly (no empty middle slot), wrapping to a new
              row past five. Each cell is a fixed fraction of the row so
              five sit side-by-side at full width. */
        .product-gallery {
          display: flex;
          flex-wrap: wrap;
          justify-content: center;
          gap: 14px;
          max-width: 1280px;
          margin: 0 auto;
        }
        .product-gallery-cell {
          position: relative;
          width: calc((100% - 4 * 14px) / 5);  /* up to 5 per row */
          aspect-ratio: 1;
          overflow: hidden;
          background: #ffffff;
          transition: transform 0.5s var(--ease-out);
        }
        /* Detail shots fill their square cell (cover) in natural
           orientation — no rotation — so square-format tiles read as
           square and rectangular ones are centre-cropped to match. */
        .product-gallery-cell img {
          width: 100%; height: 100%;
          object-fit: cover; display: block;
          transition: transform 0.7s var(--ease-out);
        }
        .product-gallery-cell:hover img { transform: scale(1.04); }

        /* ── 3. SPECIFICATION — two halves split by a centre rule.
              The grid is wide (1180px max) so each half can use the
              outer screen space. Content anchors at the centre line
              and grows outward — Size on the left, Finish on the right. */
        .product-spec-row {
          display: grid;
          grid-template-columns: 1fr 1fr;
          column-gap: 64px;
          width: 100%;
          max-width: 1180px;
          margin: 0 auto;
          align-items: start;
          position: relative;
        }
        /* The visible centre line. Height set in JS height-equivalent
           via padding-top; vertically centred at 30px down from the
           grid's top so it sits just below the section title. */
        .product-spec-row::before {
          content: '';
          position: absolute;
          top: 8px; bottom: 8px;
          left: 50%;
          width: 1px;
          background: var(--cream-deep);
          transform: translateX(-50%);
        }

        /* Each half is left-aligned text in its own column. The LEFT
           half pushes its content to the right (so it kisses the
           centre line); the RIGHT half pushes its content to the left
           (same — kisses the centre from the other side). Net effect:
           the controls cluster around the centre and let the wide
           outer canvas breathe. */
        .product-spec-half {
          display: flex;
          flex-direction: column;
          gap: 10px;
          min-width: 0;
        }
        /* Size half — sits on the LEFT side of the spec row,
           anchored at the centre line on its RIGHT edge, content
           extends leftward. */
        .product-spec-size {
          align-items: flex-end;
          text-align: right;
          padding-right: 24px;
          padding-left: 0;
        }
        /* Finish half — sits on the RIGHT side of the spec row,
           anchored at the centre line on its LEFT edge, content
           extends rightward. */
        .product-spec-finish {
          align-items: flex-start;
          text-align: left;
          padding-left: 24px;
          padding-right: 0;
        }
        .product-spec-fallback {
          font-family: var(--serif);
          font-size: 16px;
          font-style: italic;
          color: var(--dark);
          margin: 0;
          padding: 9px 0;
        }

        /* SIZE — shape-grouped boxes. One slim box per shape;
           dimensions are PLAIN text inside (no inner button look —
           click to select, active state is just bold colour). The
           size half is right-aligned (kisses the centre divider on
           its right edge), so we cluster boxes to the right with
           justify-content: flex-end. Source order is square first
           (sort), but we use row-reverse so the square ends up at
           the RIGHT of the row — i.e. nearest the centre divider —
           and rectangles extend leftward (away from the centre). */
        .product-size-groups {
          display: flex;
          flex-direction: row-reverse;
          flex-wrap: wrap;
          gap: 16px;
          justify-content: flex-start;    /* with row-reverse, flex-start is
                                             the RIGHT side of the row */
          align-items: flex-end;          /* rectangles sit on a shared floor
                                             alongside the square */
        }
        .product-size-shape-box {
          /* Long edge fixed at 130px → box width = 130, height = 130/ratio.
             Smaller than the previous 200px so the row reads as a
             compact spec, not a hero. */
          width: 130px;
          min-height: 60px;
          border: 1px solid var(--dark);
          background: transparent;
          display: flex;
          align-items: center;
          justify-content: center;
          padding: 6px 8px;
          box-sizing: border-box;
        }
        .product-size-shape-list {
          list-style: none;
          margin: 0;
          padding: 0;
          display: flex;
          flex-direction: column;
          align-items: center;
          justify-content: center;
          gap: 2px;
          width: 100%;
        }
        /* Plain text — NOT a pill / chip / inner box. The active size
           is signalled with weight + a subtle terracotta accent so the
           selection is obvious without nesting a box inside the box. */
        .product-size-pill {
          background: transparent;
          border: none;
          padding: 0;
          font-family: var(--serif);
          font-size: 13px;
          font-style: italic;
          letter-spacing: -0.005em;
          color: var(--dark);
          cursor: pointer;
          transition: color 0.2s;
          line-height: 1.25;
        }
        .product-size-pill:hover { color: var(--terracotta); }
        .product-size-pill.is-active {
          color: var(--terracotta);
          font-weight: 500;
        }
        @media (max-width: 760px) {
          .product-size-groups { justify-content: flex-start; align-items: flex-start; }
        }

        /* Finish pills — each carries a small SVG finish-icon BEFORE
           the name. Wrap if many; active pill filled in dark. */
        .product-finish-pills {
          display: flex;
          flex-wrap: wrap;
          gap: 8px;
        }
        .product-finish-pill {
          display: inline-flex;
          align-items: center;
          gap: 8px;
          background: white;
          border: 1px solid var(--cream-deep);
          padding: 7px 14px 7px 10px;
          font-family: var(--sans);
          font-size: 11px;
          letter-spacing: 0.04em;
          color: var(--dark);
          cursor: pointer;
          transition: background 0.2s, border-color 0.2s, color 0.2s;
        }
        .product-finish-pill:hover { border-color: var(--dark); }
        .product-finish-pill.is-active {
          background: var(--dark);
          color: white;
          border-color: var(--dark);
        }
        .product-finish-pill svg {
          flex: 0 0 auto;
        }
        .product-control-label {
          display: block;
          font-family: var(--sans);
          font-size: 9px;
          font-weight: 500;
          letter-spacing: 0.22em;
          text-transform: uppercase;
          color: var(--dark-mid);
          margin: 0 0 8px;
        }
        .product-size-select-wrap {
          display: block;
          cursor: pointer;
        }
        .product-select-large {
          width: 100%;
          background-color: white;
          border: 1px solid var(--cream-deep);
          padding: 10px 32px 10px 14px;
          font-family: var(--serif);
          font-size: 16px;
          font-weight: 400;
          color: var(--dark);
          cursor: pointer;
          outline: none;
          appearance: none;
          -webkit-appearance: none;
          -moz-appearance: none;
          background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6' fill='none'><path d='M1 1l4 4 4-4' stroke='%23111' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'/></svg>");
          background-repeat: no-repeat;
          background-position: right 12px center;
          background-size: 10px 6px;
          transition: border-color 0.25s;
        }
        .product-select-large:hover, .product-select-large:focus { border-color: var(--dark); }

        .product-calc-large-row {
          display: flex;
          align-items: center;
          gap: 6px;
        }
        .product-calc-large-row button {
          width: 30px; height: 36px;
          background: white;
          border: 1px solid var(--cream-deep);
          font-family: var(--sans);
          font-size: 14px;
          color: var(--dark);
          cursor: pointer;
          transition: border-color 0.2s;
          line-height: 1;
        }
        .product-calc-large-row button:hover { border-color: var(--dark); }
        .product-calc-large-row input {
          flex: 1;
          min-width: 0;
          background: white;
          border: 1px solid var(--cream-deep);
          padding: 7px 10px;
          font-family: var(--serif);
          font-size: 16px;
          font-weight: 400;
          text-align: center;
          color: var(--dark);
          outline: none;
          -moz-appearance: textfield;
          height: 36px;
        }
        .product-calc-large-row input::-webkit-inner-spin-button,
        .product-calc-large-row input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
        .product-calc-large-unit {
          font-family: var(--sans);
          font-size: 13px;
          color: var(--dark-mid);
          padding: 0 4px;
        }
        .product-calc-large-result {
          margin: 8px 0 0;
          font-family: var(--sans);
          font-size: 11px;
          letter-spacing: 0.05em;
          color: var(--dark-mid);
        }

        /* ── 4. COLOURS — flex-centred wrap so a single colour
              sits in the middle and multiple colours stay balanced
              around the centre line, growing outwards on both ends
              as more get added. */
        .product-colours-grid {
          display: flex;
          flex-wrap: wrap;
          justify-content: center;
          /* align-items: flex-start so every cell sits at the TOP of
             its row regardless of how tall the row's tallest cell is.
             Without this, default stretch + the Current cell's extra
             "Current" tag pushes sibling thumbs down to the middle of
             a now-taller row (Firefox button defaults make this worse). */
          align-items: flex-start;
          gap: 14px 10px;
          max-width: 880px;
          margin: 0 auto;
          text-align: center;
        }
        .product-colour-cell {
          width: 120px;   /* fixed cell width so wrapping is predictable */
          flex: 0 0 auto;
          /* Force a consistent vertical stack inside every cell
             (div + button alike) so the thumb is always the first
             child rendered at the top — overrides Firefox's native
             inline-flex centring on <button> elements. */
          display: flex;
          flex-direction: column;
          align-items: stretch;
        }
        .product-colour-cell {
          background: none;
          border: none;
          padding: 0;
          cursor: pointer;
          text-align: center;
          transition: transform 0.4s var(--ease-out);
        }
        .product-colour-cell:not(.is-current):hover { transform: translateY(-3px); }
        .product-colour-cell-thumb {
          aspect-ratio: 1;
          overflow: hidden;
          background: #ffffff;
          margin-bottom: 8px;
        }
        /* Colour cell images fill their square cell (cover) in natural
           orientation so square-format tiles read square. */
        .product-colour-cell-thumb img {
          width: 100%; height: 100%;
          object-fit: cover; display: block;
          transition: transform 0.7s var(--ease-out);
        }
        .product-colour-cell:hover .product-colour-cell-thumb img { transform: scale(1.06); }
        /* "Current" cell highlight — drawn INSIDE the thumb edge as
           an inset box-shadow so it never escapes the layout box.
           Using outline + outline-offset previously made the ring
           extend 4px above/below the thumb, pushing the visual top
           of the current cell higher than its siblings on the row. */
        .product-colour-cell.is-current .product-colour-cell-thumb {
          box-shadow: inset 0 0 0 2px var(--dark);
        }
        .product-colour-cell-name {
          font-family: var(--serif);
          font-size: 14px;
          font-style: italic;
          color: var(--dark);
          letter-spacing: -0.005em;
          margin: 0;
        }
        .product-colour-cell-tag {
          font-family: var(--sans);
          font-size: 9px;
          letter-spacing: 0.22em;
          text-transform: uppercase;
          color: var(--terracotta);
          margin: 3px 0 0;
        }

        /* ── 5. FINISHES — pill row ───────────────────────────────── */
        .product-finishes-row {
          display: inline-flex;
          flex-wrap: wrap;
          gap: 8px;
          justify-content: center;
        }
        .product-finish-pill {
          background: white;
          border: 1px solid var(--cream-deep);
          padding: 10px 22px;
          font-family: var(--serif);
          font-size: 15px;
          font-weight: 400;
          color: var(--dark);
          letter-spacing: -0.005em;
          cursor: pointer;
          transition: background 0.25s, color 0.25s, border-color 0.25s;
        }
        .product-finish-pill:hover { border-color: var(--dark); }
        .product-finish-pill.is-active {
          background: var(--dark);
          color: white;
          border-color: var(--dark);
        }

        /* ── 6. ACTIONS — calculator + ♡ + Request a quote, stacked. */
        .product-actions-block {
          display: flex;
          flex-direction: column;
          align-items: center;
          gap: 14px;
          width: 100%;
          max-width: 360px;
          margin: 0 auto;
        }
        .product-actions-block .product-control-label {
          margin: 0;
        }
        .product-actions-block .product-calc-large-row {
          width: 100%;
        }
        .product-actions-block .product-calc-large-result {
          margin: 0 0 8px;
        }
        .product-actions-row {
          display: inline-flex;
          align-items: center;
          gap: 10px;
        }
        .product-action-fav {
          background: white;
          border: 1px solid var(--cream-deep);
          padding: 11px 18px;
          font-family: var(--sans);
          font-size: 10px;
          font-weight: 500;
          letter-spacing: 0.2em;
          text-transform: uppercase;
          color: var(--dark);
          cursor: pointer;
          display: inline-flex;
          align-items: center;
          gap: 8px;
          transition: border-color 0.25s, color 0.25s;
        }
        .product-action-fav:hover, .product-action-fav.is-faved {
          border-color: var(--terracotta);
          color: var(--terracotta);
        }
        .product-action-fav span { font-size: 13px; }
        .product-action-quote {
          background: var(--dark);
          color: white;
          border: none;
          padding: 11px 22px;
          font-family: var(--sans);
          font-size: 10px;
          font-weight: 500;
          letter-spacing: 0.2em;
          text-transform: uppercase;
          cursor: pointer;
          display: inline-flex;
          align-items: center;
          gap: 8px;
          transition: background 0.25s;
        }
        .product-action-quote:hover { background: var(--terracotta); }
        .product-action-quote-arrow {
          display: inline-block;
          transition: transform 0.25s var(--ease-out);
        }
        .product-action-quote:hover .product-action-quote-arrow { transform: translateX(4px); }

        /* ── 7. SUGGESTIONS — full-page separator at top, prominent
              centred title, then the listing's row-card layout. Same
              top-padding rhythm as .product-section (80/20 to centre
              content between lines) but with extra bottom-padding for
              clean page-end breathing room since this is the last
              section. */
        .product-suggestions {
          position: relative;
          padding: 80px 40px 90px;
          margin: 0 auto;
          text-align: center;
        }
        .product-suggestions-title {
          font-family: var(--serif);
          font-style: italic;
          font-weight: 300;
          font-size: clamp(28px, 3.6vw, 44px);
          line-height: 1.02;
          letter-spacing: -0.02em;
          color: var(--dark);
          margin: 0 auto 32px;
          max-width: 1180px;
          text-align: center;
        }
        .product-suggestions-title em {
          color: var(--terracotta);
        }
        .product-suggestions-grid {
          /* exact same row-card grid the listing uses. The class chain
             tile-grid + view-row does the work; this is just a scope. */
          max-width: 1280px;
          margin: 0 auto;
        }
        .product-suggestions-more {
          display: flex;
          flex-direction: column;
          align-items: center;
          gap: 10px;
          padding: 40px 0 0;
        }
        .product-suggestions-more-count {
          font-family: var(--sans);
          font-size: 11px;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          color: var(--dark-mid);
          margin: 0;
        }
        .product-suggestions-more-btn {
          background: var(--dark);
          color: var(--cream);
          border: 1px solid var(--dark);
          cursor: pointer;
          padding: 14px 32px;
          font-family: var(--sans);
          font-size: 11px;
          font-weight: 500;
          letter-spacing: 0.22em;
          text-transform: uppercase;
          transition: all 0.2s;
        }
        .product-suggestions-more-btn:hover {
          background: var(--terracotta);
          border-color: var(--terracotta);
        }

        /* ── Responsive ───────────────────────────────────────────── */
        @media (max-width: 860px) {
          .product-section { padding: 60px 24px 16px; }
          .product-spec-split { grid-template-columns: 1fr; gap: 28px; }
          .product-gallery { gap: 8px; }
          /* Spec row — drop the centre line, stack the two halves so
             SIZE sits above FINISH. Both halves go left-aligned and
             span the full container width on mobile. */
          .product-spec-row { grid-template-columns: 1fr; column-gap: 0; row-gap: 32px; }
          .product-spec-row::before { display: none; }
          .product-spec-size,
          .product-spec-finish {
            align-items: flex-start !important;
            text-align: left !important;
            padding-left: 0 !important;
            padding-right: 0 !important;
          }
          .product-size-groups { justify-content: flex-start !important; align-items: flex-start !important; }
          /* Suggestions: tighter horizontal padding */
          .product-suggestions { padding: 60px 24px 80px; }
          .product-suggestions-title { font-size: clamp(26px, 5vw, 40px); margin-bottom: 24px; }
        }
        @media (max-width: 600px) {
          .product-hero { height: auto; min-height: 70vh; }
          .product-back { left: 16px; top: calc(var(--nav-h) + 16px); }
          .product-hero-eyebrow { margin-bottom: 16px; letter-spacing: 0.28em; }
          .product-hero-title { font-size: clamp(40px, 12vw, 72px); }
          .product-hero-description { font-size: 13px; max-width: 92vw; margin-top: 16px; }
          .product-hero-collection { font-size: 11px; margin-top: 18px; }
          .product-hero-content-inner { padding: 0 20px; }
          .product-section { padding: 40px 16px 0; }
          .product-section + .product-section { padding-top: 32px; }
          .product-section-title { font-size: clamp(22px, 6vw, 30px); margin-bottom: 22px; }
          .product-section-eyebrow { margin-bottom: 10px; }
          /* Phone: 3 cells per row, still centred via flex. */
          .product-gallery { gap: 6px; }
          .product-gallery-cell { width: calc((100% - 2 * 6px) / 3); }
          .product-suggestions { padding: 40px 16px 60px; }
          .product-actions-row { flex-direction: column; gap: 8px; align-items: stretch; }
          /* Colour cells slightly smaller so 3 per row fits a 360-380px viewport */
          .product-colour-cell { width: calc((100% - 24px) / 3); min-width: 0; }
          .product-colour-cell-name { font-size: 12px; }
          .product-colours-grid { gap: 12px 8px; padding: 0 4px; }
          /* Size shape-boxes shrink for narrow viewports */
          .product-size-shape-box { width: 110px; min-height: 52px; }
          /* Hero pips lift slightly above the scroll cue's slim line */
          .product-hero-pips { bottom: 10px; }
        }
        @media (max-width: 380px) {
          /* Very narrow phones — 2 colour cells per row */
          .product-colour-cell { width: calc((100% - 12px) / 2); }
          .product-colours-grid { gap: 14px 12px; }
          .product-hero-title { font-size: clamp(34px, 13vw, 60px); }
        }
        /* ── Mobile touch targets (Apple HIG / Material both
           recommend ≥44px). Size and finish pills become larger and
           easier to tap. Calculator + quote actions also scale up. */
        @media (max-width: 760px) {
          .product-size-pill {
            font-size: 15px;
            padding: 8px 12px;
            min-height: 36px;
          }
          .product-size-shape-box {
            width: 130px;
            min-height: 70px;
            padding: 10px;
          }
          .product-finish-pill {
            padding: 11px 16px 11px 12px;
            font-size: 12px;
            min-height: 42px;
          }
          .product-select-large {
            padding: 12px 14px;
            font-size: 15px;
          }
          .product-calc-large-row button,
          .product-calc-large-row input {
            min-height: 44px;
            font-size: 15px;
          }
          /* The full-page section dividers are visually noisy on a
             phone where everything reads as a stack already. Hide
             them so the page flows as a single column without lines
             cutting across each section. */
          .product-section::before,
          .product-suggestions::before { display: none; }
        }
        /* The sticky mobile CTA needs the body to leave space at
           the bottom so the last section isn't hidden behind it.
           The aria-hidden 76px spacer in the JSX handles this. */
      `}</style>
    </div>
  );
}

Object.assign(window, {
  // New three-tier journey
  CollectionsHub, CollectionsAxis, CollectionDetail,
  // Legacy aliases — anything still saying CollectionsList gets the Hub
  CollectionsList,
  // Shared
  Product, TileCard,
  // Listing-page card variants + view switcher (exposed for debugging
  // / future reuse from other surfaces, e.g. favourites page).
  RowCard, GridCard, CompactCard, ViewSwitcher,
  // Single source of truth for the browse axes — read by Nav so the
  // mega-menu and the sidebar stay in sync. Nav imports these at render.
  AXES,
  // Exposed for Phone.jsx — phone components reuse the same filter
  // logic without duplicating it.
  SIDEBAR_SECTIONS,
  applySidebarFn: applySidebar,
  filterTilesFn:  filterTiles,
  tileHasFn:      tileHas,
  resolveFilter,
  useAllTilesHook: useAllTiles,
  useCollectionsMetaHook: useCollectionsMeta,
});

