// Search results — card grid (no operator grouping), with Grid / Map view toggle.

const PRE_ORDER_LABELS = {
  none: null,
  optional:       { short: 'Pre-order available',            title: 'This venue publishes a menu — you can add guest selections during booking.' },
  menu:           { short: 'Menu pre-order required',         title: 'Guests must choose menu items before this booking can be submitted.' },
  package:        { short: 'Package required',                title: 'A package must be added to this booking before it can be submitted.' },
  packageAndMenu: { short: 'Pre-order required', title: 'Either a package or menu selections must be added before this booking can be submitted.' },
};

function ResultsScreen({ query, setQuery, onSearch, onBook, onBack, onPickSuggestion, searching = false, searchError = null }) {
  const [view, setView] = React.useState('grid');

  // The results list + cards render from the COMMITTED `query`. The sticky
  // search bar edits a local draft instead, so editing date / guests /
  // product / operator does NOT re-run the search — nothing changes until the
  // partner presses the search icon (onSubmit commits the draft). Re-sync the
  // draft whenever the committed query changes from outside (e.g. an omni
  // suggestion pick, which is an explicit commit).
  const [draft, setDraft] = React.useState(query);
  React.useEffect(() => { setDraft(query); }, [query]);

  const text = (query.text || '').trim().toLowerCase();
  const vibe = (query.vibe || '').trim().toLowerCase();
  const area = (query.area || '').trim().toLowerCase();
  const near = query.near;

  // Build the filter as two passes:
  //   1. Hard pass — city, operator, productType, near-radius, area substring.
  //   2. Soft pass — apply vibe / free-text on top, but only when at least one
  //      result still matches. This stops a single fuzzy keyword wiping every
  //      otherwise-valid result (e.g. vibe="London Eye" against a Lunch
  //      catalogue that no venue's name mentions).
  // Tiered filter: hard signals (city / operator / productType / near-radius)
  // must always pass. Area / vibe / free-text are soft signals applied
  // greedily — if all soft signals together yield 0 results we fall back to
  // hard-only, so partners always see something rather than a blank page.
  const matchesHard = (v) => {
    if (query.venueId && v.id !== query.venueId) return false;
    if (query.location && query.location !== 'All locations' && v.city !== query.location) return false;
    if (query.operatorId && v.operator !== query.operatorId) return false;
    if (query.productType && !v.products.includes(query.productType)) return false;
    if (near && Number.isFinite(v.lat) && Number.isFinite(v.lng)) {
      const d = ppHaversineKm(near.lat, near.lng, v.lat, v.lng);
      if (d > (near.radiusKm || 5)) return false;
    } else if (near) {
      return false;
    }
    return true;
  };
  // Searchable text per venue. Beyond the headline fields we fold in the
  // product taxonomy names and the pre-order menu/package data (menu + item +
  // package names and descriptions) so dish-level terms like "fish and chips"
  // or "sunday roast" can match. Normalised via ppNorm so punctuation and
  // ampersands ("Fish & Chips") don't block a match.
  const norm = (typeof ppNorm === 'function')
    ? ppNorm
    : (s) => (s || '').toString().toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
  const SOFT_STOP = new Set(['and', 'the', 'with', 'for', 'a', 'an', 'of', 'in', 'on', 'at', 'to', 'that', 'does', 'do']);
  // Needle matches when every significant token appears somewhere in the hay.
  const tokenHit = (hay, needle) => {
    const toks = norm(needle).split(' ').filter(t => t && !SOFT_STOP.has(t));
    if (!toks.length) return true;
    return toks.every(t => hay.includes(t));
  };
  // Coerce arrays / object-maps / strings / missing into a flat list so a
  // mixed payload shape (e.g. productNames keyed by productId) can't throw.
  const asList = (typeof ppValues === 'function')
    ? ppValues
    : (x) => Array.isArray(x) ? x : (x && typeof x === 'object') ? Object.values(x) : (x != null && x !== '') ? [x] : [];
  const hayOf = (v) => {
    const op = operatorById(v.operator);
    const pre = v.preorders || {};
    const menuText = asList(pre.menus).flatMap(m => [
      m.name, m.description, ...(asList(m.items).flatMap(it => [it.name, it.description])),
    ]);
    const pkgText = asList(pre.packages).flatMap(p => [p.name, p.description]);
    return norm([
      v.name, v.description, v.city, v.area, op && op.name,
      ...asList(v.productNames), ...menuText, ...pkgText,
    ].filter(Boolean).join(' '));
  };
  const matchesSoft = (v) => {
    const hay = hayOf(v);
    if (area && !tokenHit(hay, area)) return false;
    if (vibe && !tokenHit(hay, vibe)) return false;
    if (text && !vibe && !near && !tokenHit(hay, text)) return false;
    return true;
  };
  const hardMatches = VENUES.filter(matchesHard);
  const softMatches = hardMatches.filter(matchesSoft);
  const filtered = softMatches.length > 0 ? softMatches : hardMatches;
  const droppedSoftSignals = softMatches.length === 0 && hardMatches.length > 0 && (area || vibe || (text && !vibe && !near));

  // Sort by distance when a near anchor is set.
  if (near) {
    filtered.sort((a, b) => ppHaversineKm(near.lat, near.lng, a.lat || 0, a.lng || 0) - ppHaversineKm(near.lat, near.lng, b.lat || 0, b.lng || 0));
  }

  // One card per venue. The card itself carries an inline product switcher
  // so the partner can pick which of the venue's products to view times for
  // — fixes "click an operator, see a random product" by surfacing every
  // product the venue offers as a chip on the card.
  const cards = filtered
    .map(v => {
      const products = (v.products || []).filter(Boolean);
      if (!products.length) return null;
      const initialProductId = (query.productType && products.includes(query.productType))
        ? query.productType
        : products[0];
      return { venue: v, products, productId: initialProductId };
    })
    .filter(Boolean);

  return (
    <div className="pp-screen-results">
      <div className="pp-results-head">
        <div className="pp-results-head-left">
          <button className="pp-ghost-btn" onClick={onBack} aria-label="Back to search">
            <IconArrowL size={14}/>
            <span>Back</span>
          </button>
          <h1 className="pp-page-title">
            {cards.length} {cards.length === 1 ? 'venue' : 'venues'}
            <span className="pp-page-title-meta">{query.searched ? ' with availability' : ' in this catalogue'}</span>
          </h1>
        </div>
        <div className="pp-results-head-right">
          <SortPicker/>
        </div>
      </div>

      <StickyResultsBar>
        <div className="pp-results-bar">
          <SearchBar value={draft} onChange={setDraft} onSubmit={() => onSearch(draft)} onPickSuggestion={onPickSuggestion} compact glow={false} />
        </div>
      </StickyResultsBar>

      {searchError && (
        <div className="pp-results-widen" role="alert" style={{ borderColor: '#c98a1d', color: '#8a5a00' }}>
          <IconSearch size={13}/>
          <span>Smart search is temporarily unavailable, so we couldn’t read your full request. Showing all venues — use the filters above to narrow down.</span>
        </div>
      )}

      {!searchError && droppedSoftSignals && (
        <div className="pp-results-widen" role="status">
          <IconSearch size={13}/>
          <span>No exact matches for <strong>{[area, vibe].filter(Boolean).join(' · ') || query.text}</strong>. Showing all results that match your filters.</span>
        </div>
      )}

      <div className="pp-view-toggle-row">
        <div className="pp-segmented" role="radiogroup" aria-label="View">
          <button role="radio" aria-checked={view === 'grid'}
                  className={"pp-segmented-btn" + (view === 'grid' ? " is-active" : "")}
                  onClick={() => setView('grid')}>
            <IconLayoutCols size={13}/><span>Grid view</span>
          </button>
          <button role="radio" aria-checked={view === 'map'}
                  className={"pp-segmented-btn" + (view === 'map' ? " is-active" : "")}
                  onClick={() => setView('map')}>
            <IconPin size={13}/><span>Map view</span>
          </button>
        </div>
        <span className="pp-results-count">Found <strong>{cards.length}</strong> {cards.length === 1 ? 'venue' : 'venues'}</span>
      </div>

      {cards.length === 0 ? (
        <EmptyResults onClear={() => setQuery({ ...query, text:'', location:'All locations', productType:null, operatorId:null })}/>
      ) : view === 'grid' ? (
        <div className="pp-card-grid">
          {cards.map(c => (
            <VenueCard key={c.venue.id}
                       venue={c.venue}
                       products={c.products}
                       defaultProductId={c.productId}
                       query={query}
                       onBook={onBook}/>
          ))}
        </div>
      ) : (
        <MapView cards={cards} query={query} onBook={onBook}/>
      )}
    </div>
  );
}

function VenueCard({ venue, products, defaultProductId, query, onBook }) {
  const [saved, setSaved] = React.useState(false);
  const [sel, setSel] = React.useState(defaultProductId);
  const [expanded, setExpanded] = React.useState(false);
  React.useEffect(() => { setSel(defaultProductId); setExpanded(false); }, [defaultProductId]);

  // Per-product minimum party size (from the Bookable weekly rules, see #60).
  // A product whose minimum exceeds the searched party size can never return
  // a slot, so we skip its availability call entirely and tell the partner the
  // minimum instead of showing a misleading "no times".
  const minByProduct = {};
  (venue.rawProducts || []).forEach(rp => {
    const n = Number(rp.minPartySize);
    if (rp.portalProduct && n > 0) minByProduct[rp.portalProduct] = n;
  });
  const belowMinFor = (pid) => minByProduct[pid] > 0 && query.guests < minByProduct[pid];
  const eligible = products.filter(pid => !belowMinFor(pid));

  const byProduct = useAllProductsAvailability(venue, eligible, query.date, query.guests, query.searched);
  const productId = sel || defaultProductId;
  const belowMin = belowMinFor(productId) ? minByProduct[productId] : null;
  const slots = byProduct[productId];
  const loading = query.searched && !belowMin && (slots === null || slots === undefined);
  const product = productById(productId) || { name: (venue.productNames && venue.productNames[productId]) || productId };
  const G = PRODUCT_GLYPHS[productId] || PRODUCT_GLYPHS.dinner;
  const priceList = (slots || []).map(s => s.price).filter(p => typeof p === 'number' && p > 0);
  const minPrice = priceList.length ? Math.min(...priceList) : null;
  const rawProduct = (venue.rawProducts || []).find(rp => rp.portalProduct === productId);
  // Pre-order menus/packages live at the venue level but each product
  // publishes an allow-list (preorderMenuIds / preorderPackageIds). Only
  // surface the badge when this product is actually nested onto something.
  const allowedMenuIds = (rawProduct && rawProduct.preorderMenuIds) || [];
  const allowedPackageIds = (rawProduct && rawProduct.preorderPackageIds) || [];
  const venueMenus = (venue.preorders && venue.preorders.menus) || [];
  const venuePackages = (venue.preorders && venue.preorders.packages) || [];
  const productMenus = allowedMenuIds.length
    ? venueMenus.filter(m => allowedMenuIds.includes(String(m.id)) || (m.slug && allowedMenuIds.includes(m.slug)))
    : [];
  const productPackages = allowedPackageIds.length
    ? venuePackages.filter(p => allowedPackageIds.includes(String(p.id)) || (p.slug && allowedPackageIds.includes(p.slug)))
    : [];
  const hasPreorderCatalogue = productMenus.length > 0 || productPackages.length > 0;
  const preOrderRequired = !!(rawProduct && rawProduct.preOrderRequired);
  const preOrderType = preOrderRequired ? (rawProduct.preOrderRequiredType || 'menu')
                     : hasPreorderCatalogue ? 'optional'
                     : 'none';
  const preOrderLabel = PRE_ORDER_LABELS[preOrderType];
  const minParty = rawProduct && Number(rawProduct.minPartySize) > 0 ? Number(rawProduct.minPartySize) : null;
  return (
    <article className="pp-vcard">
      <div className={"pp-vcard-cover pp-cover--" + (venue.tone || 'sage') + (venue.photos && venue.photos[0] ? ' has-photo' : '')}>
        {venue.photos && venue.photos[0] && (
          <img className="pp-vcard-cover-img"
               src={venue.photos[0]}
               alt=""
               decoding="async"
               fetchPriority="high"
               onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.parentNode.classList.remove('has-photo'); }}/>
        )}
        <button className={"pp-vcard-heart" + (saved ? " is-on" : "")} onClick={(e) => { e.stopPropagation(); setSaved(s => !s); }}
                aria-label={saved ? 'Unsave' : 'Save'} aria-pressed={saved}>
          <HeartIcon filled={saved}/>
        </button>
        {(!venue.photos || !venue.photos[0]) && (
          <div className="pp-vcard-cover-mark" aria-hidden="true">
            <span className="pp-vcard-cover-mono">{monogramOf(venue.name)}</span>
          </div>
        )}
        <span className="pp-vcard-cover-tag"><OperatorTag id={venue.operator} size="sm"/></span>
      </div>
      <div className="pp-vcard-body">
        <div className="pp-vcard-head">
          <div className="pp-vcard-title-wrap">
            <h3 className="pp-vcard-title">{venue.name}</h3>
            <div className="pp-vcard-loc"><IconPin size={11}/> {venue.city}</div>
          </div>
          <button className="pp-vcard-info" aria-label="Venue details" title="Venue details">
            <InfoIcon/>
          </button>
        </div>
        <div className="pp-vcard-section-label">
          Products <span className="pp-vcard-section-count">{products.length}</span>
        </div>
        <div className="pp-prod-switch" role="tablist" aria-label="Choose a product">
          {products.map(pid => {
            const PG = PRODUCT_GLYPHS[pid] || PRODUCT_GLYPHS.dinner;
            const p = productById(pid) || { name: (venue.productNames && venue.productNames[pid]) || pid };
            const pSlots = byProduct[pid];
            const isSel = pid === productId;
            const none = Array.isArray(pSlots) && pSlots.length === 0;
            return (
              <button key={pid} type="button" role="tab" aria-selected={isSel}
                      className={"pp-prod-tab" + (isSel ? " is-active" : "") + (none ? " is-empty" : "")}
                      title={none ? p.name + ' — no times for this date' : p.name}
                      onClick={() => { setSel(pid); setExpanded(false); }}>
                <PG size={12}/>
                <span>{p.name}</span>
                {none && <span className="pp-prod-tab-x" aria-hidden="true">—</span>}
              </button>
            );
          })}
        </div>
        {preOrderLabel && (
          <div className={"pp-vcard-preorder" + (preOrderType === 'optional' ? ' pp-vcard-preorder--soft' : '')} title={preOrderLabel.title}>
            <IconBox size={12}/>
            <span>{preOrderLabel.short}</span>
          </div>
        )}
        <div className="pp-vcard-times-head">
          <span className="pp-vcard-section-label">
            <G size={12}/> {product.name}
            {minParty ? <span className="pp-vcard-minparty" title={'Minimum ' + minParty + (minParty === 1 ? ' guest' : ' guests') + ' for this product'}>min {minParty} {minParty === 1 ? 'guest' : 'guests'}</span> : null}
          </span>
          {minPrice ? <span className="pp-vcard-from">from £{minPrice}/pp</span> : null}
        </div>
        <div className="pp-time-chips">
          {!query.searched ? (
            <div className="pp-vcard-no-times">Pick a date and search to see times</div>
          ) : belowMin ? (
            <div className="pp-vcard-no-times">Minimum {belowMin} guests for this product</div>
          ) : loading ? (
            <React.Fragment>
              {Array.from({ length: 5 }).map((_, i) => (
                <span key={i} className="pp-time-chip-skel pp-shimmer" aria-hidden="true"/>
              ))}
            </React.Fragment>
          ) : (slots || []).length === 0 ? (
            <div className="pp-vcard-no-times">No times for this date</div>
          ) : (() => {
            const SHOWN = 8;
            const all = slots || [];
            const visible = expanded ? all : all.slice(0, SHOWN);
            const hidden = all.length - visible.length;
            return (
              <React.Fragment>
                {visible.map(s => {
                  const isRequest = s.type === 'request';
                  return (
                    <button key={s.time + '-' + (s.type || 'book')}
                            type="button"
                            className={"pp-time-chip" + (isRequest ? " pp-time-chip--request" : "")}
                            title={(isRequest ? 'Enquiry — operator must approve.' : 'Instant confirmation.') + (s.price ? ' £' + s.price + ' per person' : '')}
                            onClick={() => onBook({ venueId: venue.id, productId, date: query.date, time: s.time, guests: query.guests, price: s.price, type: s.type, compositeId: s.compositeId, preOrderItems: s.preOrderItems })}>
                      {s.time}
                    </button>
                  );
                })}
                {hidden > 0 && (
                  <button type="button" className="pp-time-chip pp-time-chip--more" onClick={() => setExpanded(true)}>
                    +{hidden} more
                  </button>
                )}
              </React.Fragment>
            );
          })()}
        </div>
      </div>
    </article>
  );
}

function monogramOf(name) {
  return name.replace(/[^A-Za-z\s]/g, '').split(/\s+/).filter(Boolean).slice(0, 2).map(w => w[0]).join('').toUpperCase();
}

function HeartIcon({ filled }) {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill={filled ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round">
      <path d="M12 21s-7-4.5-9.3-9.1A5.3 5.3 0 0 1 12 5.3a5.3 5.3 0 0 1 9.3 6.6C19 16.5 12 21 12 21Z"/>
    </svg>
  );
}

function InfoIcon() {
  return (
    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
      <circle cx="12" cy="12" r="9"/>
      <path d="M12 11v5M12 8v.01" strokeLinecap="round"/>
    </svg>
  );
}

function SortPicker() {
  const [open, setOpen] = React.useState(false);
  const [val, setVal] = React.useState('relevance');
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [open]);
  const opts = [['relevance','Relevance'],['earliest','Earliest time'],['price-asc','Price (low to high)'],['price-desc','Price (high to low)']];
  const label = opts.find(o => o[0] === val)[1];
  return (
    <div className="pp-dd-wrap" ref={ref}>
      <button className="pp-pill-btn pp-pill-btn--sm" onClick={() => setOpen(o => !o)}>
        <IconSort size={14}/>
        <span>Sort: {label}</span>
        <IconChevronDn size={12}/>
      </button>
      {open && (
        <div className="pp-dd-menu pp-dd-menu--right">
          {opts.map(([id, lbl]) => (
            <button key={id} className={"pp-dd-item" + (id === val ? " is-active" : "")} onClick={() => { setVal(id); setOpen(false); }}>
              {lbl}
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function EmptyResults({ onClear }) {
  return (
    <div className="pp-empty">
      <div className="pp-empty-glyph"><IconSearch size={28}/></div>
      <div className="pp-empty-title">No inventory matches</div>
      <div className="pp-empty-sub">Try a different date, loosen the product type, or broaden the location.</div>
      <button className="pp-btn pp-btn--ghost" onClick={onClear}>Clear filters</button>
    </div>
  );
}

// Map view — Leaflet split layout. List of venues on the left, real map with
// per-venue markers on the right. Hovering a list row pans to the marker;
// clicking either opens a popup with photo + name + city + a Book button.
function MapView({ cards, query, onBook }) {
  const mapRef = React.useRef(null);
  const mapInstanceRef = React.useRef(null);
  const markersRef = React.useRef(new Map()); // venueId → L.marker
  const [activeId, setActiveId] = React.useState(null);

  const geo = React.useMemo(
    () => cards.filter(c => Number.isFinite(c.venue.lat) && Number.isFinite(c.venue.lng)),
    [cards]
  );

  // Init the map once.
  React.useEffect(() => {
    if (!mapRef.current || mapInstanceRef.current || !window.L) return;
    const map = window.L.map(mapRef.current, {
      zoomControl: true,
      attributionControl: true,
      worldCopyJump: true,
    }).setView([54.5, -2.5], 6);
    window.L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
      subdomains: 'abcd',
      maxZoom: 19,
    }).addTo(map);
    mapInstanceRef.current = map;
    return () => { map.remove(); mapInstanceRef.current = null; markersRef.current.clear(); };
  }, []);

  // Sync markers whenever the visible cards change.
  React.useEffect(() => {
    const map = mapInstanceRef.current;
    if (!map || !window.L) return;

    // Drop stale markers
    for (const [id, m] of markersRef.current) {
      if (!geo.find(c => c.venue.id === id)) { m.remove(); markersRef.current.delete(id); }
    }

    // Add new markers
    for (const c of geo) {
      if (markersRef.current.has(c.venue.id)) continue;
      const v = c.venue;
      const icon = window.L.divIcon({
        className: 'pp-pin-wrap',
        html: '<div class="pp-pin"><div class="pp-pin-inner"></div></div>',
        iconSize: [30, 30],
        iconAnchor: [15, 30],
        popupAnchor: [0, -28],
      });
      const marker = window.L.marker([v.lat, v.lng], { icon, riseOnHover: true })
        .addTo(map)
        .bindPopup(popupHTML(v, c.productId), { closeButton: true, autoClose: true, className: 'pp-popup-wrap' });
      marker.on('click', () => setActiveId(v.id));
      marker.on('popupopen', () => setActiveId(v.id));
      marker.on('popupclose', () => setActiveId(prev => prev === v.id ? null : prev));
      marker.getElement()?.querySelector('.pp-pin')?.setAttribute('data-vid', v.id);
      markersRef.current.set(v.id, marker);
    }

    // Wire popup CTA via event delegation on the map container.
    const onClick = (e) => {
      const cta = e.target.closest && e.target.closest('.pp-popup-cta');
      if (!cta) return;
      const vid = cta.getAttribute('data-vid');
      const pid = cta.getAttribute('data-pid');
      const c = geo.find(c => c.venue.id === vid);
      if (c) onBook({ venueId: vid, productId: pid || c.productId, date: query.date, guests: query.guests });
    };
    mapRef.current.addEventListener('click', onClick);

    // Fit bounds.
    if (geo.length) {
      const bounds = window.L.latLngBounds(geo.map(c => [c.venue.lat, c.venue.lng]));
      map.fitBounds(bounds, { padding: [40, 40], maxZoom: 13 });
    }

    return () => { mapRef.current && mapRef.current.removeEventListener('click', onClick); };
  }, [geo, query.date, query.guests, onBook]);

  // Highlight active marker.
  React.useEffect(() => {
    const allPins = mapRef.current?.querySelectorAll('.pp-pin') || [];
    allPins.forEach(p => p.classList.toggle('is-active', p.getAttribute('data-vid') === activeId));
  }, [activeId]);

  const focusVenue = (c) => {
    const map = mapInstanceRef.current;
    const marker = markersRef.current.get(c.venue.id);
    if (!map || !marker) return;
    map.setView([c.venue.lat, c.venue.lng], Math.max(map.getZoom(), 14), { animate: true });
    marker.openPopup();
    setActiveId(c.venue.id);
  };

  const missing = cards.length - geo.length;

  return (
    <div className="pp-mapview">
      <div className="pp-mapview-list">
        <div className="pp-mapview-list-head">
          <span><span className="pp-mapview-list-count">{geo.length}</span> on map</span>
          {missing > 0 && <span title="No coordinates from Bookable">{missing} unmapped</span>}
        </div>
        {geo.map(c => {
          const v = c.venue;
          const op = operatorById(v.operator);
          const product = productById(c.productId) || { name: (v.productNames && v.productNames[c.productId]) || c.productId };
          return (
            <button key={v.id}
                    className={"pp-mapview-row" + (activeId === v.id ? ' is-active' : '')}
                    onMouseEnter={() => setActiveId(v.id)}
                    onClick={() => focusVenue(c)}>
              {v.photos && v.photos[0] ? (
                <img className="pp-mapview-row-thumb" src={v.photos[0]} alt="" loading="lazy"/>
              ) : (
                <span className="pp-mapview-row-thumb-placeholder">{monogramOf(v.name)}</span>
              )}
              <span className="pp-mapview-row-body">
                <span className="pp-mapview-row-name">{v.name}</span>
                <span className="pp-mapview-row-meta">
                  <span><IconPin size={10}/> {v.city}{v.area ? ' · ' + v.area : ''}</span>
                </span>
                <span className="pp-mapview-row-meta">
                  {op && <OperatorTag id={v.operator} size="sm"/>}
                  <span className="pp-mapview-row-product">{product.name}</span>
                </span>
              </span>
            </button>
          );
        })}
        {geo.length === 0 && (
          <div className="pp-mapview-empty">No mappable venues for these filters.</div>
        )}
      </div>
      <div className="pp-mapview-mapwrap">
        <div ref={mapRef} className="pp-mapview-map"/>
      </div>
    </div>
  );
}

function popupHTML(v, productId) {
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
  const img = v.photos && v.photos[0] ? `<img class="pp-popup-img" src="${esc(v.photos[0])}" alt=""/>` : '';
  const productName = (v.productNames && v.productNames[productId]) || productId;
  return [
    '<div class="pp-popup">',
    img,
    '<div class="pp-popup-body">',
    `<h3 class="pp-popup-name">${esc(v.name)}</h3>`,
    `<div class="pp-popup-meta">${esc(v.city)}${v.area ? ' · ' + esc(v.area) : ''}</div>`,
    `<div class="pp-popup-meta">${esc(productName)}</div>`,
    `<button class="pp-popup-cta" data-vid="${esc(v.id)}" data-pid="${esc(productId)}">Book ${esc(productName)}</button>`,
    '</div></div>',
  ].join('');
}

// Sticky wrapper for the results-page search + filters. Toggles an is-stuck
// class once the wrapper has scrolled to its anchor so the divider/shadow
// only appears when content sits below.
function StickyResultsBar({ children }) {
  const ref = React.useRef(null);
  const [stuck, setStuck] = React.useState(false);
  React.useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const sentinel = document.createElement('div');
    sentinel.style.cssText = 'position:absolute;top:-1px;left:0;height:1px;width:1px;pointer-events:none';
    el.parentElement.insertBefore(sentinel, el);
    const io = new IntersectionObserver(([entry]) => setStuck(!entry.isIntersecting), { threshold: 1, rootMargin: '0px 0px 0px 0px' });
    io.observe(sentinel);
    return () => { io.disconnect(); sentinel.remove(); };
  }, []);
  return (
    <div ref={ref} className={'pp-results-sticky' + (stuck ? ' is-stuck' : '')}>
      {children}
    </div>
  );
}

function ppHaversineKm(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const toRad = (d) => (d * Math.PI) / 180;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

Object.assign(window, { ResultsScreen, ppHaversineKm });
