/* global React, Icon, SectionHead, MegaFooter, ContactStatus, OaVerifyStat, OaOpenNotice, KitPrepTechPage, KitTechPortalPage, ConsignmentsAdminPage */
const { useState: useStateRentals, useEffect: useEffectRentals } = React;

// ===================================================================
// Rentals — sub-routed
// ===================================================================
// `/rentals`               → RentalsLanding (hero + reviews + FAQ)
// `/rentals/equipment`     → EquipmentPage (native catalog from Booqable CSV)
// `/rentals/about`         → RentalsAboutPage (hours, delivery zones, contact)
// `/rentals/open-account`  → OpenAccountPage (multi-step verification wizard)
//
// Legacy `/rentals/contact` redirects to `/rentals/about` via parseLocation.
//
// All sub-pages share the `rentals-light` chrome (light theme, rentals
// nav, rentals footer). The big hero only appears on the landing —
// every sub-page gets its own slim VR—XX header so it feels distinct.
//
// The Booqable JS widget script is loaded on every rentals route via
// `useBooqable()` so dependent date-pickers stay available. The
// Booqable cart launcher itself is hidden via CSS; our own floating
// search-and-cart bar (FloatingRentalsBar) lives in the same slot.
// ===================================================================

const BOOQABLE_SRC = 'https://ff981641-1ae6-4700-9720-0b8c06579658.assets.booqable.com/v2/booqable.js';

// ---------------------------------------------------------------------
// Ownership-code visibility — staff-tools toggle.
// ---------------------------------------------------------------------
// Returns a string for an item ("O-VR", "C-PK", "X-SB") or null when
// nothing should render. Encodes the universal staff rules in one
// place so cards + drawer + cart agree:
//   • owned       → "O-VR" (Owned by Valley Rentals — uniform, no per-
//                   item initials needed since VR owns all owned kit)
//   • consignment → "C-<initials>" from the sync's derive_owner_initials
//   • cross-hire  → "X-<initials>" same source
//   • custom      → null (synth lines have no ownership code)
function ownershipCodeFor(item) {
  if (!item) return null;
  if (item.ownership === 'owned') return 'O-VR';
  if (item.ownership === 'consignment' && item.ownerInitials) return `C-${item.ownerInitials}`;
  if (item.ownership === 'cross-hire'  && item.ownerInitials) return `X-${item.ownerInitials}`;
  return null;
}

// Hook: reads + subscribes to the "Show ownership codes" staff toggle.
// Default off — codes are useful for staff but visual noise for
// customers. Flipped via Cmd+/ → Staff Tools → Show ownership codes.
function useShowOwnershipCodes() {
  const [on, setOn] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-show-ownership-codes') === '1'; } catch (e) { return false; }
  });
  useEffectRentals(() => {
    if (typeof window === 'undefined') return undefined;
    const onToggle = () => setOn((v) => !v);
    window.addEventListener('vf-rentals-show-ownership-codes-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-show-ownership-codes-toggle', onToggle);
  }, []);
  return on;
}

function useBooqable() {
  const [status, setStatus] = useStateRentals('loading');
  useEffectRentals(() => {
    const existing = document.querySelector(`script[src="${BOOQABLE_SRC}"]`);
    if (existing) {
      setStatus(existing.dataset.bqStatus || 'loaded');
      return;
    }
    const s = document.createElement('script');
    s.src = BOOQABLE_SRC;
    s.async = true;
    s.onload = () => { s.dataset.bqStatus = 'loaded'; setStatus('loaded'); };
    s.onerror = () => { s.dataset.bqStatus = 'error'; setStatus('error'); };
    document.head.appendChild(s);
    const t = setTimeout(() => setStatus((p) => p === 'loading' ? 'error' : p), 8000);
    return () => clearTimeout(t);
  }, []);
  return status;
}


// ───────────────────────────────────────────────────────────────────
// Insurance recommendation card. Used in two places: the dedicated
// "Insurance" FAQ entry on the rentals landing page, and the Open
// Account welcome step. Renters aren't required to carry hire-in
// insurance, but we strongly recommend it — the card surfaces our
// preferred broker so applicants know where to start.
// ───────────────────────────────────────────────────────────────────
const INSURANCE_BROKER = {
  name: 'Performance Insurance',
  url: 'https://www.performance-insurance.com/',
  blurb: 'Production-friendly hire-in cover. Tell them you\'re renting from Valley and they\'ll know the kit list shapes we typically send.',
};

function InsuranceCard({ compact = false }) {
  return (
    <aside className={`rentals-insurance-card ${compact ? 'is-compact' : ''}`}>
      <div className="ri-icon" aria-hidden="true">
        {/* Shield-with-alert icon — same shield family as the security badge
            (so they read as part of one design system) but the interior
            mark is an exclamation rather than a tick. Reads as "pay
            attention / strongly recommended", which lines up with the
            section copy. Subtly different shield curve from the security
            badge too so they don't double-read as the same icon. */}
        <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
          <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
          <line x1="12" y1="8" x2="12" y2="12" />
          <line x1="12" y1="16" x2="12.01" y2="16" />
        </svg>
      </div>
      <div className="ri-body">
        <h4>Insurance — strongly recommended</h4>
        <p>You aren't required to carry hire-in insurance, but the renter is liable for any damage, loss or theft while the kit is in their care. We recommend:</p>
        <a className="ri-cta" href={INSURANCE_BROKER.url} target="_blank" rel="noopener noreferrer">
          {INSURANCE_BROKER.name} <Icon name="arrow-up-right" size={13} />
        </a>
        {!compact && <p className="ri-fine">{INSURANCE_BROKER.blurb}</p>}
      </div>
    </aside>);
}

const FAQ = [
// Business hours, Address & Parking, Collection & Delivery, and
// "Who can rent from us?" were removed from this FAQ list — the
// content moved to the About page (hours, parking, delivery) and
// the Open Account intro column (who can rent). Removing duplicate
// fine-print from the home page keeps it scannable.
{
  q: 'Payment & accounts',
  a: 'New direct clients book on a Cash Account: full payment in advance via bank transfer or card before any kit is prepped. After a successful first hire you can move to a Credit Account with 30-day terms (invoices over £250 default to 30 days; smaller invoices stay payment-on-collection until a track record is established). Hire-in insurance covering the full replacement value isn\'t required, but we strongly recommend it — see the Insurance entry below for our preferred broker.'
},
{
  q: 'Insurance',
  a: 'Hire-in insurance isn\'t required to rent from us, but we strongly recommend it. The renter is liable for any damage, loss or theft while the kit is in their care. We recommend Performance Insurance for production-friendly hire-in cover.',
  aBody: (
    <>
      <p>Hire-in insurance isn't a hard requirement to rent from us, but we strongly recommend it. The renter is liable for any damage, loss or theft while the kit is in their care, so a small premium can save you from a five-figure invoice if something goes sideways.</p>
      <p style={{ marginTop: 12 }}>We work with crews who carry their own annual policy, project-specific cover bought per shoot, and clients who self-insure on smaller hires — all fine. If you need a starting point:</p>
      <InsuranceCard />
    </>
  )
},
{
  q: 'Hire periods & late returns',
  a: "A single-day hire bridges three calendar days: pick up any time from 3pm the day before, the rental day itself, then return by 11am the day after. The early-pickup window and the morning-after return grace are both built into the day rate. Multi-day hires work the same way — for N rental days you span N+2 calendar days (so a 2-day hire bridges four). Saturday and Sunday are charged as normal rental days (no weekend discount). Returns after 11am on the return day are charged at a full additional day rate."
},
{
  q: 'Prep & checkout',
  a: 'Every kit is bench-tested, projected (lenses), and packed by our techs before it leaves the building. We don\'t have a prep space on-site for clients — if you need one, get in touch and we\'ll see if we can work something out.'
},
{
  q: 'Minimum order size',
  a: 'Every booking has a £25 minimum spend. Worth knowing before you build a single-item list — sometimes it\'s cheaper to add a second item than meet the minimum on one.'
},
{
  q: 'Damage, loss & cancellations',
  a: 'The renter is liable for any damage, loss or theft of the kit while it is in their care. We strongly recommend hire-in insurance covering the full replacement value of the kit — see the Insurance entry above for our preferred broker. Accidental damage is invoiced via Booqable with photos; items not returned within 24 hours of the scheduled return are invoiced at full replacement value. Cancellation: free more than 14 days before pickup, 50% of the rental value 48 hours to 14 days before, non-refundable inside 48 hours.',
  aBody: (
    <>
      <p>The renter is liable for any damage, loss or theft of the kit while it is in their care. Hire-in insurance covering the full replacement value isn't a hard requirement, but we strongly recommend it — see the <strong>Insurance</strong> entry above for our preferred broker.</p>
      <p style={{ marginTop: 12 }}>Accidental damage is invoiced through Booqable with photos so you can pass it to your insurer. Items not returned within 24 hours of the scheduled return are invoiced at full replacement value.</p>
      <p style={{ marginTop: 12 }}><strong>Cancellations</strong></p>
      <table className="faq-table">
        <thead>
          <tr><th>Cancellation window</th><th>Charge</th></tr>
        </thead>
        <tbody>
          <tr><td>More than 14 days before pickup</td><td>Free</td></tr>
          <tr><td>48 hours to 14 days before pickup</td><td>50% of rental value</td></tr>
          <tr><td>Within 48 hours of pickup</td><td>Non-refundable</td></tr>
        </tbody>
      </table>
    </>
  )
}];


// ===================================================================
// Slim per-page hero — used by every sub-page.
// ===================================================================
function RentalsSubHero({ idx, label, title, lead }) {
  return (
    <section className="rentals-subhero">
      <div className="container">
        <div className="eyebrow"><span className="idx">{idx}</span> {label}</div>
        <h1 dangerouslySetInnerHTML={{ __html: title }} />
        {lead && <p className="lead">{lead}</p>}
      </div>
    </section>);

}

// ===================================================================
// /rentals — landing page (big hero + 4 cards)
// ===================================================================
// ===================================================================
// Reviews — Google reviews shown as a 3-up grid of cards. Each card
// links to Google Maps reviews; the bottom CTA opens the full list.
// REVIEWS_TOTAL is the count Google shows for the listing (update as
// more reviews come in). To swap the featured three, replace entries
// in REVIEWS with text from the Google review you want surfaced.
// ===================================================================
const GOOGLE_REVIEWS_URL = 'https://www.google.com/maps/place/Valley+Rentals/@51.5061014,-0.2620762,17z/data=!4m8!3m7!1s0x48760f2a724d1c33:0xaa5d67f6abb38bfc!8m2!3d51.5060981!4d-0.2595013!9m1!1b1!16s%2Fg%2F11vbw9dmpt';
const REVIEWS_TOTAL = 10;

// Reviewer display: short form ("Mark W.") on the card; `author` keeps
// the full name for JSON-LD / accessibility. `url` deep-links to the
// individual Google review so the visitor lands on that exact review
// rather than the listing's review list. `avatar` is the small profile
// face shown on the card.
const REVIEWS = [
{
  author: 'Mark Winterlin',
  shortName: 'Mark W.',
  avatar: 'assets/review-mark.png',
  rating: 5,
  text: 'Valley Rentals is my go-to kit hire company. They have excellent service, are always responsive and take the initiative to tackle any problems, and Max and the team are always lovely to work with. Highly recommended!',
  url: 'https://maps.app.goo.gl/Hu7q4sb5MrNMU5bf7'
},
{
  author: 'Fenton Dyer',
  shortName: 'Fenton D.',
  avatar: 'assets/review-fenton.png',
  rating: 5,
  text: "I had a top tier experience with Valley Rentals. Friendly and clear communication, fair prices, and easy to pick up and drop off. Glad to say this is everything you'd want from a rental house!",
  url: 'https://maps.app.goo.gl/fGCUoyndEug37PUf6'
},
{
  author: 'Lucas Patternot',
  shortName: 'Lucas P.',
  avatar: 'assets/review-lucas.png',
  rating: 5,
  // Lucas's review is long — opted into hover-to-expand truncation
  // via `truncate: true`. Short reviews above render in full.
  truncate: true,
  text: 'As a repeat customer of Valley Rentals, I can confirm that both the kit and the people in the company are fantastic. Each piece of kit is meticulously cleaned, packed and ready to go. This even goes as far as their metal work. Max, the person who I have dealt with many times, is a wonderful person and just an absolute pleasure to deal with. As an experienced member of both the camera AND lighting team, he was able to curate a full studio package for my last three shoots and offered suggestions to help make the gear go further. 10/10 would rent again.',
  url: 'https://maps.app.goo.gl/caodu4TWXhEQEVTHA'
}];


// Single-star SVG — filled-vs-empty driven by parent class. We render
// 5 stars per card and toggle the .filled class up to `rating`.
function ReviewStar({ filled }) {
  return (
    <svg className={`rr-star ${filled ? 'filled' : ''}`} viewBox="0 0 20 20" width="14" height="14" aria-hidden="true">
      <path d="M10 1.5l2.6 5.3 5.9.85-4.25 4.15.99 5.85L10 14.9 4.76 17.65l.99-5.85L1.5 7.65l5.9-.85L10 1.5z" fill="currentColor" />
    </svg>);

}

// Reviews grid. Truncated cards (Lucas) are clamped to ~6 lines via
// CSS line-clamp — close to Mark's natural text length. On hover the
// card lifts the clamp and floats out of grid flow so siblings don't
// shift. Simple and predictable; no JS measurement needed.
function ReviewGrid({ reviews }) {
  return (
    <div className="rr-grid">
      {reviews.map((r, i) => (
        <div className="rr-slot" key={i}>
          <ReviewCard r={r} />
        </div>))}
    </div>);
}

function ReviewCard({ r }) {
  const [open, setOpen] = useStateRentals(false);
  // `collapsing` is held true for the duration of the un-expand text-
  // shrink animation (700ms, matches the CSS max-height transition on
  // .rr-text). During this window the card stays position: absolute so
  // the still-tall text doesn't snap back into the flex layout and
  // stretch the grid row + sibling slots — that's the "jump out"
  // glitch Max reported on Lucas P's review (May 2026).
  const [collapsing, setCollapsing] = useStateRentals(false);
  const collapseTimerRef = React.useRef(null);
  const COLLAPSE_MS = 700;

  const expand = () => {
    if (!r.truncate) return;
    if (collapseTimerRef.current) {
      clearTimeout(collapseTimerRef.current);
      collapseTimerRef.current = null;
    }
    setCollapsing(false);
    setOpen(true);
  };
  const collapse = () => {
    if (!r.truncate) return;
    setOpen(false);
    setCollapsing(true);
    if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current);
    collapseTimerRef.current = setTimeout(() => {
      setCollapsing(false);
      collapseTimerRef.current = null;
    }, COLLAPSE_MS);
  };
  React.useEffect(() => () => {
    if (collapseTimerRef.current) clearTimeout(collapseTimerRef.current);
  }, []);

  return (
    <a
      href={r.url || GOOGLE_REVIEWS_URL}
      target="_blank"
      rel="noopener noreferrer"
      className={`rr-card ${r.truncate ? 'is-truncated' : ''} ${open ? 'is-expanded' : ''} ${collapsing ? 'is-collapsing' : ''}`}
      data-hover="Read"
      onMouseEnter={expand}
      onMouseLeave={collapse}
      onFocus={expand}
      onBlur={collapse}>
      <span className="rr-stars-row" aria-label={`${r.rating} out of 5 stars`}>
        {Array.from({ length: 5 }, (_, k) => <ReviewStar key={k} filled={k < r.rating} />)}
      </span>
      <p className="rr-text">{r.text}</p>
      <div className="rr-author-row">
        {r.avatar && (
          <img className="rr-avatar" src={r.avatar} alt="" aria-hidden="true" loading="lazy" />)}
        <span className="rr-author">{r.shortName || r.author}</span>
      </div>
    </a>);
}

function ReviewsSection() {
  return (
    <section id="reviews" className="rentals-reviews">
      <div className="container">
        <div className="rr-head">
          <div className="eyebrow"><span className="idx">VR—1.3</span> What clients say</div>
          <h2>Reviewed on <em>Google</em>.</h2>
          <div className="rr-summary">
            <span className="rr-stars-row" aria-label="5 out of 5 stars">
              {Array.from({ length: 5 }, (_, i) => <ReviewStar key={i} filled />)}
            </span>
            <span className="rr-summary-num">5.0</span>
            <span className="rr-summary-source">· {REVIEWS_TOTAL}+ reviews on Google</span>
          </div>
        </div>

        <ReviewGrid reviews={REVIEWS} />

        <div className="rr-cta">
          <a href={GOOGLE_REVIEWS_URL} target="_blank" rel="noopener noreferrer" className="rr-cta-link" data-hover="Open">
            See all reviews on Google
            <Icon name="arrow-up-right" size={14} />
          </a>
        </div>
      </div>
    </section>);

}

// ===================================================================
// /rentals — landing page (hero, reviews, FAQ, footer). Mirrors the
// flow of the films home: editorial sections rather than a card grid.
// ===================================================================
// Hero photo carousel — cycles through the rentals photo library on the
// landing page. Cross-fade between the current and next slot every
// CYCLE_MS; the next index is computed off the previous to avoid jumps
// if React re-renders mid-interval.
const HERO_PHOTOS = [
  'assets/valley_rentals/IMG_5258.JPG',
  'assets/valley_rentals/IMG_9582.jpeg',
  'assets/valley_rentals/IMG_8033.JPG',
  'assets/valley_rentals/IMG_9088.JPG',
  'assets/valley_rentals/IMG_0526.JPG',
  'assets/valley_rentals/classicu-2026-01-26.jpg',
  'assets/valley_rentals/classicu-2026-02-03.jpg'
];
const HERO_CYCLE_MS = 5200;

// Shelf strip — horizontal photo row used inside the redesigned VR—1.1
// section. Different selection than the hero so the page doesn't repeat
// the same shots back-to-back. Click any thumbnail to open the lightbox.
const SHELF_STRIP_PHOTOS = [
  'assets/valley_rentals/IMG_2879.JPG',
  'assets/valley_rentals/IMG_8033.JPG',
  'assets/valley_rentals/IMG_9088.JPG',
  'assets/valley_rentals/classicu-2026-02-03.jpg'
];

// Brand strip — the manufacturers Valley Rentals actually stocks, rendered
// as a continuous marquee on the rentals landing. Mixed text + logos
// during the rollout: drop a PNG at assets/vr-logos/<file> and add a
// `logo:` field, the renderer swaps the text span for an <img>.
const RENTAL_BRANDS = [
  { name: 'RED',           logo: 'red.png' },
  { name: 'Sony',          logo: 'sony.png' },
  { name: 'Blackmagic',    logo: 'blackmagic-design-logo.png' },
  { name: 'Aputure',       logo: 'aputure.png' },
  { name: 'Sigma',         logo: 'sigma.png' },
  { name: 'DZOFilm',       logo: 'dzofilm.png' },
  { name: 'DJI',           logo: 'dji.png' },
  { name: 'Tilta',         logo: 'tilta-logo.png' },
  { name: 'Matthews',      logo: 'matthews-logo.png' },
  { name: 'Manfrotto',     logo: 'manfrotto-logo.png' },
  { name: 'EasyRig' },
  { name: 'Atomos',        logo: 'atomos.png' },
  { name: 'RODE',          logo: 'rode-logo.png' },
  { name: 'Tentacle Sync', logo: 'tentacle-sync-logo.png' }
];

// Featured kits — a right-to-left marquee of the rentals catalog,
// filtered to owned kit (consignment + cross-hire excluded) and sorted
// by day rate descending so the most expensive items pass first. Pulls
// live from window.RENTALS_CATALOG (populated by the nightly Booqable
// sync) — no manual curation needed; new items auto-appear next sync.
// Pattern + rAF loop copied from RentalsBrandStrip just below for
// consistent motion feel. Hovering the strip slows it ~5.5×.
function FeaturedPackages() {
  const items = React.useMemo(() => {
    const catalog = (typeof window !== 'undefined' && Array.isArray(window.RENTALS_CATALOG))
      ? window.RENTALS_CATALOG : [];
    return catalog
      .filter((c) => c && (c.ownership || 'owned') === 'owned' && (c.dayRate || 0) > 0 && c.image)
      .slice()
      .sort((a, b) => (b.dayRate || 0) - (a.dayRate || 0));
  }, []);

  const stripRef = React.useRef(null);
  const trackRef = React.useRef(null);
  useEffectRentals(() => {
    const strip = stripRef.current;
    const track = trackRef.current;
    if (!strip || !track || items.length === 0) return;

    let setW = 0;
    const measure = () => {
      const set = track.querySelector('.rfk-set');
      if (set) setW = set.getBoundingClientRect().width;
    };
    measure();
    window.addEventListener('resize', measure);

    // Slightly slower than the brand strip — visual cards take more
    // attention than text logos so a calmer pace reads better.
    const baseSpeed = () => (window.innerWidth < 760 ? 0.08 : 0.05);
    const HOVER_FACTOR = 0.18;
    let target = baseSpeed();
    let speed = target;
    let pos = 0;
    let last = performance.now();

    const onEnter = () => { target = baseSpeed() * HOVER_FACTOR; };
    const onLeave = () => { target = baseSpeed(); };
    strip.addEventListener('mouseenter', onEnter);
    strip.addEventListener('mouseleave', onLeave);

    let raf;
    const loop = (t) => {
      const dt = Math.min(48, t - last);
      last = t;
      const k = 1 - Math.pow(0.001, dt / 1000);
      speed += (target - speed) * k;
      pos -= speed * dt;
      if (setW > 0) {
        while (-pos >= setW) pos += setW;
      }
      track.style.transform = `translate3d(${pos}px, 0, 0)`;
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', measure);
      strip.removeEventListener('mouseenter', onEnter);
      strip.removeEventListener('mouseleave', onLeave);
    };
  // Re-run when item count changes (e.g. catalog hydrated after first
  // render). Length is a stable signal — Array identity would re-fire
  // on every render even when the contents are the same.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [items.length]);

  const SETS = 3;
  return (
    <section id="featured" className="rentals-featured rentals-featured-marquee" aria-label="Featured kits">
      <div className="container rentals-featured-head">
        <div className="eyebrow"><span className="idx">VR—1.2</span> Featured kits</div>
        <h2>Our shelf, <em>on rotation</em>.</h2>
      </div>
      <div className="rfk-strip" ref={stripRef}>
        <div className="rfk-track" ref={trackRef}>
          {Array.from({ length: SETS }, (_, n) => (
            <div className="rfk-set" key={n} aria-hidden={n > 0 ? true : undefined}>
              {items.map((it) => (
                <a
                  key={`${n}-${it.id}`}
                  className="rfk-card"
                  href={`/rentals/equipment?item=${encodeURIComponent(it.id)}`}
                  tabIndex={n > 0 ? -1 : 0}
                  data-hover="View">
                  <div className="rfk-photo" style={{ backgroundImage: `url(${it.image})` }} />
                  <div className="rfk-meta">
                    <div className="rfk-name">{it.name}</div>
                    <div className="rfk-price">£{it.dayRate}/day</div>
                  </div>
                </a>
              ))}
            </div>
          ))}
        </div>
      </div>
      <div className="rentals-featured-foot">
        <a className="rentals-featured-all" href="/rentals/equipment" data-hover="Browse">Browse the full inventory <Icon name="arrow-up-right" size={12} /></a>
      </div>
    </section>
  );
}

// Marquee of rental kit brands — pure text for v1, rAF-driven scroll that
// mirrors the films home ClientStrip 1:1: same base speed (0.06 px/ms on
// desktop, 0.10 on mobile), same hover slowdown (0.18× = ~5.5× slower),
// and the same gentle ~200ms half-life smoothing so the rate transitions
// glide rather than snap. Renders 4 identical sets so the loop has no
// visible seam at any viewport width; wraps by exactly one set width.
function RentalsBrandStrip() {
  const stripRef = React.useRef(null);
  const trackRef = React.useRef(null);
  useEffectRentals(() => {
    const strip = stripRef.current;
    const track = trackRef.current;
    if (!strip || !track) return;

    let setW = 0;
    const measure = () => {
      const set = track.querySelector('.rbs-set');
      if (set) setW = set.getBoundingClientRect().width;
    };
    measure();
    window.addEventListener('resize', measure);

    // Same numbers as ClientStrip in components-hero.jsx — keep them in
    // sync if you tune one. baseSpeed is re-evaluated inside the loop so
    // a viewport rotation/resize picks up the right cadence live.
    const baseSpeed = () => (window.innerWidth < 760 ? 0.10 : 0.06);
    const HOVER_FACTOR = 0.18; // ~5.5x slower while hovered
    let target = baseSpeed();
    let speed = target;
    let pos = 0;
    let last = performance.now();

    const onEnter = () => { target = baseSpeed() * HOVER_FACTOR; };
    const onLeave = () => { target = baseSpeed(); };
    strip.addEventListener('mouseenter', onEnter);
    strip.addEventListener('mouseleave', onLeave);

    let raf;
    const loop = (t) => {
      const dt = Math.min(48, t - last); // clamp so tab-switch resume doesn't lurch
      last = t;
      // Half-life ~200ms — responsive but glide-y.
      const k = 1 - Math.pow(0.001, dt / 1000);
      speed += (target - speed) * k;
      pos -= speed * dt;
      if (setW > 0) {
        while (-pos >= setW) pos += setW;
      }
      track.style.transform = `translate3d(${pos}px, 0, 0)`;
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);

    return () => {
      cancelAnimationFrame(raf);
      window.removeEventListener('resize', measure);
      strip.removeEventListener('mouseenter', onEnter);
      strip.removeEventListener('mouseleave', onLeave);
    };
  }, []);

  const SETS = 4;
  return (
    <div className="rentals-brand-strip" ref={stripRef} aria-label="Brands we carry">
      <div className="rbs-track" ref={trackRef}>
        {Array.from({ length: SETS }, (_, n) => (
          <div key={n} className="rbs-set" aria-hidden={n > 0 ? true : undefined}>
            {RENTAL_BRANDS.map((b, i) => {
              // Brand name doubles as the equipment-search query. Fuse
              // scoring across name + tags + subcategory means typing
              // e.g. "Aputure" surfaces every Aputure-branded item.
              const href = `/rentals/equipment?q=${encodeURIComponent(b.name)}`;
              return (
                <React.Fragment key={`${n}-${i}`}>
                  {b.logo
                    ? <a className="rbs-link" href={href} tabIndex={n > 0 ? -1 : 0} data-hover={b.name}>
                        <img className="rbs-logo" src={`assets/vr-logos/${b.logo}`} alt={b.name} loading="lazy" />
                      </a>
                    : <a className="rbs-item" href={href} tabIndex={n > 0 ? -1 : 0} data-hover={b.name}>{b.name}</a>}
                  <span className="rbs-sep" aria-hidden="true">·</span>
                </React.Fragment>);
            })}
          </div>
        ))}
      </div>
    </div>
  );
}

function RentalsLanding({ onGoto }) {
  // Controlled-accordion state — only one FAQ open at a time. Mirrors the
  // pattern used by ServicesAccordion on the films home so the reveal
  // animation can use grid-template-rows: 0fr → 1fr instead of <details>'
  // discrete toggle. All items start closed; click or hover opens them.
  const [openFaq, setOpenFaq] = useStateRentals(-1);
  // Hover-driven on devices with a fine pointer; click stays as the
  // fallback for keyboards and touch screens. matchMedia gate prevents
  // the open-then-instantly-close flicker when a tap synthesises both
  // mouseenter and click on iOS/Android.
  const [hoverOpens, setHoverOpens] = useStateRentals(false);
  React.useEffect(() => {
    const m = window.matchMedia('(hover: hover) and (pointer: fine)');
    const apply = () => setHoverOpens(m.matches);
    apply();
    m.addEventListener ? m.addEventListener('change', apply) : m.addListener(apply);
    return () => { m.removeEventListener ? m.removeEventListener('change', apply) : m.removeListener(apply); };
  }, []);

  // Hero carousel index. Two background layers rendered; we toggle which
  // shows the "current" photo by alternating opacity for a smooth cross-
  // fade. Paused if the user prefers reduced motion.
  const [heroIdx, setHeroIdx] = useStateRentals(0);
  React.useEffect(() => {
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
    const id = setInterval(() => setHeroIdx((i) => (i + 1) % HERO_PHOTOS.length), HERO_CYCLE_MS);
    return () => clearInterval(id);
  }, []);
  const nextHeroIdx = (heroIdx + 1) % HERO_PHOTOS.length;

  // Shelf-strip lightbox — opens a full-bleed view of any clicked photo.
  // Escape closes it; body scroll is locked while open.
  const [lightboxSrc, setLightboxSrc] = useStateRentals(null);
  React.useEffect(() => {
    if (!lightboxSrc) return;
    const onKey = (e) => { if (e.key === 'Escape') setLightboxSrc(null); };
    window.addEventListener('keydown', onKey);
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';
    return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = prevOverflow; };
  }, [lightboxSrc]);

  return (
    <main className="page active rentals-light" data-screen-label="03b Rentals">

      {/* FAQPage structured data — surfaces in Google search results.
          The FAQ accordion now lives on the home page, so the schema
          ships from here too. */}
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify({
        "@context": "https://schema.org",
        "@type": "FAQPage",
        "mainEntity": FAQ.map((f) => ({
          "@type": "Question",
          "name": f.q,
          "acceptedAnswer": { "@type": "Answer", "text": f.a }
        }))
      }) }} />

      <section className="rentals-hero rentals-hero-cycle">
        <div className="rentals-hero-photos" aria-hidden="true">
          {HERO_PHOTOS.map((src, i) => (
            <div
              key={src}
              className={`rhp-slide${i === heroIdx ? ' is-current' : ''}`}
              style={{ backgroundImage: `url(${src})` }} />
          ))}
        </div>
        <div className="rentals-hero-veil" aria-hidden="true" />
        <div className="rentals-hero-blob" />
        <div className="container">
          <div className="eyebrow"><span className="idx">VR—1.0</span> Valley Rentals</div>
          <h1>Studio kit, <em>ready</em><br />when you are.</h1>
          <div className="rentals-hero-stats">
            <div className="stat"><div className="num">230+</div><div className="lbl">Equipment items</div></div>
            <div className="stat"><div className="num">2hr</div><div className="lbl">Same-day prep window</div></div>
            <div className="stat"><div className="num">W3</div><div className="lbl">Acton, London</div></div>
          </div>
        </div>
      </section>

      <RentalsBrandStrip />

      <section id="shelf" className="rentals-shelf">
        <div className="container rentals-shelf-head">
          <div className="eyebrow"><span className="idx">VR—1.1</span> The shelf</div>
          <div className="rentals-shelf-grid">
            <h2>The same camera, lens and lighting we <em>shoot with</em> day-to-day.</h2>
            <div className="rentals-shelf-body">
              <p>Every box on the shelf has earned its place on a Valley shoot first. If it isn't reliable enough for our own crew, it isn't reliable enough to send out.</p>
              <p>Prep is included on every booking, batteries come charged, cards come formatted, and there's a real person at the desk to talk through anything unusual about your shoot.</p>
              <a href="/rentals/equipment" className="rentals-shelf-cta" data-hover="Browse">Browse the shelf <Icon name="arrow-up-right" /></a>
            </div>
          </div>
        </div>
        <div className="rentals-shelf-strip">
          {SHELF_STRIP_PHOTOS.map((src) => (
            <button
              key={src}
              type="button"
              className="rss-card"
              aria-label="Open larger view"
              onClick={() => setLightboxSrc(src)}>
              <div className="rss-photo" style={{ backgroundImage: `url(${src})` }} />
            </button>
          ))}
        </div>
      </section>

      {lightboxSrc && (
        <div
          className="rentals-lightbox"
          role="dialog"
          aria-modal="true"
          aria-label="Photo preview"
          onClick={() => setLightboxSrc(null)}>
          <img src={lightboxSrc} alt="" />
          <button
            type="button"
            className="rentals-lightbox-close"
            aria-label="Close preview"
            onClick={(e) => { e.stopPropagation(); setLightboxSrc(null); }}>
            <Icon name="close" size={16} />
          </button>
        </div>
      )}

      <FeaturedPackages />

      <ReviewsSection />

      <section id="faqs" className="rentals-faq">
        <div className="container">
          <SectionHead idx="VR—1.4" label="Rental SOP · FAQ" title="The <em>useful</em><br/>fine print." />
          <div className="faq-grid">
            {FAQ.map((f, i) => {
              const isOpen = openFaq === i;
              const toggle = () => setOpenFaq(isOpen ? -1 : i);
              return (
                <div
                  key={i}
                  className={`faq-item${isOpen ? ' is-open' : ''}`}
                  onMouseEnter={hoverOpens ? () => setOpenFaq(i) : undefined}
                  onMouseLeave={hoverOpens ? () => setOpenFaq((prev) => prev === i ? -1 : prev) : undefined}
                  data-hover={isOpen ? 'Close' : 'Open'}>
                  <button
                    type="button"
                    className="faq-head"
                    aria-expanded={isOpen}
                    onClick={toggle}>
                    <span className="faq-num">{String(i + 1).padStart(2, '0')}</span>
                    <span className="faq-q">{f.q}</span>
                    <span className="faq-toggle" aria-hidden="true"><Icon name="plus" size={14} /></span>
                  </button>
                  <div className="faq-body">
                    <div className="faq-body-inner">
                      <div className="faq-a">{f.aBody ? f.aBody : f.a}{f.link && <> <a href={f.link.url} target="_blank" rel="noopener noreferrer" className="faq-link">{f.link.label}</a></>}</div>
                    </div>
                  </div>
                </div>);

            })}
          </div>
        </div>
      </section>

      <MegaFooter onGoto={onGoto} isRentals />
    </main>);

}

// ===================================================================
// /rentals/equipment — native catalog page. Reads from RENTALS_CATALOG
// in data.jsx (eventually published from Booqable's CSV feed by the
// weekly cron; sample data until then). Search via Fuse.js, faceted
// filter sidebar, item detail drawer. No cart yet — that's the next
// phase. Existing Booqable shop link stays as a fallback in the footer.
// ===================================================================

// Preferred order for the top-level chips — mirrors the Booqable
// shop's sidebar. Anything in the catalog with a category NOT in
// this list (e.g. an 'Other' bucket for items not tagged in any
// Booqable collection) gets appended at the end so it's still
// reachable. Sub-categories per chip are derived dynamically from
// the items themselves (see subsByCategory in EquipmentPage).
//
// 'Packages' intentionally omitted — packages are surfaced as KIT
// badges on individual cards (see CatalogCard isBundle handling)
// rather than as a separate filter. Max removed the Packages
// collection in Booqable to match.
const CATALOG_CATEGORIES = ['Camera', 'Electric', 'Grip', 'Audio', 'Unit', 'Support'];

// Tiny lazy-loaded image w/ graceful fallback. Booqable CDN URLs are
// hot-linked, so a broken link drops us to the gradient placeholder
// rather than showing a busted-image icon.
function CatalogImage({ src, alt }) {
  const [failed, setFailed] = useStateRentals(false);
  if (!src || failed) {
    return <div className="rcat-photo rcat-photo-fallback" aria-label={alt}><span>{alt}</span></div>;
  }
  return (
    <div className="rcat-photo" style={{ backgroundImage: `url(${src})` }} role="img" aria-label={alt}>
      <img src={src} alt="" onError={() => setFailed(true)} style={{ display: 'none' }} />
    </div>);
}

// Ownership cue. Owned + consignment items can be hired straight from
// the shelf; cross-hire items can still be requested but the badge
// flags that we'll arrange them with another house. Wording stays
// gentle on purpose — the goal is a soft hint, not friction.
function OwnershipBadge({ ownership, consigner }) {
  if (ownership === 'owned') return null;
  const label = ownership === 'consignment' ? 'Consignment' : 'Cross-hire';
  const title = ownership === 'consignment'
    ? `Held by Valley on consignment${consigner ? ` (${consigner})` : ''}.`
    : 'Sub-rented from a partner house — we arrange this on request.';
  return <span className={`rcat-tag rcat-tag-${ownership}`} title={title}>{label}</span>;
}

function CatalogCard({
  item, onOpen, onQuickAdd, cartQty = 0,
  reviewMode = false, reviewFlag = null, isEditingFlag = false,
  onFlagOpen, onFlagSaveRemove, onFlagSaveEdit, onFlagRemove, onFlagCancel,
}) {
  // Local "just added" tick lives on the card so the affordance stays
  // visible after the click (the floating cart pulses too — see
  // FloatingRentalsSearch — but the user's eye is on the card they
  // just clicked, not at the bottom of the page).
  const [adding, setAdding] = useStateRentals(false);
  const handleQuickAdd = (e) => {
    // The card itself is a button that opens the detail drawer.
    // Stop the click from bubbling, otherwise quick-add + drawer-open
    // fire together and the drawer steals focus.
    e.stopPropagation();
    e.preventDefault();
    if (adding) return;
    onQuickAdd(item);
    setAdding(true);
    setTimeout(() => setAdding(false), 900);
  };
  const handleFlagClick = (e) => {
    e.stopPropagation();
    e.preventDefault();
    if (onFlagOpen) onFlagOpen(item);
  };
  // Three resting states on the quick-add button:
  //   - adding:  briefly shows a green tick (~900ms) after a click
  //   - inCart:  item is already in the request — chip shows the qty
  //              in blue. Hovering swaps the qty → + so the user
  //              knows the next click increments.
  //   - default: empty chip with a +; hover rotates to ×.
  const inCart = cartQty > 0 && !adding;
  // Backwards compat — flags written before the type split default to
  // 'edit' (the original single-action behaviour). is-flagged classes
  // are only applied while review mode is ON so a public visitor (or
  // Max with the mode toggled off) sees the normal user experience —
  // no coloured outlines on cards just because they were flagged in a
  // previous session.
  const flagType = reviewFlag ? (reviewFlag.type || 'edit') : null;
  const isFlagged = !!reviewFlag;
  const showFlagDecoration = reviewMode && isFlagged;
  const showOwnershipCodes = useShowOwnershipCodes();
  const ownershipCode = showOwnershipCodes ? ownershipCodeFor(item) : null;
  return (
    <div className={`rcat-card-wrap ${reviewMode ? 'is-review-mode' : ''} ${showFlagDecoration ? `is-flagged is-flagged-${flagType}` : ''}`}>
      <button type="button" className={`rcat-card ${item.isBundle ? 'is-bundle' : ''}`} onClick={() => onOpen(item)}>
        <CatalogImage src={item.image} alt={item.name} />
        {ownershipCode && (
          <span
            className={`rcat-owner-badge is-${item.ownership}`}
            aria-hidden="true"
            title="Internal ownership code (staff-only)">
            {ownershipCode}
          </span>
        )}
        {item.isBundle && <span className="rcat-kit-badge" aria-label="Kit — bundled package" title="Complete kit — everything listed in the description below">KIT</span>}
        <div className="rcat-body">
          <div className="rcat-sub">{item.subcategory || item.category}</div>
          <div className="rcat-row">
            <h3>{item.name}</h3>
            <span className="rcat-price">{item.isBundle ? 'from ' : ''}£{item.dayRate}/day</span>
          </div>
          <p>{item.shortDesc}</p>
          <div className="rcat-foot">
            {/* OwnershipBadge intentionally removed from cards — the
                "(Consignment)" / "Cross-hire" tag was leaking ownership
                info into the public view. The drawer carries a discreet
                admin-only initials code instead (see rcat-admin-code). */}
            <span className="rcat-cta">View <Icon name="arrow-up-right" size={11} /></span>
          </div>
        </div>
      </button>
      {/* Quick-add lives as a sibling of the card button, not a child —
          nested <button>s are invalid HTML. The wrap div is the
          positioning context. If onQuickAdd isn't passed (e.g. future
          callers on pages with no cart), the button just doesn't
          render. */}
      {onQuickAdd && (
        <button
          type="button"
          className={`rcat-quick-add ${adding ? 'is-added' : ''} ${inCart ? 'has-qty' : ''}`}
          onClick={handleQuickAdd}
          aria-label={
            adding ? `${item.name} added to request`
            : inCart ? `${cartQty} in request — add another ${item.name}`
            : `Quick-add ${item.name} to request`}
          title={
            adding ? 'Added ✓'
            : inCart ? `${cartQty} in request — click to add another`
            : 'Add to request'}>
          {adding ? (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <polyline points="20 6 9 17 4 12" />
            </svg>
          ) : inCart ? (
            // Two stacked children: the count is the resting glyph,
            // "+1" cross-fades in on hover so the user knows the
            // next click bumps the qty. Both are plain text spans
            // (not an svg) — keeps the hover free of the rotation
            // applied to the default-state + icon, which read as
            // unnecessary motion on the already-in-cart chip.
            <React.Fragment>
              <span className="rcat-quick-add-count" aria-hidden="true">{cartQty}</span>
              <span className="rcat-quick-add-plus" aria-hidden="true">+1</span>
            </React.Fragment>
          ) : (
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <line x1="12" y1="5" x2="12" y2="19" />
              <line x1="5" y1="12" x2="19" y2="12" />
            </svg>)}
        </button>)}
      {/* Hidden review-mode flag button — only shown when the user
          has toggled review mode via the Staff Tools menu entry.
          Glyph reflects the action picked:
            +  unflagged (default)
            ✕  flagged for removal
            ✎  flagged for description edit
          Anchors the inline picker / note popover below. */}
      {reviewMode && (
        <button
          type="button"
          className={`rcat-review-flag ${isFlagged ? `is-flagged is-flagged-${flagType}` : ''}`}
          onClick={handleFlagClick}
          aria-label={isFlagged ? `Edit ${flagType} flag for ${item.name}` : `Flag ${item.name} for review`}
          title={isFlagged ? (flagType === 'remove' ? 'Flagged for removal — click to change' : 'Flagged for edit — click to update note') : 'Flag for review'}>
          {!isFlagged ? '+' : flagType === 'remove' ? '✕' : '✎'}
        </button>)}
      {reviewMode && isEditingFlag && (
        <ReviewFlagPopover
          item={item}
          reviewFlag={reviewFlag}
          onSaveRemove={onFlagSaveRemove}
          onSaveEdit={onFlagSaveEdit}
          onRemove={onFlagRemove}
          onCancel={onFlagCancel} />)}
    </div>);
}

// Centered modal for adding a brand-new listing that isn't on the
// shelf yet — fired from the review-mode toolbar's "Add listing"
// button. Name and category are required; description is encouraged
// (so the PDF carries enough context for someone else to enter the
// item into Booqable). Saves as a flag with type='add'.
function AddListingModal({ categories, onSave, onCancel }) {
  const [name, setName] = useStateRentals('');
  const [category, setCategory] = useStateRentals((categories && categories[0]) || '');
  const [note, setNote] = useStateRentals('');
  const nameRef = React.useRef(null);
  React.useEffect(() => {
    if (nameRef.current) nameRef.current.focus();
    const esc = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', esc);
    return () => window.removeEventListener('keydown', esc);
  }, [onCancel]);
  const canSave = name.trim().length > 0;
  const submit = () => { if (canSave) onSave({ name: name.trim(), category, note: note.trim() }); };
  const stop = (e) => e.stopPropagation();
  return (
    <div className="rcat-review-modal-scrim" onClick={onCancel} role="dialog" aria-modal="true" aria-label="Add new listing">
      <div className="rcat-review-modal" onClick={stop}>
        <div className="rcat-review-modal-eyebrow">＋ Add listing</div>
        <h3 className="rcat-review-modal-h">Flag a missing item for Booqable</h3>
        <label className="rcat-review-modal-field">
          <span>Item name</span>
          <input
            ref={nameRef}
            type="text"
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder="e.g. Aputure 600x"
            maxLength={120} />
        </label>
        <label className="rcat-review-modal-field">
          <span>Category</span>
          <select value={category} onChange={(e) => setCategory(e.target.value)}>
            {(categories || []).map((c) => <option key={c} value={c}>{c}</option>)}
            <option value="Other">Other</option>
          </select>
        </label>
        <label className="rcat-review-modal-field">
          <span>Notes</span>
          <textarea
            value={note}
            onChange={(e) => setNote(e.target.value)}
            placeholder="Day-rate, source, why we need it, ownership type…"
            rows={4}
            maxLength={500} />
        </label>
        <div className="rcat-review-modal-actions">
          <div className="rcat-review-popover-spacer" />
          <button type="button" className="rcat-review-btn rcat-review-btn-secondary" onClick={onCancel}>
            Cancel
          </button>
          <button
            type="button"
            className="rcat-review-btn rcat-review-btn-primary"
            onClick={submit}
            disabled={!canSave}>
            Save
          </button>
        </div>
      </div>
    </div>);
}

// Inline popover anchored to the review-mode flag button. Two-stage
// flow: an unflagged item first picks Remove vs Edit; Edit reveals
// a note textarea, Remove saves the flag immediately. A re-opened
// flag shows its current state and lets the user edit the note,
// switch action, or remove the flag entirely.
function ReviewFlagPopover({ item, reviewFlag, onSaveRemove, onSaveEdit, onRemove, onCancel }) {
  const existingType = reviewFlag ? (reviewFlag.type || 'edit') : null;
  // Local stage: 'choose' = pick Remove / Edit; 'edit' = note input.
  // Edit is the default — clicking + on a card lands straight in the
  // note input since "edit the listing" is the common case. Only a
  // re-opened remove flag starts at 'choose' so the user sees their
  // current pick highlighted plus the option to switch.
  const [stage, setStage] = useStateRentals(existingType === 'remove' ? 'choose' : 'edit');
  const [note, setNote] = useStateRentals(reviewFlag ? reviewFlag.note || '' : '');
  const inputRef = React.useRef(null);
  React.useEffect(() => {
    if (stage === 'edit' && inputRef.current) inputRef.current.focus();
  }, [stage]);
  const stop = (e) => e.stopPropagation();
  const handleKey = (e) => {
    if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); onSaveEdit(item, note); }
  };
  return (
    <div className="rcat-review-popover" onClick={stop} onMouseDown={stop} role="dialog" aria-label={`Review action for ${item.name}`}>
      <div className="rcat-review-popover-head">{item.name}</div>

      {stage === 'choose' ? (
        <React.Fragment>
          <p className="rcat-review-popover-prompt">What needs to happen with this listing?</p>
          <div className="rcat-review-choices">
            <button
              type="button"
              className={`rcat-review-choice ${existingType === 'remove' ? 'is-active' : ''}`}
              onClick={() => onSaveRemove(item)}>
              <span className="rcat-review-choice-glyph" aria-hidden="true">✕</span>
              <span className="rcat-review-choice-label">Remove listing</span>
              <span className="rcat-review-choice-sub">Pull this item from Booqable.</span>
            </button>
            <button
              type="button"
              className={`rcat-review-choice ${existingType === 'edit' ? 'is-active' : ''}`}
              onClick={() => setStage('edit')}>
              <span className="rcat-review-choice-glyph" aria-hidden="true">✎</span>
              <span className="rcat-review-choice-label">Edit listing</span>
              <span className="rcat-review-choice-sub">Rewrite description, retag, fix details.</span>
            </button>
          </div>
          <div className="rcat-review-popover-actions">
            {reviewFlag && (
              <button type="button" className="rcat-review-btn rcat-review-btn-danger" onClick={() => onRemove(item.id)}>
                Clear flag
              </button>)}
            <div className="rcat-review-popover-spacer" />
            <button type="button" className="rcat-review-btn rcat-review-btn-secondary" onClick={onCancel}>
              Cancel
            </button>
          </div>
        </React.Fragment>
      ) : (
        <React.Fragment>
          <p className="rcat-review-popover-prompt">
            <span className="rcat-review-popover-eyebrow">Edit listing</span> — describe the change you want:
          </p>
          <textarea
            ref={inputRef}
            className="rcat-review-popover-input"
            value={note}
            onChange={(e) => setNote(e.target.value)}
            onKeyDown={handleKey}
            placeholder="Description rewrite, retag, missing spec, image issue…"
            rows={4}
            maxLength={500}
            spellCheck={true} />
          <div className="rcat-review-popover-actions">
            {reviewFlag ? (
              <button type="button" className="rcat-review-btn rcat-review-btn-danger" onClick={() => onRemove(item.id)}>
                Clear flag
              </button>
            ) : (
              <button
                type="button"
                className="rcat-review-btn rcat-review-btn-ghost"
                onClick={() => onSaveRemove(item)}
                title="Flag for removal instead — no description required">
                <span aria-hidden="true">✕</span> Remove instead
              </button>)}
            <div className="rcat-review-popover-spacer" />
            <button type="button" className="rcat-review-btn rcat-review-btn-secondary" onClick={onCancel}>
              Cancel
            </button>
            <button
              type="button"
              className="rcat-review-btn rcat-review-btn-primary"
              onClick={() => onSaveEdit(item, note)}
              disabled={!note.trim()}>
              Save
            </button>
          </div>
        </React.Fragment>
      )}
    </div>);
}

function CatalogDrawer({ item, qty, onQty, onClose }) {
  const cart = useCart();
  const [justAdded, setJustAdded] = useStateRentals(false);
  const [copied, setCopied] = useStateRentals(false);
  React.useEffect(() => {
    if (!item) return undefined;
    const esc = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', esc);
    document.body.style.overflow = 'hidden';
    return () => { window.removeEventListener('keydown', esc); document.body.style.overflow = ''; };
  }, [item, onClose]);
  React.useEffect(() => { setJustAdded(false); setCopied(false); }, [item]);
  if (!item) return null;

  const handleAdd = () => {
    cart.add(item, qty);
    setJustAdded(true);
    setTimeout(() => onClose(), 700);
  };
  // Build a permalink for this item — equipment page reads ?item=<id>
  // and opens the drawer directly. Click handler copies it to the
  // clipboard with a brief "Copied" confirmation. Falls back to a
  // textarea + execCommand on older browsers (very rare these days).
  const itemUrl = (typeof window !== 'undefined')
    ? `${window.location.origin}/rentals/equipment?item=${encodeURIComponent(item.id)}`
    : `/rentals/equipment?item=${encodeURIComponent(item.id)}`;
  const handleShare = async () => {
    try {
      await navigator.clipboard.writeText(itemUrl);
    } catch {
      // Best-effort fallback for non-secure-context / old browsers.
      try {
        const ta = document.createElement('textarea');
        ta.value = itemUrl;
        ta.style.position = 'fixed';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
      } catch {}
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 1600);
  };
  // Admin-only ownership code — O-VR (owned), C-XX (consignment), X-XX
  // (cross-hire). Gated by the staff-tools "Show ownership codes"
  // toggle so customers never see it; staff flip it on via Cmd+/.
  // ownerInitials is written by the nightly Booqable sync from the
  // ``Consignor: <name>`` / ``Cross-Hire: <name>`` markers — owned
  // items get a uniform "O-VR" since Valley Rentals owns all owned
  // kit. Renders top-right of the drawer, just left of the close
  // button. Stays opaque (2-letter code reveals nothing about the
  // underlying name) so the surface is safe even if accidentally
  // left on with a customer looking over a staffer's shoulder.
  const showOwnershipCodes = useShowOwnershipCodes();
  const adminCode = showOwnershipCodes ? ownershipCodeFor(item) : null;
  return (
    <div className="rcat-drawer-scrim" onClick={onClose}>
      <aside className="rcat-drawer" onClick={(e) => e.stopPropagation()} aria-label={`${item.name} details`}>
        <button type="button" className="rcat-drawer-close" onClick={onClose} aria-label="Close">×</button>
        {adminCode && (
          <span
            className="rcat-drawer-admin-code"
            aria-hidden="true"
            title="Internal ownership code">
            {adminCode}
          </span>)}
        <CatalogImage src={item.image} alt={item.name} />
        <div className="rcat-drawer-body">
          <div className="rcat-sub">{item.category}{item.subcategory && item.subcategory.toLowerCase() !== item.category.toLowerCase() ? ` · ${item.subcategory}` : ''}</div>
          <h2>
            {item.name}
            {item.isBundle && <span className="rcat-kit-inline">KIT</span>}
          </h2>
          {/* Price: just the plain day-rate, no "from" prefix for kits
              and no "no VAT" disclaimer — those belong in the cart /
              quote where they have proper context. Item-level cards
              just state the price as-is. */}
          <div className="rcat-drawer-price">£{item.dayRate}<span>/day</span></div>
          {/* Prefer the full description (item.desc) when the sync
              has written it; fall back to the truncated shortDesc on
              older snapshots. The drawer is the only surface that
              needs the untruncated text — grid cards keep using
              shortDesc to limit card height. */}
          <p className="rcat-drawer-desc">{item.desc || item.shortDesc}</p>
          {item.specs && item.specs.length > 0 && (
            <dl className="rcat-specs">
              {item.specs.map((s, i) => (
                <div key={i}>
                  <dt>{s.k}</dt>
                  <dd>{s.v}</dd>
                </div>))}
            </dl>)}
          {item.isBundle && Array.isArray(item.bundleIncludes) && item.bundleIncludes.length > 0 && (
            <div className="rcat-bundle-includes">
              <div className="rcat-bundle-includes-h">Includes</div>
              <ul>
                {item.bundleIncludes.map((line, i) => <li key={i}>{line}</li>)}
              </ul>
            </div>)}
          <div className="rcat-qty-row">
            <label htmlFor="rcat-qty">Quantity</label>
            <div className="rcat-qty-stepper">
              <button type="button" onClick={() => onQty(Math.max(1, qty - 1))} aria-label="Decrease">−</button>
              <input id="rcat-qty" type="number" min={1} value={qty} onChange={(e) => onQty(Math.max(1, parseInt(e.target.value || '1', 10)))} />
              <button type="button" onClick={() => onQty(qty + 1)} aria-label="Increase">+</button>
            </div>
          </div>
          {/* Action row — share button on the LEFT (20% width, same
              pill style as the primary CTA but secondary fill), Add
              to request on the RIGHT (the dominant 80%). Both share
              the same chip language; primary contrast comes from the
              fill colour and width ratio rather than typography. */}
          <div className="rcat-drawer-actions">
            <button
              type="button"
              className={`rcat-drawer-share ${copied ? 'is-copied' : ''}`}
              onClick={handleShare}
              aria-label={copied ? 'Link copied' : 'Copy link to this item'}
              title={copied ? 'Copied to clipboard' : 'Copy link to this item'}>
              {copied ? (
                <>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12" /></svg>
                  <span>Copied</span>
                </>
              ) : (
                <>
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" /><polyline points="16 6 12 2 8 6" /><line x1="12" y1="2" x2="12" y2="15" /></svg>
                  <span>Share</span>
                </>)}
            </button>
            <button type="button" className={`rcat-add-btn ${justAdded ? 'is-added' : ''}`} onClick={handleAdd} disabled={justAdded}>
              {justAdded ? <>Added ✓</> : <>Add to request <Icon name="arrow-up-right" size={12} /></>}
            </button>
          </div>
        </div>
      </aside>
    </div>);
}

function EquipmentPage({ onGoto }) {
  // One cart instance for the whole page — passed down to each
  // CatalogCard so the + buttons can quick-add without each card
  // mounting its own storage listener (the grid renders 50+ cards).
  // cartQtyMap lets every card cheaply look up its current qty (for
  // the "1 in request — add another" hover state) without iterating
  // the whole items array on each render.
  const cart = useCart();
  const quickAdd = React.useCallback((item) => cart.add(item, 1), [cart]);
  const cartQtyMap = React.useMemo(() => {
    const m = new Map();
    for (const it of cart.items) m.set(it.id, it.qty);
    return m;
  }, [cart.items]);

  // Shared-cart hydration. ?cart=<slug:qty,…> on the URL means a quote
  // link was opened — REPLACE the local cart with the shared set, show
  // a one-shot banner, and strip the param so a manual refresh doesn't
  // re-hydrate over the user's later edits. Skips silently if the URL
  // param is empty or all slugs are unknown to the catalog.
  const [sharedCartBanner, setSharedCartBanner] = useStateRentals(null);
  React.useEffect(() => {
    if (!sharedCartStr || typeof window === 'undefined') return;
    const incoming = decodeCartFromShareUrl(sharedCartStr);
    if (!incoming.length) return;
    const catalog = Array.isArray(window.RENTALS_CATALOG) ? window.RENTALS_CATALOG : [];
    const byId = new Map(catalog.map((c) => [c.id, c]));
    const resolved = [];
    let unknown = 0;
    for (const inc of incoming) {
      const cat = byId.get(inc.id);
      if (!cat) { unknown++; continue; }
      resolved.push({
        id: cat.id,
        name: cat.name,
        qty: inc.qty,
        ownership: cat.ownership || 'owned',
        ...(cat.ownerInitials ? { ownerInitials: cat.ownerInitials } : {}),
      });
    }
    if (!resolved.length) return;
    cart.replace(resolved);
    setSharedCartBanner({ added: resolved.length, unknown });
    // Strip the param so refresh-after-edit doesn't clobber the user's
    // later changes.
    try {
      const url = new URL(window.location.href);
      url.searchParams.delete('cart');
      window.history.replaceState(null, '', url.pathname + url.search + url.hash);
    } catch (e) { /* */ }
  // sharedCartStr is captured at mount-time so we only run this once,
  // even if the cart hook recomputes.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Catalog comes pre-sorted by category. We re-sort here on a
  // composite rank: price descending (the dominant signal — most-
  // expensive at the top of every chip) with two soft ownership
  // penalties applied first:
  //
  //   - owned       → no penalty (full price)
  //   - consignment → 3% haircut, so it loses ties to owned at the
  //                   same price but stays adjacent
  //   - cross-hire  → 50% haircut, so x-hires cluster much lower
  //                   in the list — visible (we still want users
  //                   to know we can source it) but never appearing
  //                   above directly-comparable in-house kit. A £400
  //                   x-hire ends up ranking around £200, etc.
  //
  // Soft penalties (rather than strict tier sort) means price still
  // dominates broadly — a £500 x-hire still beats a £40 owned
  // accessory — but the bias is firmly towards Valley's own kit.
  // Ties fall back to a name-A→Z sort for stable ordering between
  // refreshes.
  const all = React.useMemo(() => {
    const raw = (typeof window !== 'undefined' && Array.isArray(window.RENTALS_CATALOG)) ? window.RENTALS_CATALOG : [];
    const sortRank = (it) => {
      const own = it.ownership || 'owned';
      const rate = it.dayRate || 0;
      if (own === 'cross-hire') return rate * 0.50;
      if (own === 'consignment') return rate * 0.97;
      return rate;
    };
    return [...raw].sort((a, b) => {
      const d = sortRank(b) - sortRank(a);
      if (d !== 0) return d;
      return (a.name || '').localeCompare(b.name || '');
    });
  }, []);
  // Seed the search / open-drawer from URL params — set by the nav-bar
  // search flyout. ?q=foo → preload the search box. ?item=<id> → open
  // that item's drawer directly without filtering the grid.
  const { seedQ, seedItemId, sharedCartStr } = (() => {
    if (typeof window === 'undefined') return { seedQ: '', seedItemId: null, sharedCartStr: '' };
    try {
      const sp = new URLSearchParams(window.location.search);
      return {
        seedQ: sp.get('q') || '',
        seedItemId: sp.get('item') || null,
        sharedCartStr: sp.get('cart') || '',
      };
    } catch (e) { return { seedQ: '', seedItemId: null, sharedCartStr: '' }; }
  })();
  const [query, setQuery] = useStateRentals(seedQ);
  const [activeCats, setActiveCats] = useStateRentals(new Set());
  const [activeSubs, setActiveSubs] = useStateRentals(new Set());
  // Staff-only "owned only" filter — when on, only items where
  // ownership === 'owned' (VF-owned kit) survive the filter pipeline.
  // Useful for building a clean-slate quote where the WINTERLIN50 /
  // WETHIRE30 codes give the full % to every line (consignment kit
  // would only get half; cross-hire would get zero). Mirrors the
  // Custom-quote-mode toggle (so it only renders + persists when
  // staff is actively building quotes).
  const [ownedOnly, setOwnedOnly] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-owned-only') === '1'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    try { localStorage.setItem('vf-rentals-owned-only', ownedOnly ? '1' : '0'); } catch (e) { /* */ }
  }, [ownedOnly]);
  // Live-listen for the Cmd+/ → Staff Tools → "Owned kit only" toggle
  // so flipping it from the shortcuts menu updates the catalog in
  // place (no re-render needed). Same race-safe pattern as quoteStaff
  // and pdf-comet modes.
  React.useEffect(() => {
    const onToggle = () => setOwnedOnly((v) => !v);
    window.addEventListener('vf-rentals-owned-only-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-owned-only-toggle', onToggle);
  }, []);
  // Staff-mode flag mirrors CartDrawer's "Custom quote mode" state so
  // we know whether to surface the owned-only chip on the equipment
  // page. Independent of EquipmentPage's Review Mode.
  const [quoteStaffOn, setQuoteStaffOn] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-quote-staff-mode') === '1'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    const onToggle = () => setQuoteStaffOn((v) => !v);
    window.addEventListener('vf-rentals-quote-staff-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-quote-staff-toggle', onToggle);
  }, []);
  // When the user hovers a parent chip, we surface its sub-chips
  // in the row above as a preview — clicking is what commits the
  // selection. On mouse-leave, the row reverts to whichever
  // parent is currently active (or hides if nothing is active).
  const [hoveredCat, setHoveredCat] = useStateRentals(null);
  // Delay-close pattern for the sub-category popover. mouseleave on
  // either the parent chip OR the popover schedules a 400ms timeout
  // that clears hoveredCat; any mouseenter inside that window cancels
  // the close, so the user has time to drift down into the popover
  // and click a sub-chip without the row disappearing under them.
  const subsCloseTimerRef = React.useRef(null);
  const enterSubsHover = (cat) => {
    if (subsCloseTimerRef.current) {
      clearTimeout(subsCloseTimerRef.current);
      subsCloseTimerRef.current = null;
    }
    setHoveredCat(cat);
  };
  const scheduleSubsClose = () => {
    if (subsCloseTimerRef.current) clearTimeout(subsCloseTimerRef.current);
    // Linger longer than the previous 700ms — gives the user more time
    // to drift between the parent chip + the popover without it
    // closing under them.
    subsCloseTimerRef.current = setTimeout(() => {
      setHoveredCat(null);
      subsCloseTimerRef.current = null;
    }, 1000);
  };

  // Stage the sub-row unmount so the CSS exit fade has time to play.
  // `lingerSubs` holds the most recently visible sub list so that when
  // visibleSubs goes empty (parent deselected / cursor left after
  // timeout) the row stays in the DOM with `is-leaving` class for
  // SUBS_LEAVE_MS, then clears. Mirrors the CSS exit duration above —
  // bump both in lockstep.
  const SUBS_LEAVE_MS = 700;
  const [lingerSubs, setLingerSubs] = useStateRentals([]);
  const [lingerCat, setLingerCat] = useStateRentals(null);
  const [subsLeaving, setSubsLeaving] = useStateRentals(false);
  const subsLeaveTimerRef = React.useRef(null);
  const lingerSubsSigRef = React.useRef('');
  const [openItem, setOpenItem] = useStateRentals(() => {
    if (!seedItemId) return null;
    return all.find((it) => it.id === seedItemId) || null;
  });
  const [openQty, setOpenQty] = useStateRentals(1);

  // Sub-categories indexed by parent. Built from the catalog so any
  // new Booqable sub-collection auto-appears after a sync — no code
  // change needed. Ordered first by item-count desc (most-populated
  // sub-chips first) then by name for stable ties.
  const subsByCategory = React.useMemo(() => {
    const counts = new Map();  // cat → Map(sub → n)
    for (const it of all) {
      if (!it.subcategory) continue;
      const m = counts.get(it.category) || new Map();
      m.set(it.subcategory, (m.get(it.subcategory) || 0) + 1);
      counts.set(it.category, m);
    }
    const out = {};
    for (const [cat, subMap] of counts) {
      out[cat] = [...subMap.entries()]
        .sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
        .map(([sub]) => sub);
    }
    return out;
  }, [all]);

  // Sub-row "what should be shown right now" — hoisted out of the JSX
  // so the staged-unmount effect below can read it. hovered parent
  // wins (during a hover), else the single active cat (when only one
  // is selected), else nothing.
  const subRowCat = hoveredCat || (activeCats.size === 1 ? [...activeCats][0] : null);
  const subRowSubs = subRowCat ? (subsByCategory[subRowCat] || []) : [];

  // Stage the sub-row unmount: when subRowSubs goes empty, keep the
  // previous content in `lingerSubs` (rendered with .is-leaving) for
  // SUBS_LEAVE_MS so the CSS exit fade has time to play; once that
  // timer fires, clear the linger state and the row finally unmounts.
  useEffectRentals(() => {
    const sig = `${subRowCat || ''}::${subRowSubs.join(',')}`;
    if (subRowSubs.length > 0) {
      // Fresh content — cancel any pending unmount and adopt the new
      // list as the visible content. Use a signature so we don't churn
      // state on identical re-renders.
      if (subsLeaveTimerRef.current) {
        clearTimeout(subsLeaveTimerRef.current);
        subsLeaveTimerRef.current = null;
      }
      if (sig !== lingerSubsSigRef.current) {
        lingerSubsSigRef.current = sig;
        setLingerSubs(subRowSubs);
        setLingerCat(subRowCat);
      }
      if (subsLeaving) setSubsLeaving(false);
    } else if (lingerSubs.length > 0 && !subsLeaving) {
      // Transitioning to empty — start the leaving fade and unmount
      // after SUBS_LEAVE_MS. Keep the existing lingerSubs content so
      // the fade has something to render.
      setSubsLeaving(true);
      if (subsLeaveTimerRef.current) clearTimeout(subsLeaveTimerRef.current);
      subsLeaveTimerRef.current = setTimeout(() => {
        setLingerSubs([]);
        setLingerCat(null);
        setSubsLeaving(false);
        lingerSubsSigRef.current = '';
        subsLeaveTimerRef.current = null;
      }, SUBS_LEAVE_MS);
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [subRowCat, subRowSubs.length, subRowSubs.join(',')]);

  // Live-filter wiring: the floating search bar dispatches
  // 'vf-equipment-search' events as the user types. We update the
  // local query state on every event from the bar. On mount we also
  // broadcast the URL-seeded query (with fromBar:false) so the bar's
  // input mirrors what's in the URL.
  useEffectRentals(() => {
    const handler = (e) => {
      if (!e.detail || !e.detail.fromBar) return;
      const next = e.detail.query || '';
      setQuery((cur) => cur === next ? cur : next);
    };
    window.addEventListener('vf-equipment-search', handler);
    return () => window.removeEventListener('vf-equipment-search', handler);
  }, []);

  useEffectRentals(() => {
    // Broadcast the initial query once so the floating bar can mirror
    // the URL's ?q= on first paint.
    if (seedQ) {
      window.dispatchEvent(new CustomEvent('vf-equipment-search', { detail: { query: seedQ, fromBar: false } }));
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // When the drawer closes, strip ?item= from the URL so the back
  // button does what the user expects (returns to the prior page,
  // not "back to the drawer"). Keep ?q= so the filtered view persists.
  const closeDrawer = () => {
    setOpenItem(null);
    if (typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('item')) {
      const sp = new URLSearchParams(window.location.search);
      sp.delete('item');
      const next = '/rentals/equipment' + (sp.toString() ? `?${sp.toString()}` : '');
      window.history.replaceState(null, '', next);
    }
  };

  // Fuse instance — recreated when the catalog changes (which is daily
  // at most, post-cron). Tags + name weighted highest, then subcategory.
  const fuse = React.useMemo(() => {
    if (typeof window === 'undefined' || typeof window.Fuse !== 'function') return null;
    return new window.Fuse(all, {
      keys: [
        { name: 'name', weight: 0.36 },
        { name: 'tags', weight: 0.20 },
        { name: 'bundleIncludes', weight: 0.14 },
        { name: 'subcategory', weight: 0.12 },
        { name: 'category', weight: 0.08 },
        { name: 'shortDesc', weight: 0.06 },
        { name: 'desc', weight: 0.04 },
      ],
      threshold: 0.38, ignoreLocation: true, minMatchCharLength: 2,
    });
  }, [all]);

  const filtered = React.useMemo(() => {
    let list = all;
    const q = query.trim();
    if (q && fuse) list = fuse.search(q).map((r) => r.item);
    if (activeCats.size > 0) {
      list = list.filter((it) => activeCats.has(it.category));
      // Sub-filter only applies to subs that belong to the active
      // parent(s) — irrelevant subs (from a different parent) are
      // ignored. If any relevant sub is active, the list narrows
      // to items matching one of them; otherwise all items in the
      // active parent(s) show.
      const relevantActiveSubs = new Set();
      for (const sub of activeSubs) {
        for (const cat of activeCats) {
          if ((subsByCategory[cat] || []).includes(sub)) {
            relevantActiveSubs.add(sub);
            break;
          }
        }
      }
      if (relevantActiveSubs.size > 0) {
        list = list.filter((it) => relevantActiveSubs.has(it.subcategory));
      }
    }
    // Staff-only ownership filter — only applies when the toggle is
    // explicitly on (otherwise customers + staff both see the full
    // catalog). Treats undefined ownership as 'owned' so legacy
    // entries without the tag don't accidentally disappear.
    if (ownedOnly) {
      list = list.filter((it) => (it.ownership || 'owned') === 'owned');
    }
    // Ownership penalty: cross-hire kit pushed to the bottom. Owned +
    // consignment are treated as co-equal (rank 0) — both can be hired
    // straight from VF's shelf, so a consignment camera kit shouldn't
    // rank behind an owned adapter. Applied LAST so it overrides any
    // prior ordering (catalog source order or Fuse relevance during a
    // search). JS sort is stable since ES2019, so items within the
    // same ownership tier keep their relative position. Customers +
    // staff both see this — cross-hire is genuinely lower-priority
    // (VF doesn't own it, more logistics overhead per booking) so
    // it's deprioritised across the board.
    const OWNERSHIP_RANK = { 'owned': 0, 'consignment': 0, 'cross-hire': 1 };
    list = list.slice().sort((a, b) => {
      const ra = OWNERSHIP_RANK[a.ownership || 'owned'] ?? 0;
      const rb = OWNERSHIP_RANK[b.ownership || 'owned'] ?? 0;
      return ra - rb;
    });
    return list;
  }, [all, query, activeCats, activeSubs, subsByCategory, fuse, ownedOnly]);

  // 0-results logger. Fires fire-and-forget to /api/rentals/log-search-miss
  // when the user has typed something that returned no items. Two safety
  // nets to avoid spam:
  //   • 1.5s debounce so we don't log every keystroke mid-typing
  //   • per-session de-dup (Set in a ref) so the same miss only logs
  //     once per browsing session
  // Filter-only misses (no query, just an active filter with 0 hits) are
  // skipped — they're a UX issue, not a missing-product signal.
  const loggedMissesRef = React.useRef(new Set());
  React.useEffect(() => {
    const q = query.trim();
    if (!q || q.length < 2) return;
    if (filtered.length > 0) return;
    if (loggedMissesRef.current.has(q.toLowerCase())) return;
    const timer = setTimeout(() => {
      loggedMissesRef.current.add(q.toLowerCase());
      try {
        fetch('/api/rentals/log-search-miss', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            query: q,
            activeCats: [...activeCats],
            activeSubs: [...activeSubs],
            ownedOnly,
            ts: Date.now(),
          }),
          // Beacon-like — don't block the page on a slow response, and
          // don't throw on a non-2xx so the page never sees the error.
          keepalive: true,
        }).catch(() => {});
      } catch (e) { /* never block the catalog on a logger blip */ }
    }, 1500);
    return () => clearTimeout(timer);
  }, [query, filtered.length, activeCats, activeSubs, ownedOnly]);

  // Single-select chip behaviour for parents — clicking a category
  // switches the filter to JUST that category. Clicking the active
  // chip a second time clears it. When the active parent changes,
  // any sub-chips bound to the old parent get dropped.
  const toggleCat = (cat) => {
    const isCurrentlyActive = activeCats.has(cat) && activeCats.size === 1;
    if (isCurrentlyActive) {
      setActiveCats(new Set());
      setActiveSubs(new Set());
    } else {
      setActiveCats(new Set([cat]));
      // Keep only subs that belong to the new active cat.
      const validSubs = new Set(subsByCategory[cat] || []);
      setActiveSubs(new Set([...activeSubs].filter((s) => validSubs.has(s))));
    }
  };
  // Sub-chips are additive — multiple can be active inside one
  // parent. Clicking a sub auto-activates its parent if not already.
  const toggleSub = (sub, parentCat) => {
    const next = new Set(activeSubs);
    if (next.has(sub)) next.delete(sub);
    else next.add(sub);
    setActiveSubs(next);
    if (!activeCats.has(parentCat)) {
      // Auto-activate the parent so the filter actually narrows
      // (sub filter is gated on parent being active above).
      setActiveCats(new Set([parentCat]));
    }
  };
  const clearFilters = () => {
    setQuery(''); setActiveCats(new Set()); setActiveSubs(new Set());
    window.dispatchEvent(new CustomEvent('vf-equipment-search', { detail: { query: '', fromBar: false } }));
  };

  const onOpen = (item) => { setOpenItem(item); setOpenQty(1); };
  const onClose = () => closeDrawer();

  // ─────────────────────────────────────────────────────────────────
  // Hidden Booqable-issue review mode.
  //
  // Toggled by Cmd+Opt+Shift+B (Ctrl+Alt+Shift+B on non-Mac). Esc
  // exits. Not discoverable from any UI — chord-only entry. Shift is
  // included so the chord doesn't collide with Safari's Cmd+Opt+R
  // "Reload Page From Origin" shortcut.
  //
  // When on, each equipment card gets a small "+" button (turns "✓"
  // once flagged). Clicking opens a small note popover anchored to
  // the button. A floating toolbar shows the flag count plus export
  // / clear / exit actions. State persists to localStorage so a
  // refresh mid-review doesn't lose work.
  // ─────────────────────────────────────────────────────────────────
  const [reviewMode, setReviewMode] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-review-mode') === '1'; } catch (e) { return false; }
  });
  const [reviewFlags, setReviewFlags] = useStateRentals(() => {
    if (typeof window === 'undefined') return [];
    try {
      const raw = localStorage.getItem('vf-rentals-review-flags');
      const arr = raw ? JSON.parse(raw) : [];
      return Array.isArray(arr) ? arr : [];
    } catch (e) { return []; }
  });
  // The id of the item whose note popover is open (null = no popover).
  const [editingFlagId, setEditingFlagId] = useStateRentals(null);
  const [exportingReview, setExportingReview] = useStateRentals(false);
  // Add Listing modal — opens from the floating toolbar's "Add listing"
  // button. Separate from editingFlagId because there's no source card
  // to anchor the popover to; the modal is a centered overlay.
  const [addListingOpen, setAddListingOpen] = useStateRentals(false);

  // Persist on every change so a tab close / refresh mid-review keeps
  // the work. localStorage is per-browser so other users never see
  // these — the mode + flags are tied to whoever has the chord.
  React.useEffect(() => {
    try { localStorage.setItem('vf-rentals-review-mode', reviewMode ? '1' : '0'); } catch (e) { /* */ }
  }, [reviewMode]);
  React.useEffect(() => {
    try { localStorage.setItem('vf-rentals-review-flags', JSON.stringify(reviewFlags)); } catch (e) { /* */ }
  }, [reviewFlags]);

  // O(1) lookup of flag-by-id so each CatalogCard doesn't have to
  // .find() through the whole list on every render.
  const flagsById = React.useMemo(() => {
    const m = new Map();
    for (const f of reviewFlags) m.set(f.id, f);
    return m;
  }, [reviewFlags]);

  // No keyboard chord — Equipment Revision Mode is triggered solely
  // from the Staff Tools button in the shortcuts menu (Cmd+/ to open).
  // Esc still closes the popover or exits the mode in-page so users
  // aren't stuck. The shortcuts menu dispatches `vf-rentals-review-
  // toggle` to drive our React state without hoisting it.
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.key === 'Escape') {
        if (editingFlagId) { setEditingFlagId(null); return; }
        if (reviewMode) { setReviewMode(false); }
      }
    };
    window.addEventListener('keydown', onKey);
    const onExternalToggle = () => {
      setEditingFlagId(null);
      setReviewMode((m) => !m);
    };
    window.addEventListener('vf-rentals-review-toggle', onExternalToggle);
    return () => {
      window.removeEventListener('keydown', onKey);
      window.removeEventListener('vf-rentals-review-toggle', onExternalToggle);
    };
  }, [editingFlagId, reviewMode]);

  // Each flag has a `type`: 'remove' (drop the listing), 'edit' (rewrite
  // the description — note is required), or 'add' (a new item that's
  // not yet on the shelf — synthetic id, no source catalog entry).
  // Backwards compat: flags loaded from localStorage without `type`
  // default to 'edit' (the original single-action behaviour).
  //
  // openFlagPickerFor just opens the popover for an item — the actual
  // flag isn't created until the user picks Remove vs Edit. That way
  // hitting `+` then immediately Cancel doesn't litter the list.
  const openFlagPickerFor = React.useCallback((item) => {
    setEditingFlagId(item.id);
  }, []);
  const baseFlagFor = (item) => ({
    id: item.id,
    slug: item.id, // slug == id in this catalog
    name: item.name,
    dayRate: item.dayRate,
    category: item.category || '—',
    subcategory: item.subcategory || '',
    ownership: item.ownership || 'owned',
    image: item.image || '',
    ts: Date.now(),
  });
  const saveAsRemove = React.useCallback((item) => {
    setReviewFlags((prev) => {
      const others = prev.filter((f) => f.id !== item.id);
      return [...others, { ...baseFlagFor(item), type: 'remove', note: '' }];
    });
    setEditingFlagId(null);
  }, []);
  const saveAsEdit = React.useCallback((item, note) => {
    setReviewFlags((prev) => {
      const others = prev.filter((f) => f.id !== item.id);
      return [...others, { ...baseFlagFor(item), type: 'edit', note: (note || '').trim() }];
    });
    setEditingFlagId(null);
  }, []);
  const addNewListing = React.useCallback(({ name, category, note }) => {
    const id = `_add_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
    setReviewFlags((prev) => [...prev, {
      id,
      slug: '(new)',
      name: (name || '').trim(),
      dayRate: 0,
      category: (category || '—').trim(),
      subcategory: '',
      ownership: 'n/a',
      type: 'add',
      note: (note || '').trim(),
      ts: Date.now(),
    }]);
  }, []);
  const removeFlag = React.useCallback((id) => {
    setReviewFlags((prev) => prev.filter((f) => f.id !== id));
    setEditingFlagId(null);
  }, []);
  const clearAllFlags = React.useCallback(() => {
    if (typeof window === 'undefined') return;
    if (!window.confirm(`Clear all ${reviewFlags.length} flagged item${reviewFlags.length === 1 ? '' : 's'}?`)) return;
    setReviewFlags([]);
    setEditingFlagId(null);
  }, [reviewFlags.length]);
  const exportReviewPdf = React.useCallback(async () => {
    if (reviewFlags.length === 0 || exportingReview) return;
    setExportingReview(true);
    try {
      await buildReviewPdf(reviewFlags);
    } catch (err) {
      // Surface to console for diagnosis; toolbar stays open so the
      // user can retry. No toast — the chord-mode UI is internal-only.
      console.error('Review PDF export failed', err);
      if (typeof window !== 'undefined') window.alert("Couldn't build the review PDF — see browser console for details.");
    } finally {
      setExportingReview(false);
    }
  }, [reviewFlags, exportingReview]);

  return (
    <main className="page active rentals-light" data-screen-label="03b Rentals · Equipment">
      <section className="rcat-page" aria-label="Equipment catalog">
        <header className="rcat-head">
          <div className="rcat-head-text">
            <div className="eyebrow"><span className="idx">VR—2.0</span> Equipment</div>
            <h1>The full <em>shelf</em>.</h1>
            <p>Cameras, glass, lighting, grip, audio and monitoring — packages too. Request a quote; we'll confirm dates and price by email.</p>
          </div>
        </header>
        {sharedCartBanner && (
          <div className="rcat-shared-banner" role="status">
            <span>
              ✓ Loaded shared cart with <b>{sharedCartBanner.added}</b> item{sharedCartBanner.added === 1 ? '' : 's'}
              {sharedCartBanner.unknown > 0 && <> · {sharedCartBanner.unknown} skipped (no longer in catalog)</>}
            </span>
            <button type="button" className="rcat-shared-banner-close" onClick={() => setSharedCartBanner(null)} aria-label="Dismiss">×</button>
          </div>)}

        <div className="rcat-results rcat-results-wide">
          <div className="rcat-results-head">
            <span>
              {filtered.length} {filtered.length === 1 ? 'item' : 'items'}
              {query ? ` matching “${query}”` : ''}
              {activeCats.size > 0 ? ` · ${[...activeCats].join(', ')}` : ''}
              {activeSubs.size > 0 ? ` › ${[...activeSubs].join(', ')}` : ''}
            </span>
            {(activeCats.size > 0 || activeSubs.size > 0 || query) && (
              <button type="button" className="rcat-clear" onClick={clearFilters}>Clear filters</button>)}
          </div>
          {filtered.length === 0 ? (
            <div className="rcat-empty rcat-empty-prominent">
              <div className="rcat-empty-eyebrow">Nothing matches that</div>
              <h3 className="rcat-empty-title">
                {query ? <>Haven't got what you're looking for?</> : <>No items match these filters</>}
              </h3>
              <p className="rcat-empty-blurb">
                {query
                  ? <>Shoot us a quick email with what you're after — we can usually source it from a partner facility within 24 hours.</>
                  : <>Try clearing the filters, or get in touch with a kit list and we'll quote it.</>}
              </p>
              <div className="rcat-empty-actions">
                <a className="rcat-empty-cta" href={`mailto:rentals@valley.film?subject=${encodeURIComponent('Kit request' + (query ? `: ${query}` : ''))}&body=${encodeURIComponent('Hi Valley Rentals,\n\nI\'m looking for' + (query ? ` "${query}"` : ' some kit') + ' — can you help source it?\n\nThanks')}`}>
                  rentals@valley.film
                </a>
                {(activeCats.size > 0 || activeSubs.size > 0 || query) && (
                  <button type="button" className="rcat-empty-clear" onClick={clearFilters}>Clear filters</button>)}
              </div>
            </div>) : (
            <div className="rcat-grid">
              {filtered.map((it) => (
                <CatalogCard
                  key={it.id}
                  item={it}
                  onOpen={onOpen}
                  onQuickAdd={quickAdd}
                  cartQty={cartQtyMap.get(it.id) || 0}
                  reviewMode={reviewMode}
                  reviewFlag={flagsById.get(it.id) || null}
                  isEditingFlag={editingFlagId === it.id}
                  onFlagOpen={openFlagPickerFor}
                  onFlagSaveRemove={saveAsRemove}
                  onFlagSaveEdit={saveAsEdit}
                  onFlagRemove={removeFlag}
                  onFlagCancel={() => setEditingFlagId(null)} />))}
            </div>)}
        </div>

        {/* Floating category-chips strip — pairs visually with the
            bottom-centred floating search bar. Lives in fixed position
            above it. Only renders on the equipment page (its rules
            in CSS are scoped to body.is-rentals-equipment).

            Two stacked rows inside one glass panel:
              · Top row (rcat-chips-row-subs) — sub-chips for whatever
                parent is currently active OR hovered (preview).
                Hidden when nothing is selected/hovered.
              · Bottom row (rcat-chips-row-main) — the top-level
                category chips, always visible. */}
        {(() => {
          const present = new Set(all.map((it) => it.category).filter(Boolean));
          const ordered = CATALOG_CATEGORIES.filter((c) => present.has(c));
          const extras = [...present].filter((c) => !CATALOG_CATEGORIES.includes(c)).sort();
          const orderedCats = [...ordered, ...extras];
          // The render uses `lingerSubs` / `lingerCat` (managed by the
          // staged-unmount effect above) so the row can fade out
          // smoothly when subRowSubs goes empty. `is-open` plays the
          // entrance, `is-leaving` plays the exit; only one is active
          // at a time.
          const rowMounted = lingerSubs.length > 0;
          const rowOpen = rowMounted && !subsLeaving;
          // .has-subs-row tracks MOUNT, not OPEN — the parent keeps its
          // rounded-rect shape (vs the single-row pill) for the full
          // duration of the exit fade, so the border-radius doesn't
          // snap to 999px while the sub-row's still on screen mid-fade.
          return (
            <div className={`rcat-floating-chips ${rowMounted ? 'has-subs-row' : ''}`} role="toolbar" aria-label="Filter by category">
              {rowMounted && (
                <div
                  className={`rcat-chips-row rcat-chips-row-subs ${rowOpen ? 'is-open' : 'is-leaving'}`}
                  role="menu"
                  aria-label={`${lingerCat} sub-categories`}
                  onMouseEnter={() => enterSubsHover(lingerCat)}
                  onMouseLeave={scheduleSubsClose}>
                  {lingerSubs.map((sub) => {
                    const subCount = all.filter((it) => it.category === lingerCat && it.subcategory === sub).length;
                    const subActive = activeSubs.has(sub) && activeCats.has(lingerCat);
                    return (
                      <button
                        key={sub}
                        type="button"
                        role="menuitemcheckbox"
                        aria-checked={subActive}
                        className={`rcat-chip rcat-subchip ${subActive ? 'is-on' : ''}`}
                        onClick={() => toggleSub(sub, lingerCat)}>
                        {sub} <span>{subCount}</span>
                      </button>);
                  })}
                </div>)}
              <div className="rcat-chips-row rcat-chips-row-main">
                {orderedCats.map((cat) => {
                  const count = all.filter((it) => it.category === cat).length;
                  if (count === 0) return null;
                  const subs = subsByCategory[cat] || [];
                  const isActive = activeCats.has(cat);
                  return (
                    <button
                      key={cat}
                      type="button"
                      className={`rcat-chip ${isActive ? 'is-on' : ''} ${subs.length > 0 ? 'has-subs' : ''}`}
                      onClick={() => toggleCat(cat)}
                      onMouseEnter={() => subs.length > 0 && enterSubsHover(cat)}
                      onMouseLeave={scheduleSubsClose}
                      aria-haspopup={subs.length > 0 ? 'menu' : undefined}
                      aria-expanded={subs.length > 0 ? isActive : undefined}>
                      {cat} <span>{count}</span>
                    </button>);
                })}
                {/* "Owned only" chip removed — the filter is now toggled
                    exclusively from Cmd+/ → Staff Tools → "Owned kit
                    only". State + filter logic stay (see ownedOnly
                    above + the filter useMemo below); the chip just no
                    longer renders in the catalog UI. */}
              </div>
            </div>);
        })()}

        <CatalogDrawer item={openItem} qty={openQty} onQty={setOpenQty} onClose={onClose} />

        {/* Review-mode toolbar — appears only after Cmd+Opt+Shift+B
            toggles the mode on. Top-right of the viewport so it
            doesn't fight with the floating search bar at the bottom.
            Internal-only — no public links lead here. The breakdown
            (R/E/A) only renders when at least one of each exists so
            the bar stays compact when the queue is small. */}
        {reviewMode && (() => {
          const counts = reviewFlags.reduce((acc, f) => {
            const t = f.type || 'edit';
            acc[t] = (acc[t] || 0) + 1;
            return acc;
          }, {});
          return (
          <div className="rcat-review-toolbar" role="toolbar" aria-label="Review mode toolbar">
            <span className="rcat-review-toolbar-label">Review mode</span>
            <span className="rcat-review-toolbar-count">
              {reviewFlags.length} flagged
            </span>
            {reviewFlags.length > 0 && (
              <span className="rcat-review-toolbar-breakdown" aria-hidden="true">
                {counts.remove > 0 && <span className="rcat-review-tag is-remove" title={`${counts.remove} to remove`}>✕ {counts.remove}</span>}
                {counts.edit   > 0 && <span className="rcat-review-tag is-edit"   title={`${counts.edit} to edit`}>✎ {counts.edit}</span>}
                {counts.add    > 0 && <span className="rcat-review-tag is-add"    title={`${counts.add} to add`}>＋ {counts.add}</span>}
              </span>)}
            <div className="rcat-review-toolbar-spacer" />
            <button
              type="button"
              className="rcat-review-btn rcat-review-btn-secondary"
              onClick={() => setAddListingOpen(true)}>
              + Add listing
            </button>
            <button
              type="button"
              className="rcat-review-btn rcat-review-btn-primary"
              disabled={reviewFlags.length === 0 || exportingReview}
              onClick={exportReviewPdf}>
              {exportingReview ? 'Building…' : 'Export PDF'}
            </button>
            <button
              type="button"
              className="rcat-review-btn rcat-review-btn-secondary"
              disabled={reviewFlags.length === 0}
              onClick={clearAllFlags}>
              Clear all
            </button>
            <button
              type="button"
              className="rcat-review-btn rcat-review-btn-ghost"
              onClick={() => { setReviewMode(false); setEditingFlagId(null); setAddListingOpen(false); }}
              title="Exit review mode (Esc)">
              Exit
            </button>
          </div>);
        })()}
        {reviewMode && addListingOpen && (
          <AddListingModal
            categories={CATALOG_CATEGORIES}
            onSave={(payload) => { addNewListing(payload); setAddListingOpen(false); }}
            onCancel={() => setAddListingOpen(false)} />)}
      </section>
    </main>);

}

// 1-hour collection / return slots within the Mon–Sat 08:30–18:00 window,
// rounded to whole hours. Sunday's tighter 10:00–16:00 window is left for
// the user to self-select within; the After-Hours sentinels at top/bottom
// cover anything outside the staffed window — flat £25 fee applies, see
// the "Business hours" FAQ entry.
const COLLECTION_TIME_SLOTS = [
  { value: 'after-hours-early', label: 'After hours — Early' },
  '09:00', '10:00', '11:00', '12:00', '13:00',
  '14:00', '15:00', '16:00', '17:00', '18:00',
  { value: 'after-hours-late', label: 'After hours — Late' }
].map((s) => typeof s === 'string' ? { value: s, label: s } : s);

// Pin to the exact spot on The Vale where collections meet — Street View
// vantage from outside the main entrance.
const RENTALS_MAP_URL = 'https://www.google.com/maps/place/Valley+Rentals/@51.5060216,-0.2597282,3a,75y,90t/data=!3m8!1e2!3m6!1sAF1QipMktesP_g3BZOwzVlFsAoDmxergAgimMUVFRj8F!2e10!3e12!6shttps:%2F%2Flh3.googleusercontent.com%2Fp%2FAF1QipMktesP_g3BZOwzVlFsAoDmxergAgimMUVFRj8F%3Dw203-h141-k-no!7i1377!8i957!4m7!3m6!1s0x48760f2a724d1c33:0xaa5d67f6abb38bfc!8m2!3d51.5060981!4d-0.2595013!10e5!16s%2Fg%2F11vbw9dmpt';

function RentalsMapCard() {
  // Locked to SE-up (visible faces: top + east + south) — the angle
  // that puts north at ~2 o'clock on the compass. The swivel toggle
  // was removed in favour of this fixed orientation.
  const viewAngle = 0;

  // World coords always: +X = east, +Y = south, +Z = up. The projection
  // formulas swap between viewAngles so the same world coords render
  // from a different camera position.
  const OX = 240, OY = 198, S = 11;
  const C = 0.866 * S;
  const H = 0.5 * S;
  const ix = (x, y) => +(OX + (viewAngle === 0 ? (x - y) : (x + y)) * C).toFixed(2);
  const iy = (x, y, z = 0) => +(OY + (viewAngle === 0 ? (x + y) : (y - x)) * H - z * S).toFixed(2);
  const P = (x, y, z = 0) => `${ix(x, y)},${iy(x, y, z)}`;
  const Poly = (pts) => pts.map((p) => P(p[0], p[1], p[2] || 0)).join(' ');

  // 3D box. Always shows the south wall + top + one side wall. In SE-up
  // the side wall is the east face (x=x+w). In SW-up it's the west face
  // (x=x). Painter's order within the box: side wall → south wall → top.
  const drawBox = (k, x, y, w, d, h, c, stroke = '#CFD3DC', sw = 0.6) => {
    const sideWallPts = viewAngle === 0 ?
    [[x + w, y, 0], [x + w, y + d, 0], [x + w, y + d, h], [x + w, y, h]] :
    [[x, y, 0], [x, y + d, 0], [x, y + d, h], [x, y, h]];

    return (
      <g key={k}>
        <polygon
          points={Poly(sideWallPts)}
          fill={c.side} stroke={stroke} strokeWidth={sw} strokeLinejoin="round" />
        <polygon
          points={Poly([[x, y + d, 0], [x + w, y + d, 0], [x + w, y + d, h], [x, y + d, h]])}
          fill={c.south} stroke={stroke} strokeWidth={sw} strokeLinejoin="round" />
        <polygon
          points={Poly([[x, y, h], [x + w, y, h], [x + w, y + d, h], [x, y + d, h]])}
          fill={c.top} stroke={stroke} strokeWidth={sw} strokeLinejoin="round" />
      </g>);

  };

  const drawPlate = (k, x, y, w, d, fill, stroke, sw = 0.5, dash) => (
    <polygon
      key={k}
      points={Poly([[x, y, 0], [x + w, y, 0], [x + w, y + d, 0], [x, y + d, 0]])}
      fill={fill} stroke={stroke} strokeWidth={sw} strokeDasharray={dash} strokeLinejoin="round" />);


  const drawTree = (k, x, y, scale = 1, dark = '#7AB89C', light = '#A6D2BB') => {
    const cx = ix(x, y);
    const cy = iy(x, y, 0);
    const r = 5.5 * scale;
    return (
      <g key={k}>
        <ellipse cx={cx + r * 0.18} cy={cy + r * 0.18} rx={r * 0.85} ry={r * 0.35} fill="#0A0F1A" opacity="0.08" />
        <circle cx={cx} cy={cy - r * 0.7} r={r} fill={dark} />
        <circle cx={cx - r * 0.32} cy={cy - r * 1.0} r={r * 0.55} fill={light} />
      </g>);

  };

  // Palettes — `south` is the long visible facade (the Valley-blue feature
  // wall on Access House). `side` is the short end wall (lighter accent).
  const dim = { top: '#F7F8FA', south: '#CFD3DC', side: '#E6E8EE' };
  const dim2 = { top: '#FAFBFD', south: '#D7DBE2', side: '#EFF1F5' };
  const mainBldg = { top: '#FFFFFF', south: '#095EDF', side: '#E1ECFB' };
  const mainLow = { top: '#F4F8FE', south: '#0E58CE', side: '#CFDDF4' };
  const pinkBldg = { top: '#F4E5E1', south: '#C99D94', side: '#E0BBB3' };

  // Buildings — footprints matched to Apple Maps. Access House is a
  // main block plus a lower south wing where the roller gate sits.
  const BUILDINGS = [
    // West of Eastman Rd
    { id: 'shell', x: -6.4, y: 2.5, w: 1.8, d: 0.9, h: 0.7, c: dim2 },
    { id: 'shell-canopy', x: -6.2, y: 3.4, w: 1.4, d: 0.7, h: 0.45, c: { top: '#FFE787', south: '#D2AF36', side: '#F2D55B' } },
    { id: 'ukcreams', x: -8.6, y: 4.6, w: 3, d: 2.4, h: 1.9, c: dim },
    { id: 'sw-block', x: -8.6, y: 7.3, w: 3, d: 1.4, h: 1.2, c: dim2 },
    { id: 'eastman-mid', x: -8.6, y: 2, w: 1.6, d: 1.6, h: 1.2, c: dim2 },

    // Eastman-side small building south of Shell
    { id: 'gopuff', x: -5, y: 4.0, w: 1.7, d: 1.0, h: 1.2, c: dim2 },
    { id: 'rising-phoenix', x: -3.5, y: 7, w: 2.3, d: 1.4, h: 1.3, c: dim2 },

    // Access House — main block (tall, 3 storeys) + lower south wing
    { id: 'ah-main', x: -2.5, y: 0.3, w: 5.0, d: 1.55, h: 3, c: mainBldg, stroke: '#095EDF', sw: 1.25 },
    { id: 'ah-wing', x: -0.5, y: 1.85, w: 3.0, d: 0.7, h: 2, c: mainLow, stroke: '#095EDF', sw: 1 },

    // Lavish Oud — small pink-tinted building east of Access House
    { id: 'lavish-oud', x: 2.85, y: 0.6, w: 1.65, d: 1.5, h: 1.6, c: pinkBldg, stroke: '#A6857D', sw: 0.7 },

    // East row — Kiss Gyms / AW Scents / Hire Tech
    { id: 'kissgym', x: 5.2, y: 0.2, w: 2.1, d: 1.0, h: 1.6, c: dim },
    { id: 'aw-scents', x: 5.2, y: 1.6, w: 1.9, d: 1.2, h: 1.4, c: dim2 },
    { id: 'east-mid', x: 5.2, y: 3.2, w: 2.0, d: 1.3, h: 1.5, c: dim2 },
    { id: 'hire-tech', x: 5.2, y: 4.8, w: 2.2, d: 1.5, h: 1.6, c: dim },

    // South row — Howdens + China & Co + filler
    { id: 'howdens', x: -2.0, y: 5.6, w: 4.0, d: 1.3, h: 1.5, c: dim },
    { id: 'china-co', x: 2.6, y: 5.7, w: 2.2, d: 1.4, h: 1.5, c: dim },
    { id: 'south-far', x: -1.2, y: 7.2, w: 4.5, d: 1.4, h: 1.3, c: dim2 }];


  // Painter's order — sort by the building's front-corner depth so back
  // buildings draw first. Front corner depends on view: SE corner in
  // SE-up, SW corner in SW-up.
  const frontDepth = (b) => viewAngle === 0 ?
  b.x + b.w + b.y + b.d :
  -b.x + b.y + b.d;
  const sortedBuildings = [...BUILDINGS].sort((a, b) => frontDepth(a) - frontDepth(b));


  // Park trees (in Acton Park, north of The Vale)
  const PARK_TREES = [
  [-10, -8.7, 0.85], [-7.5, -9.2, 1], [-5, -8.6, 0.95], [-2.5, -9, 1.1],
  [0.5, -8.8, 0.95], [3, -9.1, 1.05], [5.5, -8.6, 0.9], [8, -9, 0.95],
  [10, -8.5, 0.85],
  [-9, -6.7, 0.95], [-6, -7, 0.9], [-3, -6.6, 1], [0, -7.1, 0.95],
  [3, -6.8, 0.9], [6, -7, 0.95], [9, -6.6, 0.85],
  [-10, -4.7, 0.85], [-7, -4.9, 0.9], [-4, -4.6, 0.95], [-1, -5.1, 0.9],
  [3.5, -4.8, 0.95], [6.5, -4.5, 0.85], [9, -4.9, 0.8],
  [-8, -2.7, 0.85], [-5, -3, 0.9], [-1.5, -2.6, 0.85], [4, -2.8, 0.9],
  [7, -2.5, 0.8], [9.5, -2.9, 0.75]];


  // Trees lining streets / yards on the south side
  const STREET_TREES = [
  [-4.05, 2.5, 0.55], [-4.05, 4.8, 0.55], [-4.05, 7.0, 0.55],
  [4.45, 1.5, 0.55], [4.45, 3.8, 0.55], [4.45, 6.2, 0.55],
  [-4.2, 9.4, 0.55], [4.55, 9.4, 0.55]];


  // The MAIN ENTRANCE callout tag is parked outside the building outline.
  // SE-up: building's south face sits in the lower-LEFT of the canvas →
  // tag lower-right. SW-up: south face sits in the lower-RIGHT → tag
  // lower-left.
  const entranceTag = viewAngle === 0 ?
  { tx: 332, ty: 308, w: 132 } :
  { tx: 18, ty: 308, w: 132 };
  const parkingTag = viewAngle === 0 ?
  { tx: 18, ty: 274, w: 80 } :
  { tx: 384, ty: 274, w: 80 };

  // Compass — in SE-up, north points upper-right (-30° from horizontal).
  // In SW-up, north points upper-left (-150° / 210°).
  const compassRot = viewAngle === 0 ? -30 : -150;
  const compassNX = viewAngle === 0 ? 11 : -11;

  return (
    <div className="rentals-map-card">
      <h3>Finding the studio</h3>
      <p className="rentals-map-sub">Across The Vale from Acton Park. Long facade faces the road; entrance is the roller gate at the rear of the yard.</p>

      <div className="rentals-map-frame">
        <a
          className="rentals-map-svg-link"
          href={RENTALS_MAP_URL}
          target="_blank"
          rel="noopener noreferrer"
          aria-label="Open Valley Rentals in Google Maps">

          <svg
            className="rentals-map-svg"
            viewBox="0 0 480 360"
            xmlns="http://www.w3.org/2000/svg"
            role="img"
            aria-hidden="true">

            <defs>
              <linearGradient id="rmSky" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0%" stopColor="#F4F7F4" />
                <stop offset="55%" stopColor="#FFFFFF" />
              </linearGradient>
              {/* Soft shadow used under each building footprint to give
                  the buildings a hint of weight against the road plane. */}
              <radialGradient id="rmBldgShadow" cx="50%" cy="50%" r="50%">
                <stop offset="0%" stopColor="#0A0F1A" stopOpacity="0.18" />
                <stop offset="100%" stopColor="#0A0F1A" stopOpacity="0" />
              </radialGradient>
            </defs>

            <rect width="480" height="360" fill="url(#rmSky)" />

            {/* === ACTON PARK — large green plate, north of The Vale === */}
            {drawPlate('park', -11.5, -10, 23, 8.8, '#CFE7D4', '#94BFA0', 0.6)}

            {/* Park paths — soft curved lines */}
            <path
              d={`M ${ix(-3.5, -9.5)} ${iy(-3.5, -9.5)} Q ${ix(-1, -7)} ${iy(-1, -7)}, ${ix(1.5, -4.5)} ${iy(1.5, -4.5)} T ${ix(2.5, -1.5)} ${iy(2.5, -1.5)}`}
              stroke="#94BFA0" strokeWidth="0.7" fill="none" opacity="0.75" />

            <path
              d={`M ${ix(4, -9.5)} ${iy(4, -9.5)} Q ${ix(2.5, -7)} ${iy(2.5, -7)}, ${ix(3.5, -4.5)} ${iy(3.5, -4.5)} T ${ix(6, -1.5)} ${iy(6, -1.5)}`}
              stroke="#94BFA0" strokeWidth="0.7" fill="none" opacity="0.75" />


            {/* Park trees */}
            {PARK_TREES.map((t, i) =>
            <React.Fragment key={`pt${i}`}>
                {drawTree(`pt${i}`, t[0], t[1], t[2], '#5DA384', '#88C2A4')}
              </React.Fragment>
            )}

            {/* === ROADS === */}
            {drawPlate('vale', -11.5, -1.2, 23, 1.2, '#EFF1F5', '#CFD3DC', 0.65)}
            <line
              x1={ix(-11.5, -0.6)} y1={iy(-11.5, -0.6)}
              x2={ix(11.5, -0.6)} y2={iy(11.5, -0.6)}
              stroke="#CFD3DC" strokeWidth="0.75" strokeDasharray="6 5" />

            {drawPlate('eastman', -4.6, 0, 0.7, 9.4, '#EFF1F5', '#CFD3DC', 0.55)}
            {drawPlate('stanley', 7.3, 0, 0.7, 9.4, '#EFF1F5', '#CFD3DC', 0.55)}
            {drawPlate('centre', 1.2, -3, 0.7, 1.8, '#EFF1F5', '#CFD3DC', 0.5)}
            {drawPlate('side-east', 4.2, 0, 0.7, 9.4, '#EFF1F5', '#CFD3DC', 0.5)}
            {drawPlate('inner', -2.5, 2.55, 5.0, 3.05, '#EFF1F5', '#CFD3DC', 0.5)}

            {/* === Access House parking lot — sits adjacent to the
                wing's south face, between building and the yard road === */}
            {drawPlate('ah-parking', -1.2, 2.55, 4.4, 1.0, '#F1F6FE', '#095EDF', 0.85, '3 2')}
            {[-0.3, 0.5, 1.3, 2.1, 2.9].map((bx, i) =>
            <line key={`pb${i}`}
              x1={ix(bx, 2.55)} y1={iy(bx, 2.55, 0)}
              x2={ix(bx, 3.55)} y2={iy(bx, 3.55, 0)}
              stroke="#8FB7F5" strokeWidth="0.55" />

            )}

            {/* === BUILDING SHADOWS — drawn before the buildings so each
                building's box settles on a soft ground-shadow patch. Cheap
                visual weight that makes the iso feel less flat. === */}
            {sortedBuildings.map((b) => {
              const cx = ix(b.x + b.w / 2, b.y + b.d / 2);
              const cy = iy(b.x + b.w / 2, b.y + b.d / 2, 0);
              const rx = (b.w + b.d) * 5.5;
              const ry = (b.w + b.d) * 2.8;
              return <ellipse key={`sh-${b.id}`} cx={cx} cy={cy + 2} rx={rx} ry={ry} fill="url(#rmBldgShadow)" />;
            })}

            {/* === BUILDINGS === */}
            {sortedBuildings.map((b) =>
            drawBox(b.id, b.x, b.y, b.w, b.d, b.h, b.c, b.stroke || '#CFD3DC', b.sw || 0.6)
            )}

            {/* Access House details — main block south facade has window
                strips, the lower south wing has the roller gate where
                the entrance actually is. */}
            <polyline
              points={Poly([[-2.3, 1.85, 2.5], [2.3, 1.85, 2.5]])}
              fill="none" stroke="#FFFFFF" strokeWidth="1" opacity="0.7" />

            <polyline
              points={Poly([[-2.3, 1.85, 1.7], [2.3, 1.85, 1.7]])}
              fill="none" stroke="#FFFFFF" strokeWidth="1" opacity="0.55" />


            {/* Roller gate — on the wing's south face at y=2.55 */}
            <polygon
              points={Poly([[1.0, 2.55, 0], [2.0, 2.55, 0], [2.0, 2.55, 1.5], [1.0, 2.55, 1.5]])}
              fill="#5A6478" stroke="#3A4252" strokeWidth="0.5" strokeLinejoin="round" />

            {/* Roller gate slats */}
            {[0.18, 0.36, 0.55, 0.74, 0.93, 1.12, 1.31, 1.50].map((zh, i) =>
            <line key={`slat${i}`}
              x1={ix(1.0, 2.55)} y1={iy(1.0, 2.55, zh)}
              x2={ix(2.0, 2.55)} y2={iy(2.0, 2.55, zh)}
              stroke="#3A4252" strokeWidth="0.35" />

            )}

            {/* Pedestrian door beside the roller gate */}
            <polygon
              points={Poly([[2.15, 2.55, 0], [2.4, 2.55, 0], [2.4, 2.55, 1.3], [2.15, 2.55, 1.3]])}
              fill="#063F96" stroke="none" />


            {/* Street trees */}
            {STREET_TREES.map((t, i) =>
            <React.Fragment key={`st${i}`}>
                {drawTree(`st${i}`, t[0], t[1], t[2])}
              </React.Fragment>
            )}

            {/* === LABELS === */}
            <text
              x={ix(-3, -6)} y={iy(-3, -6, 0)}
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="11"
              fontWeight="700"
              letterSpacing="0.22em"
              fill="#3F7553">
              ACTON PARK
            </text>

            {/* Road labels — small mono caps following the iso projection.
                Helps visitors orient against Google Maps without needing
                to open Maps in a new tab. */}
            <text
              x={ix(-1, -0.6)} y={iy(-1, -0.6, 0) - 1}
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="9"
              fontWeight="700"
              letterSpacing="0.20em"
              fill="#8089A0">
              THE VALE
            </text>
            <text
              x={ix(-4.25, 5.5)} y={iy(-4.25, 5.5, 0)}
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="8"
              fontWeight="700"
              letterSpacing="0.18em"
              fill="#8089A0"
              transform={`rotate(${viewAngle === 0 ? 30 : -30}, ${ix(-4.25, 5.5)}, ${iy(-4.25, 5.5, 0)})`}>
              EASTMAN RD
            </text>
            <text
              x={ix(7.65, 5.5)} y={iy(7.65, 5.5, 0)}
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="8"
              fontWeight="700"
              letterSpacing="0.18em"
              fill="#8089A0"
              transform={`rotate(${viewAngle === 0 ? 30 : -30}, ${ix(7.65, 5.5)}, ${iy(7.65, 5.5, 0)})`}>
              STANLEY GDNS
            </text>

            {/* Shell station pin — distinctive yellow/red badge so the
                landmark stands out from the generic dim2 buildings.
                Sits floating above the Shell footprint. */}
            {(() => {
              const sx = ix(-5.5, 2.95);
              const sy = iy(-5.5, 2.95, 0.7) - 14;
              return (
                <g>
                  <ellipse cx={sx} cy={sy + 16} rx="11" ry="2.6" fill="#0A0F1A" opacity="0.18" />
                  <path
                    d={`M ${sx} ${sy + 14} L ${sx - 7} ${sy + 4} A 7 7 0 1 1 ${sx + 7} ${sy + 4} Z`}
                    fill="#FBCE07" stroke="#D69E00" strokeWidth="0.8" />
                  <text
                    x={sx} y={sy + 6}
                    textAnchor="middle"
                    dominantBaseline="middle"
                    fontFamily="JetBrains Mono, ui-monospace, monospace"
                    fontSize="6.5"
                    fontWeight="800"
                    letterSpacing="0.06em"
                    fill="#E03A1F">
                    SHELL
                  </text>
                </g>);
            })()}


            {/* === COMPASS === */}
            <g transform="translate(444, 32)">
              <circle r="15" fill="#FFFFFF" stroke="#CFD3DC" strokeWidth="0.9" />
              <g transform={`rotate(${compassRot})`}>
                <polygon points="10,0 -3.5,-3 -3.5,3" fill="#095EDF" />
                <polygon points="-10,0 -3.5,-3 -3.5,3" fill="#9AA3B5" />
              </g>
              <text
                x={compassNX} y="-7"
                textAnchor="middle"
                dominantBaseline="middle"
                fontFamily="JetBrains Mono, ui-monospace, monospace"
                fontSize="10"
                fontWeight="700"
                fill="#4B5468">
                N
              </text>
            </g>

            {/* === CALLOUTS === */}
            {/* MAIN ENTRANCE — anchored on the roller gate */}
            {(() => {
              const ax = ix(1.5, 2.55);
              const ay = iy(1.5, 2.55, 0.75);
              const { tx, ty, w: tw } = entranceTag;
              const connectX = viewAngle === 0 ? tx : tx + tw;
              return (
                <g>
                  <line x1={ax} y1={ay} x2={connectX} y2={ty} stroke="#095EDF" strokeWidth="1" />
                  <circle cx={ax} cy={ay} r="3" fill="#FFFFFF" stroke="#095EDF" strokeWidth="1.25" />
                  <rect x={tx} y={ty - 11} width={tw} height="22" rx="3"
                    fill="#FFFFFF" stroke="#095EDF" strokeWidth="1" />
                  <text x={tx + tw / 2} y={ty + 4} textAnchor="middle"
                    fontFamily="JetBrains Mono, ui-monospace, monospace"
                    fontSize="11" fontWeight="700" letterSpacing="0.14em" fill="#095EDF">
                    MAIN ENTRANCE
                  </text>
                </g>);

            })()}

            {/* PARKING — anchored on the parking lot */}
            {(() => {
              const ax = ix(1.0, 3.05);
              const ay = iy(1.0, 3.05, 0);
              const { tx, ty, w: tw } = parkingTag;
              const connectX = viewAngle === 0 ? tx + tw : tx;
              return (
                <g>
                  <line x1={ax} y1={ay} x2={connectX} y2={ty} stroke="#095EDF" strokeWidth="1" />
                  <circle cx={ax} cy={ay} r="3" fill="#FFFFFF" stroke="#095EDF" strokeWidth="1.25" />
                  <rect x={tx} y={ty - 11} width={tw} height="22" rx="3"
                    fill="#FFFFFF" stroke="#095EDF" strokeWidth="1" />
                  <text x={tx + tw / 2} y={ty + 4} textAnchor="middle"
                    fontFamily="JetBrains Mono, ui-monospace, monospace"
                    fontSize="11" fontWeight="700" letterSpacing="0.14em" fill="#095EDF">
                    PARKING
                  </text>
                </g>);

            })()}

            {/* === Address strip === */}
            <rect x="0" y="336" width="480" height="24" fill="#F7F8FA" />
            <text
              x="240" y="352"
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="11"
              fontWeight="700"
              letterSpacing="0.18em"
              fill="#4B5468">
              207–211 THE VALE  ·  ACTON  ·  W3 7QS
            </text>
          </svg>

          <span className="rentals-map-cta">
            <span>Open in Google Maps</span>
            <Icon name="arrow-up-right" />
          </span>
        </a>

      </div>
    </div>);

}

// ───────────────────────────────────────────────────────────────────
// Delivery-zones map — stylised top-down plan of Greater London showing
// the two charging zones we quote against: anywhere inside the M25
// (£45/hr) and the central London CCZ (£65/hr). Small drops <10kg are
// flat-rated and don't depend on zone, so they're called out below
// rather than drawn. Visual language mirrors the iso studio map —
// soft palette, monospace labels — but top-down because the city is
// far too wide for iso to read.
// ───────────────────────────────────────────────────────────────────
// Towns labelled around / inside the M25 — gives a Londoner immediate
// orientation without needing the full borough tessellation. Positions
// are stylised, not OS-grade. Inner labels render slightly darker so
// they're legible against the blue zone fill.
const LONDON_TOWNS = [
  // Outside the M25 — softer text
  { name: 'Watford',     x: 175, y: 50,  out: true },
  { name: 'Borehamwood', x: 220, y: 52,  out: true },
  { name: 'Enfield',     x: 290, y: 65,  out: true },
  { name: 'Romford',     x: 395, y: 130, out: true },
  { name: 'Dartford',    x: 410, y: 250, out: true },
  { name: 'Sutton',      x: 220, y: 332, out: true },
  { name: 'Sevenoaks',   x: 380, y: 340, out: true },
  { name: 'Slough',      x: 30,  y: 195, out: true },
  // Inside the M25 — town labels reading over the blue zone
  { name: 'Edgware',     x: 200, y: 110 },
  { name: 'Harrow',      x: 145, y: 130 },
  { name: 'Wembley',     x: 185, y: 155 },
  { name: 'Ealing',      x: 158, y: 190 },
  { name: 'London',      x: 250, y: 198, big: true },
  { name: 'Bexleyheath', x: 360, y: 240 },
  { name: 'Bromley',     x: 320, y: 280 },
  { name: 'Croydon',     x: 275, y: 290 },
  { name: 'Kingston',    x: 165, y: 280 },
  { name: 'Twickenham',  x: 145, y: 245 },
  { name: 'Hounslow',    x: 115, y: 220 },
  { name: 'Ilford',      x: 350, y: 175 },
  { name: 'Barking',     x: 360, y: 200 },
  { name: 'Mitcham',     x: 220, y: 280 },
];

// Motorway badges — UK-style yellow shield with green outline.
// Positioned at the M25 perimeter where each radial leaves the orbital.
const LONDON_MOTORWAYS = [
  { code: 'M1',  x: 215, y: 38 },
  { code: 'A1',  x: 255, y: 38 },
  { code: 'M11', x: 335, y: 56 },
  { code: 'A12', x: 425, y: 110 },
  { code: 'A13', x: 435, y: 170 },
  { code: 'A2',  x: 425, y: 248 },
  { code: 'M20', x: 410, y: 295 },
  { code: 'M26', x: 360, y: 322 },
  { code: 'M23', x: 270, y: 340 },
  { code: 'M25', x: 240, y: 25, big: true },
  { code: 'M3',  x: 130, y: 330 },
  { code: 'M4',  x: 38,  y: 220 },
  { code: 'A40', x: 35,  y: 155 },
  { code: 'M40', x: 35,  y: 100 },
  { code: 'A406', x: 95, y: 100 },
];

function RentalsDeliveryMap() {
  // Greater London, Google-Maps-style overlay. Two translucent zones —
  // blue for within-M25 (£45/hr), pink for the CCZ (£65/hr) — sit on
  // a soft cream backdrop. Town labels around the perimeter and small
  // motorway shield badges give a Londoner instant orientation.
  //
  // The M25 outline is hand-traced as a polygon (not a smooth oval)
  // so it has the characteristic lumps the real motorway has:
  // Watford / Rickmansworth bulge NW, M11 lobe NE near Waltham
  // Abbey, Brentwood / Romford bulge E, Dartford crossing dip SE,
  // Reigate flat S, Chertsey / Leatherhead bulge SW, Heathrow
  // straight-ish W. Coords are clockwise from due north.
  const M25 = "M 240 52 L 280 56 L 322 68 L 358 88 L 388 118 L 408 152 L 420 188 L 422 218 L 408 246 L 388 268 L 366 292 L 332 308 L 296 318 L 252 322 L 210 320 L 172 312 L 136 298 L 104 280 L 78 256 L 62 226 L 56 196 L 58 164 L 70 134 L 88 108 L 116 84 L 152 68 L 196 58 L 240 52 Z";
  const CCZ = "M 228 175 C 218 162, 240 152, 260 154 C 280 156, 295 168, 295 185 C 295 205, 280 218, 258 218 C 235 218, 220 205, 222 190 C 222 184, 223 180, 228 175 Z";

  // Borough hairlines — short suggestive divider strokes inside the
  // M25 so the area reads as "subdivided London" rather than a flat
  // blob (matches the texture of the TfL ULEZ reference). These are
  // suggestive, not geographically accurate — the goal is character,
  // not cartography.
  const BOROUGH_LINES = [
    'M 90 220 L 150 215',           // Hounslow ↔ Hammersmith
    'M 160 90 L 175 145',           // Barnet ↔ Camden
    'M 320 95 L 305 160',           // Enfield ↔ Hackney
    'M 380 180 L 340 200',           // Redbridge ↔ Newham
    'M 380 235 L 320 235',           // Barking ↔ Greenwich
    'M 300 300 L 290 240',           // Bromley spur
    'M 220 305 L 230 245',           // Croydon spur
    'M 130 290 L 165 250',           // Sutton ↔ Wandsworth
    'M 290 88 L 280 140',            // Haringey ↔ Islington
    'M 250 230 L 250 290',           // Southwark/Lambeth axis
  ];
  // Thames — west to east through the centre, with the Isle of Dogs
  // southward loop and the Greenwich peninsula northward loop.
  const THAMES = "M 78 200 Q 110 205, 138 198 Q 165 192, 192 200 Q 215 206, 232 208 Q 250 210, 268 206 Q 280 204, 287 215 Q 290 232, 282 240 Q 273 246, 286 246 Q 296 246, 298 232 Q 300 218, 306 210 Q 314 198, 321 204 Q 325 212, 322 224 Q 320 234, 332 230 Q 343 226, 348 215 Q 354 205, 372 208 Q 396 212, 432 205";

  // Valley Rentals — Acton in west London, just inside the M25.
  const VR = { x: 168, y: 188 };

  return (
    <div className="rentals-map-card rentals-delivery-map">
      <h3>Delivery zones</h3>
      <p className="rentals-map-sub">Driven delivery is billed return-trip from Acton. Two rate bands by where you're shooting.</p>

      <div className="rentals-map-frame">
        <svg
          className="rentals-map-svg"
          viewBox="0 0 480 360"
          xmlns="http://www.w3.org/2000/svg"
          role="img"
          aria-label="Stylised map of Greater London showing the M25 ring, Congestion Charge Zone, and our delivery rate bands">

          {/* Cream backdrop — closer to Google Maps' off-white land tone */}
          <rect width="480" height="336" fill="#FAF7F0" />

          {/* Surrounding water (just at the far east where the Thames
              widens into the Estuary) — a faint blue wash. */}
          <rect x="430" y="200" width="50" height="136" fill="#D9E7F3" opacity="0.65" />

          {/* Thames — pale blue ribbon */}
          <path d={THAMES} stroke="#BCD8EA" strokeWidth="3.5" fill="none" strokeLinecap="round" />
          <path d={THAMES} stroke="#9EC8E0" strokeWidth="1" fill="none" strokeLinecap="round" opacity="0.6" />

          {/* Define a clip path matching the M25 outline so borough
              hairlines drawn beneath it can't bleed out into the
              surrounding cream backdrop. */}
          <defs>
            <clipPath id="vf-m25-clip">
              <path d={M25} />
            </clipPath>
          </defs>

          {/* Within-M25 zone — translucent blue. The outline is a
              polygonal trace (not a smooth oval) so it carries the
              characteristic lumps of the real motorway. */}
          <path d={M25} fill="rgba(91, 124, 226, 0.42)" stroke="rgba(70, 100, 200, 0.80)" strokeWidth="1.4" strokeLinejoin="round" />

          {/* Borough-hairline texture — short divider strokes inside
              the M25, suggesting subdivided London without being
              cartographically literal. Clipped so any stroke that
              extends past the M25 boundary gets trimmed cleanly. */}
          <g clipPath="url(#vf-m25-clip)" stroke="rgba(70, 100, 200, 0.32)" strokeWidth="0.6" strokeLinecap="round" fill="none">
            {BOROUGH_LINES.map((d, i) => <path key={i} d={d} />)}
          </g>

          {/* CCZ — translucent pink/red */}
          <path d={CCZ} fill="rgba(232, 100, 110, 0.55)" stroke="rgba(210, 80, 90, 0.75)" strokeWidth="1.1" />

          {/* Town labels — outside M25 (lighter), inside M25 (darker) */}
          {LONDON_TOWNS.map((t) => (
            <text key={t.name} x={t.x} y={t.y}
              textAnchor="middle"
              fontFamily="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
              fontSize={t.big ? 13 : 8.5}
              fontWeight={t.big ? 700 : 500}
              fill={t.out ? '#9AA3B5' : '#2F3A52'}
              opacity={t.out ? 0.85 : 0.95}
              pointerEvents="none">
              {t.name}
            </text>))}

          {/* Motorway badges — yellow with green outline (UK signage) */}
          {LONDON_MOTORWAYS.map((m) => {
            const w = m.big ? 24 : 18;
            const h = m.big ? 12 : 10;
            return (
              <g key={m.code}>
                <rect x={m.x - w / 2} y={m.y - h / 2} width={w} height={h} rx={2}
                  fill="#FEE26D" stroke="#3F7253" strokeWidth="0.9" />
                <text x={m.x} y={m.y + (m.big ? 4 : 3.5)}
                  textAnchor="middle"
                  fontFamily="-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
                  fontSize={m.big ? 8 : 7}
                  fontWeight="700"
                  fill="#1F3A1F">
                  {m.code}
                </text>
              </g>);
          })}

          {/* Valley Rentals pin — sits in Acton, west London */}
          <g>
            <line x1={VR.x} y1={VR.y} x2={VR.x} y2={VR.y - 26} stroke="#0A0F1A" strokeWidth="0.9" />
            <circle cx={VR.x} cy={VR.y} r="4" fill="#0A0F1A" />
            <rect x={VR.x - 56} y={VR.y - 38} width="112" height="22" rx="11"
              fill="#0A0F1A" stroke="#0A0F1A" strokeWidth="0.9" />
            <text x={VR.x} y={VR.y - 23} textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="10" fontWeight="700" letterSpacing="0.14em" fill="white">
              VALLEY RENTALS
            </text>
          </g>

          {/* Scale bar — bottom-right */}
          <g transform="translate(395, 312)">
            <line x1="0" y1="0" x2="40" y2="0" stroke="#4B5468" strokeWidth="1.4" />
            <line x1="0" y1="-3" x2="0" y2="3" stroke="#4B5468" strokeWidth="1.4" />
            <line x1="20" y1="-2" x2="20" y2="2" stroke="#4B5468" strokeWidth="1.2" />
            <line x1="40" y1="-3" x2="40" y2="3" stroke="#4B5468" strokeWidth="1.4" />
            <text x="20" y="-7"
              textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="8" fontWeight="700" letterSpacing="0.08em" fill="#4B5468">
              ~ 10 MI
            </text>
          </g>

          {/* Compass rose — top-right */}
          <g transform="translate(444, 32)">
            <circle r="13" fill="white" stroke="#CFD3DC" strokeWidth="0.9" />
            <polygon points="9,0 -3,-3 -3,3" fill="#095EDF" />
            <polygon points="-9,0 -3,-3 -3,3" fill="#9AA3B5" />
            <text x="0" y="-15" textAnchor="middle"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="9" fontWeight="700" fill="#4B5468">N</text>
          </g>

          {/* Rate legend — bottom-left. Chip colours match the zone
              overlays exactly so the legend reads as "this colour = this
              rate" without further explanation. */}
          <g transform="translate(20, 252)">
            <rect x="-6" y="-12" width="148" height="68" rx="6"
              fill="rgba(255,255,255,0.92)" stroke="#E2E6EF" strokeWidth="0.8" />
            <rect x="2" y="0" width="14" height="14" rx="3"
              fill="rgba(232, 100, 110, 0.85)" stroke="rgba(210, 80, 90, 0.9)" strokeWidth="1" />
            <text x="24" y="11"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="9.5" fontWeight="700" letterSpacing="0.04em" fill="#0A0F1A">
              CCZ — £65 / hr
            </text>
            <rect x="2" y="20" width="14" height="14" rx="3"
              fill="rgba(91, 124, 226, 0.55)" stroke="rgba(70, 100, 200, 0.75)" strokeWidth="1" />
            <text x="24" y="31"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="9.5" fontWeight="700" letterSpacing="0.04em" fill="#0A0F1A">
              Within M25 — £45 / hr
            </text>
            <rect x="2" y="40" width="14" height="14" rx="3"
              fill="#FFFFFF" stroke="#9AA3B5" strokeWidth="0.9" />
            <text x="24" y="51"
              fontFamily="JetBrains Mono, ui-monospace, monospace"
              fontSize="9.5" fontWeight="700" letterSpacing="0.04em" fill="#0A0F1A">
              Drops &lt;10 kg — £25 / hr
            </text>
          </g>

          {/* Address strip */}
          <rect x="0" y="336" width="480" height="24" fill="#F7F8FA" />
          <text x="240" y="352"
            textAnchor="middle"
            fontFamily="JetBrains Mono, ui-monospace, monospace"
            fontSize="11"
            fontWeight="700"
            letterSpacing="0.18em"
            fill="#4B5468">
            BILLED RETURN-TRIP · NATIONWIDE COURIER ON REQUEST
          </text>
        </svg>
      </div>
    </div>);
}

// ===================================================================
// /rentals/about — practical info about Valley Rentals (where to find
// us, when we're open, how we get kit to you) + the contact form.
// Renamed from RentalsContactPage 2026-05-15 to better reflect the
// page's broader role.
// ===================================================================
function RentalsAboutPage({ onGoto }) {
  const [sent, setSent] = useStateRentals(false);
  const [sending, setSending] = useStateRentals(false);
  const [error, setError] = useStateRentals('');
  // Form is always a general enquiry now — the previous "Rental order"
  // tab was retired May 2026 (rental orders flow through the cart on
  // /rentals/equipment instead). orderType is kept in state as
  // 'general' so api/contact.js still receives the field it expects.
  const [form, setForm] = useStateRentals({
    orderType: 'general',
    name: '', company: '', email: '',
    brief: '', honeypot: ''
  });
  // Logistics block toggle — 'collection' (hours + map) or 'delivery'
  // (driven-delivery rates). Defaults to collection since most clients
  // self-collect from the Acton studio.
  const [logisticsMode, setLogisticsMode] = useStateRentals('collection');
  const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });

  const progress = (() => {
    const has = (s) => (s || '').trim().length > 0;
    const emailOk = /\S+@\S+\.\S+/.test(form.email || '');
    const briefLen = (form.brief || '').trim().length;
    const briefScore = Math.min(1, briefLen / 80);
    const parts = [
    has(form.name) ? 1 : 0,
    has(form.company) ? 1 : 0,
    emailOk ? 1 : has(form.email) ? 0.4 : 0,
    briefScore];

    return parts.reduce((a, b) => a + b, 0) / parts.length;
  })();

  return (
    <main className="page active rentals-light" data-screen-label="03b Rentals · About">
      <RentalsSubHero
        idx="VR—3.0"
        label="About Valley Rentals"
        title="Where to find us,<br/>when we're <em>open</em>."
        lead="Acton, West London. Same-day prep, hand-checked kit. Drop us a line below — or for an active booking, just reply to your Booqable email and the thread stays clean." />

      <section className="contact rentals-contact">
        <div className="container">
          <div>
            {/* Email / WhatsApp / Instagram / Studio meta-block lives in
                the footer now — was duplicated at the top of this page. */}

            {/* Staffed hours — standalone card. This is about *reaching*
                the team (calls, emails, live booking support), distinct
                from the Collection vs Delivery logistics block below. */}
            <div className="rentals-hours rentals-hours-card">
              <h3 className="rentals-hours-title">Staffed hours</h3>
              <p className="rentals-hours-sub">When the team is at the desk for calls, emails and live booking support.</p>
              <dl>
                <div><dt>Mon–Fri</dt><dd>09:00 – 17:00</dd></div>
                <div><dt>Sat–Sun</dt><dd>Closed</dd></div>
                <div><dt>Bank holidays</dt><dd>Closed</dd></div>
              </dl>
            </div>

            {/* Logistics — Collection vs Delivery. Collection tab holds
                the pickup/return hours and the studio map; Delivery tab
                holds the driven-delivery rates. */}
            <div className="rentals-logistics-card">
              <div className="rentals-hours-toggle" role="tablist" aria-label="Collection or delivery">
                <button
                  type="button"
                  role="tab"
                  aria-selected={logisticsMode === 'collection'}
                  className={logisticsMode === 'collection' ? 'is-active' : ''}
                  onClick={() => setLogisticsMode('collection')}>
                  Collection
                </button>
                <button
                  type="button"
                  role="tab"
                  aria-selected={logisticsMode === 'delivery'}
                  className={logisticsMode === 'delivery' ? 'is-active' : ''}
                  onClick={() => setLogisticsMode('delivery')}>
                  Delivery
                </button>
              </div>

              {logisticsMode === 'collection' ? (
                <>
                  <p className="rentals-hours-sub">When you can pick kit up or drop it back at the studio.</p>
                  <dl>
                    <div><dt>Mon–Fri</dt><dd>08:30 – 18:00</dd></div>
                    <div><dt>Saturday</dt><dd>08:30 – 17:00</dd></div>
                    <div><dt>Sunday</dt><dd>10:00 – 16:00</dd></div>
                    <div><dt>After hours<sup className="rentals-hours-asterisk" aria-hidden="true">*</sup></dt><dd>£25 flat rate</dd></div>
                    <div><dt>Bank holidays</dt><dd>By arrangement</dd></div>
                  </dl>
                  <RentalsMapCard />
                </>
              ) : (
                <>
                  <p className="rentals-hours-sub">If collection isn't an option, we can drive the kit to you. Time is billed return-trip from the studio.</p>
                  {/* Same dl treatment as Staffed/Collection so the
                      three blocks read as a consistent table set. The
                      rate goes in the dd; per-hour units stay readable
                      without the old custom flex-row chrome. */}
                  <dl>
                    <div><dt>Within M25</dt><dd>£45 / hr</dd></div>
                    <div><dt>Central London (CCZ)</dt><dd>£65 / hr</dd></div>
                    <div><dt>Small drops (&lt;10 kg)</dt><dd>£25 / hr</dd></div>
                  </dl>
                  <p className="rentals-hours-note">Nationwide courier on request. Weekend slots are limited — book early.</p>
                  <RentalsDeliveryMap />
                </>
              )}
            </div>
          </div>

          <div className="form-card">
            {sent ?
            <div className="contact-sent">
                <div className="check"><Icon name="check" /></div>
                <h3>Thanks — that's with us.</h3>
                <p>We'll come back to you within one business day. Reply to the email confirmation if anything urgent comes up before then.</p>
                <button className="btn-pri" style={{ marginTop: 24 }} onClick={() => setSent(false)} data-hover="New">Send another</button>
              </div> :

            <React.Fragment>
                <h3>General enquiry</h3>
                <p className="desc">
                  Drop us a line about anything — accounts, suppliers, partnerships, or a question that isn't about a specific booking. <strong>Booking kit?</strong> Use the cart on <a href="/rentals/equipment">/rentals/equipment</a> instead — it'll quote you instantly.
                </p>
                <ContactStatus />

                <div className="step-indicator" aria-hidden="true">
                  {[0, 1, 2].map((i) => {
                  const segStart = i / 3;
                  const segEnd = (i + 1) / 3;
                  const fill = Math.max(0, Math.min(1, (progress - segStart) / (segEnd - segStart)));
                  return (
                    <div key={i} className={`dot ${fill > 0 ? 'filling' : ''}`}>
                        <span className="fill" style={{ transform: `scaleX(${fill})` }} />
                      </div>);

                })}
                </div>

                <div className="field-row">
                  <div className="field"><label>Your name</label><input value={form.name} onChange={set('name')} placeholder="Jordan Reeves" /></div>
                  <div className="field"><label>Company</label><input value={form.company} onChange={set('company')} placeholder="Company or production" /></div>
                </div>
                <div className="field-row">
                  <div className="field full"><label>Email</label><input type="email" value={form.email} onChange={set('email')} placeholder="jordan@brand.com" /></div>
                </div>

                <div className="field-row">
                  <div className="field full">
                    <label>What can we help with?</label>
                    <textarea rows="4" value={form.brief} onChange={set('brief')}
                    placeholder="A few lines about what you're after." />
                  </div>
                </div>

                {/* Honeypot — hidden field. Real users leave it blank, bots fill it. */}
                <div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', height: 0, overflow: 'hidden' }}>
                  <label>Don't fill this in <input type="text" tabIndex={-1} autoComplete="off" value={form.honeypot} onChange={set('honeypot')} /></label>
                </div>

                {error && <div className="form-error" role="alert">{error}</div>}

                <button className="submit" disabled={sending} onClick={async (e) => {
                e.preventDefault();
                setError('');
                if (!form.name.trim() || !form.email.trim() || !form.brief.trim()) {
                  setError('Please fill in your name, email, and a few lines about your enquiry.');
                  return;
                }
                if (!/^\S+@\S+\.\S+$/.test(form.email)) {
                  setError('That email doesn\'t look right — please double-check.');
                  return;
                }
                setSending(true);
                try {
                  const resp = await fetch('/api/contact', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ ...form, kind: 'rentals' })
                  });
                  if (!resp.ok) throw new Error((await resp.json().catch(() => ({}))).error || 'Send failed');
                  setSent(true);
                } catch (err) {
                  setError('Couldn\'t send right now — please email rentals@valley.film directly.');
                } finally {
                  setSending(false);
                }
              }} data-hover="Send">
                  {sending ? 'Sending…' : <>Send enquiry <Icon name="arrow-right" /></>}
                </button>
              </React.Fragment>
            }
          </div>
        </div>
        {/* Asterisked footnote for the After hours row in the
            collection table above. Worded conservatively — we want
            to keep the door open to genuine out-of-hours requests
            without committing to availability we can't always meet. */}
        <div className="container">
          <p className="rentals-hours-footnote">
            <sup aria-hidden="true">*</sup>
            After-hours collection and return is offered subject to staff
            availability and is not guaranteed at the time of booking.
            Requests are typically accommodated where confirmed in
            writing (WhatsApp or email) not less than 24 hours in advance.
            The £25 surcharge is applied per event and is non-refundable
            in the case of no-show or late cancellation.
          </p>
        </div>
      </section>

      <MegaFooter onGoto={onGoto} isRentals />
    </main>);

}

// ===================================================================
// /rentals/open-account — native multi-step application wizard
// Verification-first by design: ID + proofs upload while the applicant
// completes the rest. Files start scoring on the server in the
// background so the review state is ready by the time they submit.
// ===================================================================
const OA_STORAGE_KEY = 'vfRentalsOpenAccount.v1';
const OA_REAL_STEPS = 4; // verification, about, references, review

const OA_DEFAULT_DATA = {
  step: 0, // 0 welcome, 1 verification, 2 references, 3 about, 4 review
  // Populated when /api/applications/start fires on step 2 → step 3
  // transition. Sent back to /api/applications/submit at final Submit
  // so the server updates the existing partial row instead of
  // creating a new one (and skips re-sending the already-sent
  // reference emails).
  applicantId: null,
  // HMAC-signed token returned by /api/applications/start (and
  // refreshed by /api/applications/submit). Required by
  // /api/applications/upload — binds upload access to the original
  // submitter so anyone-with-applicantId can't overwrite slots.
  // VERIFICATION.md §3. 2-hour TTL; resume past expiry re-issues
  // via the next /start call (which the wizard hits automatically).
  uploadToken: null,
  // Timestamp captured the FIRST time the applicant leaves the
  // welcome step (step 0 → step 1). Persisted in localStorage so
  // resuming a paused application keeps the original start time.
  // The submit endpoint flags rows where the total elapsed time is
  // under 70 seconds — fast enough to suggest a bot or copy-paste
  // attack rather than a real human filling in the form.
  applicationStartedAt: null,
  about: {
    firstName: '', surname: '', email: '', phone: '',
    company: '', address: '',
    // billingDifferent toggles a checkbox on the Your Details step;
    // when checked, the billingAddress field is revealed. When
    // unchecked we treat billingAddress as empty (server already
    // falls back to address if billingAddress is blank).
    billingDifferent: false,
    billingAddress: '', portfolio: ''
  },
  references: [
    { name: '', company: '', email: '', phone: '', relationship: '' },
    { name: '', company: '', email: '', phone: '', relationship: '' }
  ],
  consent: { data: false, newsletter: false }
};

const OA_FILE_SLOTS = [
  {
    key: 'id', label: 'Photo ID', accept: 'image/*,application/pdf',
    details: 'A photo of your passport, UK driving licence, or national ID. Photograph the original document — screenshots and photocopies are rejected. Make sure your full name and date of birth are clearly visible.'
  },
  {
    key: 'selfie', label: 'Selfie', accept: 'image/*',
    details: 'A fresh selfie taken now, using the same device. Look at the camera in good light. We use this only to confirm the ID photo matches the person applying.'
  },
  {
    key: 'proof1', label: 'Document 1', accept: 'image/*,application/pdf',
    details: 'A recent bank statement, utility bill or council tax bill. Must be dated within the last three months and show your name and address.'
  },
  {
    key: 'proof2', label: 'Document 2', accept: 'image/*,application/pdf',
    details: 'Same requirements as Document 1, but from a different provider — if Document 1 is a bank statement, Document 2 should be a utility bill or similar.'
  }
];

// "Already have an account? Update your details" — small secondary action
// on the Open Account welcome step. Clicking expands an inline panel
// where existing account-holders enter their email; we send a magic
// link that lands on a prefilled review-step editable view.
//
// Phase-1 stub: the form accepts an email but doesn't yet send the
// magic link — it shows a polite "coming soon" message. The full flow
// (lib/customer-link + api/customer/auth-request + the editable review
// landing route) lands in Phase 2.
function OaUpdateAccountStub() {
  // 'closed' (collapsed link) | 'form' (email input) | 'sending'
  // | 'sent' (success state — agnostic about whether the email is
  // actually known, so we don't leak account-existence info)
  const [state, setState] = useStateRentals('closed');
  const [email, setEmail] = useStateRentals('');
  const emailValid = /\S+@\S+\.\S+/.test(email);
  const submit = async (e) => {
    e.preventDefault();
    if (!emailValid) return;
    setState('sending');
    try {
      // /api/customer always returns 200 regardless of whether the
      // email is on file — prevents account enumeration. So we
      // unconditionally show the same "check your inbox" message.
      await fetch('/api/customer?action=request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });
    } catch (err) {
      console.warn('[oa-update-stub] request failed:', err && err.message);
    }
    setState('sent');
  };
  return (
    <div className="oa-update-stub">
      {state === 'closed' && (
        <button
          type="button"
          className="oa-update-stub-toggle"
          onClick={() => setState('form')}>
          Already have an account? <span>Update your details →</span>
        </button>)}
      {(state === 'form' || state === 'sending') && (
        <form className="oa-update-stub-form" onSubmit={submit}>
          <label>
            <span>Account email</span>
            <input
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              placeholder="you@studio.com"
              autoComplete="email"
              autoFocus
              disabled={state === 'sending'} />
          </label>
          <div className="oa-update-stub-actions">
            <button type="button" className="oa-update-stub-cancel" onClick={() => setState('closed')} disabled={state === 'sending'}>Cancel</button>
            <button type="submit" className="oa-update-stub-send" disabled={!emailValid || state === 'sending'}>
              {state === 'sending' ? 'Sending…' : 'Send magic link →'}
            </button>
          </div>
        </form>)}
      {state === 'sent' && (
        <div className="oa-update-stub-sent" role="status" aria-live="polite">
          <strong>Check your inbox.</strong>
          <span>If we have an account for <code>{email}</code>, we just sent a link to update your details. It's valid for 30 minutes.</span>
        </div>)}
    </div>);
}

// Small inline info button + tooltip. Lives next to a label rather than
// floating over the upload zone — keeps the slot itself uncluttered.
function OaHelp({ text, label }) {
  return (
    <span className="oa-help">
      <button type="button" className="oa-help-btn" aria-label={`More info${label ? ` about ${label}` : ''}`}>i</button>
      <span className="oa-help-pop" role="tooltip">{text}</span>
    </span>
  );
}

// Security-theatre badge. Sits in the wizard footer, on the success
// screen, and in the referee email footer. Looks like a third-party
// identity-verification mark even though it's our own internal stack
// — purely a visual deterrent for fraudsters scoping the form.
// "Veridyne" is a fictitious brand; the badge is not making any claim
// about external auditing, just signalling that the application is
// being scrutinised.
function OaSecurityBadge() {
  // First-paint draw-in: shield outline traces in, then the tick. Fires
  // once per badge instance, gated on IntersectionObserver so it actually
  // plays when the user scrolls to it rather than already-finished if the
  // badge mounts below the fold. After the initial draw, a hover state
  // re-traces the tick — quiet micro-interaction on pointer devices only.
  const [visible, setVisible] = useStateRentals(false);
  const ref = React.useRef(null);
  useEffectRentals(() => {
    if (!ref.current || visible) return;
    const obs = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) setVisible(true); },
      { threshold: 0.5 }
    );
    obs.observe(ref.current);
    return () => obs.disconnect();
  }, [visible]);
  return (
    <div
      ref={ref}
      className={`oa-security-badge${visible ? ' is-visible' : ''}`}
      role="img"
      aria-label="Security verified by Veridyne"
    >
      <svg
        className="oa-security-badge-shield"
        viewBox="0 0 24 24"
        width="20"
        height="20"
        aria-hidden="true"
      >
        <path
          className="oa-shield-path"
          d="M12 2 L20 5 V11 C20 16.5 16.5 20.5 12 22 C7.5 20.5 4 16.5 4 11 V5 Z"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.6"
          strokeLinejoin="round"
        />
        <path
          className="oa-check-path"
          d="M8.5 12.5 L11 15 L15.8 9.6"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.8"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      <span className="oa-security-badge-text">
        <strong>Security by Veridyne</strong>
        <span>Identity, device &amp; fraud monitoring</span>
      </span>
    </div>
  );
}

// Shared renderer for one upload slot. Used twice: stacked full-width for
// Photo ID + Selfie (with their own header label + inline help icon), and
// inside a 2-col grid for the two proofs of address (compact, no per-slot
// help — the parent "Proof of address" header carries that for both).
//
// `opts.check` carries the proof-of-address background analysis state
// ({ state: 'pending' | 'matched' | 'unmatched' | 'inconclusive' | 'extracted',
//    msg }). Drives a coloured border (blue pulse while analysing, green when
// the typed address was found on the document, red when it wasn't).
function renderOaFileSlot(slot, f, meta, onPickFile, removeFile, fmtSize, opts) {
  const compact = !!(opts && opts.compact);
  const check = opts && opts.check;
  const checkClass = check ? `oa-proof-${check.state}` : '';
  return (
    <div className={`field ${compact ? '' : 'full'} oa-file-field ${compact ? 'oa-file-field--compact' : ''}`} key={slot.key}>
      {!compact && (
        <label>
          {slot.label}
          {slot.details && <OaHelp text={slot.details} label={slot.label} />}
        </label>
      )}
      {f ? (
        <div className={`oa-file-preview ${checkClass}`}>
          <div className="oa-file-thumb">
            {meta.thumb ? <img src={meta.thumb} alt="" /> : <Icon name="file" />}
          </div>
          <div className="oa-file-info">
            <div className="oa-file-name">{f.name}</div>
            <div className="oa-file-meta">{fmtSize(f.size)} · {f.type || 'file'}</div>
          </div>
          {check && check.state === 'matched' && (
            <span className="oa-proof-badge oa-proof-badge--ok" title="Address matched"><Icon name="check" /></span>
          )}
          {check && check.state === 'unmatched' && (
            <span className="oa-proof-badge oa-proof-badge--bad" title="Address didn't match">!</span>
          )}
          {check && check.state === 'pending' && (
            <span className="oa-proof-badge oa-proof-badge--pending" title="Checking address" />
          )}
          <button type="button" className="oa-file-remove" onClick={() => removeFile(slot.key)}>Remove</button>
        </div>
      ) : (
        <label className="oa-file-drop">
          <Icon name="upload" />
          <span className="oa-file-drop-lbl">{compact ? slot.label : 'Choose file or drop here'}</span>
          <input type="file" accept={slot.accept} onChange={(e) => onPickFile(slot.key, e.target.files[0])} />
        </label>
      )}
      {meta.warn && !meta.pending && (
        <div className="oa-file-warn">{meta.msg}</div>
      )}
      {check && check.msg && check.state === 'unmatched' && (
        <div className="oa-file-warn">{check.msg}</div>
      )}
      {check && check.msg && check.state === 'inconclusive' && (
        <div className="oa-field-ok" style={{ color: 'var(--vf-ink-3)', fontWeight: 400 }}>{check.msg}</div>
      )}
    </div>
  );
}

// Free email providers we won't accept as trade reference addresses —
// references need to come from a business domain so the referee can be
// reasonably verified as working for the company they're vouching for.
// Mirrors the server-side set in lib/validation.js. Keep in sync.
const OA_FREE_EMAIL_DOMAINS = new Set([
  'gmail.com', 'googlemail.com',
  'hotmail.com', 'hotmail.co.uk', 'hotmail.fr', 'hotmail.de', 'hotmail.it', 'hotmail.es',
  'outlook.com', 'outlook.co.uk', 'outlook.fr', 'outlook.de',
  'yahoo.com', 'yahoo.co.uk', 'yahoo.fr', 'yahoo.de', 'yahoo.es', 'yahoo.it', 'ymail.com',
  'live.com', 'live.co.uk', 'live.fr', 'msn.com',
  'icloud.com', 'me.com', 'mac.com',
  'aol.com', 'aol.co.uk',
  'protonmail.com', 'proton.me', 'pm.me',
  'gmx.com', 'gmx.us', 'gmx.de', 'gmx.co.uk',
  'mail.com', 'email.com',
  'yandex.com', 'yandex.ru', 'mail.ru', 'inbox.ru', 'list.ru', 'bk.ru',
  'fastmail.com', 'fastmail.fm',
  'tutanota.com', 'tutanota.de',
  'zoho.com',
  'btinternet.com', 'btopenworld.com', 'talktalk.net', 'sky.com',
  'virginmedia.com', 'ntlworld.com', 'plus.net',
]);
function oaIsFreeEmail(email) {
  if (!email) return false;
  const at = email.lastIndexOf('@');
  if (at < 0) return false;
  return OA_FREE_EMAIL_DOMAINS.has(email.slice(at + 1).toLowerCase().trim());
}

// Lightweight pattern-based check for the portfolio URL. Not a real content
// crawl — we're just trying to catch obvious wrong choices (Facebook,
// LinkedIn, Instagram explore pages) and give positive feedback for known
// filmmaker-friendly platforms. Custom domains pass by default; a human
// reviewer in Notion makes the final call.
//
// Returns one of:
//   { state: 'empty' }                        — input is blank
//   { state: 'invalid' }                      — unparseable, probably mid-typing
//   { state: 'bad', msg }                     — looks wrong (Facebook etc.)
//   { state: 'good', platform }               — looks fine
const OA_PORTFOLIO_GOOD = {
  'instagram.com': 'Instagram',
  'instagr.am': 'Instagram',
  'vimeo.com': 'Vimeo',
  'youtube.com': 'YouTube',
  'youtu.be': 'YouTube',
  'behance.net': 'Behance',
  'cargo.site': 'Cargo site',
  'cargocollective.com': 'Cargo site',
  'squarespace.com': 'Squarespace site',
  'wixsite.com': 'Wix site',
  'webflow.io': 'Webflow site',
  'flickr.com': 'Flickr',
  'lbb.online': 'LBB',
  'shootonline.com': 'Shoot',
  'tiktok.com': 'TikTok',
};
const OA_PORTFOLIO_BAD = {
  'facebook.com':   'a Facebook page',
  'fb.com':         'a Facebook page',
  'm.facebook.com': 'a Facebook page',
  'linkedin.com':   'a LinkedIn profile',
  'reddit.com':     'a Reddit link',
  'twitter.com':    'a Twitter/X feed',
  'x.com':          'a Twitter/X feed',
  'pinterest.com':  'a Pinterest board',
};
function oaPortfolioCheck(url) {
  const trimmed = (url || '').trim();
  if (!trimmed) return { state: 'empty' };
  let parsed;
  try {
    parsed = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : 'https://' + trimmed);
  } catch {
    return { state: 'invalid' };
  }
  const host = parsed.hostname.toLowerCase().replace(/^www\./, '');
  if (!host.includes('.') || host.length < 4) return { state: 'invalid' };

  if (host in OA_PORTFOLIO_BAD) {
    return { state: 'bad', msg: `${OA_PORTFOLIO_BAD[host]} isn't really a portfolio — please link to Vimeo, Instagram (your profile), Behance, or your own site.` };
  }
  if (host in OA_PORTFOLIO_GOOD) {
    // Instagram: only accept profile URLs, reject /p/, /reels, /explore, /tv, /stories
    if (host === 'instagram.com' || host === 'instagr.am') {
      const segs = parsed.pathname.split('/').filter(Boolean);
      if (segs.length === 0 || ['p', 'reel', 'reels', 'explore', 'tv', 'stories', 'accounts'].includes(segs[0])) {
        return { state: 'bad', msg: 'Please link to a profile (instagram.com/yourhandle), not a post or feed.' };
      }
    }
    // Vimeo: must have a path beyond /
    if (host === 'vimeo.com') {
      const segs = parsed.pathname.split('/').filter(Boolean);
      if (segs.length === 0) {
        return { state: 'bad', msg: 'Please link to your Vimeo page (vimeo.com/yourname or a showcase).' };
      }
    }
    return { state: 'good', platform: OA_PORTFOLIO_GOOD[host] };
  }
  // Anything else with a real TLD passes — could be a custom site.
  return { state: 'good', platform: 'Personal site' };
}

// ===== Proof-of-address auto-check =====
// Lazy-loads pdf.js when the first PDF arrives, extracts the document's
// text, then fuzzy-matches against the applicant's typed address. Runs
// in the background so the work hides behind the time the applicant
// spends on the About / References / Review steps.
//
// Match strategy: UK postcode is the gold-standard signal — if the
// applicant's postcode appears anywhere in the extracted text, we mark
// the proof as matched. Fallback: street number + at least one
// non-stopword from the applicant's address must appear too.
//
// Image proofs aren't OCR'd here (Tesseract.js is ~10MB and slow). They
// stay in an "inconclusive — reviewed manually" state until the server-
// side verification pipeline ships.

const OA_PDFJS_VERSION = '3.11.174';
let oaPdfJsLoading = null;
function oaEnsurePdfJs() {
  if (window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
  if (oaPdfJsLoading) return oaPdfJsLoading;
  oaPdfJsLoading = new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${OA_PDFJS_VERSION}/pdf.min.js`;
    script.onerror = () => { oaPdfJsLoading = null; reject(new Error('pdf.js failed to load')); };
    script.onload = () => {
      try {
        window.pdfjsLib.GlobalWorkerOptions.workerSrc =
          `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${OA_PDFJS_VERSION}/pdf.worker.min.js`;
        resolve(window.pdfjsLib);
      } catch (e) {
        oaPdfJsLoading = null;
        reject(e);
      }
    };
    document.head.appendChild(script);
  });
  return oaPdfJsLoading;
}

async function oaExtractPdfText(file) {
  const pdfjsLib = await oaEnsurePdfJs();
  const buf = await file.arrayBuffer();
  const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buf) }).promise;
  let text = '';
  for (let i = 1; i <= pdf.numPages; i++) {
    const page = await pdf.getPage(i);
    const content = await page.getTextContent();
    text += content.items.map((it) => it.str).join(' ') + '\n';
  }
  return text;
}

// L — PDF Creator/Producer metadata check. Real utility bills come from
// enterprise PDF generators (iText, SAP Crystal Reports, Adobe Acrobat
// Pro, Quadient, OpenText). Hand-edited fakes typically show signatures
// like Photoshop, Microsoft Word, Smallpdf, or "Print to PDF" wrappers.
// Returns one of: 'whitelisted' | 'blacklisted' | 'unknown'.
const OA_PDF_PRODUCER_WHITELIST = [
  'itext', 'sap crystal', 'crystal reports', 'adobe acrobat pro',
  'quadient', 'opentext', 'aspose.pdf', 'docusign', 'jasperreports',
  'fpdf', 'tcpdf', 'reportlab', 'apache pdfbox',
];
const OA_PDF_PRODUCER_BLACKLIST = [
  'photoshop', 'gimp', 'microsoft word', 'microsoft excel',
  'microsoft powerpoint', 'libreoffice', 'openoffice', 'apple pages',
  'apple keynote', 'apple numbers', 'smallpdf', 'ilovepdf', 'pdfescape',
  'sejda', 'canva', 'figma',
];
// ===== Document OCR for autofill =====
// Lazy-loads Tesseract.js (~5MB; cached after first load) when the
// first image-based ID arrives. Extracts text from the document, then
// parses it for the applicant's name (Photo ID) or address (proof of
// address). Pre-fills the corresponding form fields IF the applicant
// hasn't already typed something — their typing always wins.
//
// PDF proofs of address use the existing pdf.js text-extraction path
// (already in place for the address-match check); we just re-use the
// extracted text and run it through the address parser.

const OA_TESSERACT_VERSION = '5.1.1';
const OA_TESSERACT_CDN = `https://cdn.jsdelivr.net/npm/tesseract.js@${OA_TESSERACT_VERSION}/dist/tesseract.min.js`;
let oaTesseractLoading = null;
function oaEnsureTesseract() {
  if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
  if (window.Tesseract) return Promise.resolve(window.Tesseract);
  if (oaTesseractLoading) return oaTesseractLoading;
  oaTesseractLoading = new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = OA_TESSERACT_CDN;
    script.onerror = () => { oaTesseractLoading = null; reject(new Error('tesseract.js load failed')); };
    script.onload = () => {
      if (window.Tesseract) resolve(window.Tesseract);
      else { oaTesseractLoading = null; reject(new Error('Tesseract global missing')); }
    };
    document.head.appendChild(script);
  });
  return oaTesseractLoading;
}

async function oaOcrImage(file) {
  const Tesseract = await oaEnsureTesseract();
  const url = URL.createObjectURL(file);
  try {
    const { data } = await Tesseract.recognize(url, 'eng');
    return (data && data.text) || '';
  } finally {
    URL.revokeObjectURL(url);
  }
}

function oaTitleCase(s) {
  return String(s || '').toLowerCase().replace(/(^|[\s\-'])(\w)/g, (_, sep, c) => sep + c.toUpperCase());
}

// UK convention: the LAST word of a full name is the surname; every
// other word is part of the given/first name (people often have
// multiple given names — "Henry Max", "Anne-Marie Catherine", etc.).
// Splitting "Henry Max Paterson" → firstName: "Henry Max", surname:
// "Paterson". Used by both the ID OCR parser and the address-block
// name fallback so behaviour is consistent.
function oaSplitFullName(full) {
  const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
  if (parts.length === 0) return { firstName: '', surname: '' };
  if (parts.length === 1) return { firstName: parts[0], surname: '' };
  return {
    firstName: parts.slice(0, -1).join(' '),
    surname: parts[parts.length - 1],
  };
}

// Parse first name + surname from OCR'd Photo ID text. Tries (in
// order): passport MRZ (most reliable), UK driving licence numbered
// fields, labelled fields. Returns null if nothing parseable found.
// All given names go into firstName ("Henry Max Paterson" →
// firstName "Henry Max", surname "Paterson").
function oaParseNameFromIdText(text) {
  if (!text) return null;
  // 1. UK Passport MRZ:  P<GBRSURNAME<<JOHN<JAMES<<<<<<<<<<<<<<<<<<<
  const mrz = text.match(/P[K<]([A-Z]{3})([A-Z]{1,40}?)<<([A-Z<]{1,40})/);
  if (mrz) {
    const surname = mrz[2];
    // Take ALL given names, joined with spaces. "JOHN<JAMES" → "John James".
    const given = mrz[3].split('<').filter(Boolean).join(' ');
    if (surname && given && surname.length > 1 && given.length > 1) {
      return { firstName: oaTitleCase(given), surname: oaTitleCase(surname), source: 'passport' };
    }
  }
  // 2. UK driving licence numbered fields: "1." then surname, "2." then given names
  const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
  let surnameLine = null, givenLine = null;
  for (const line of lines) {
    const s = line.match(/^1\s*[\.\)]\s*([A-Z][A-Z\-'\s]{1,40})$/);
    const g = line.match(/^2\s*[\.\)]\s*([A-Z][A-Z\-'\s]{1,40})$/);
    if (s && !surnameLine) surnameLine = s[1].trim();
    if (g && !givenLine) givenLine = g[1].trim();
  }
  if (surnameLine && givenLine) {
    return {
      // Use ALL given names, not just the first word.
      firstName: oaTitleCase(givenLine),
      surname: oaTitleCase(surnameLine),
      source: 'driving_licence',
    };
  }
  // 3. Labelled fields ("Surname: SMITH", "Given names: JOHN JAMES")
  const sLabel = text.match(/(?:Surname|SURNAME|Family\s+name)[:\s]+([A-Z][A-Za-z\-']{1,40})/);
  // Given-name capture allows internal whitespace so multiple given
  // names come through. Stops at newline / 2+ spaces / end-of-text.
  const gLabel = text.match(/(?:Given\s+names?|GIVEN\s+NAMES?|First\s+name|Forename)[:\s]+([A-Z][A-Za-z\-'\s]{1,80}?)(?:\s{2,}|\n|\r|$)/);
  if (sLabel && gLabel) {
    return {
      firstName: oaTitleCase(gLabel[1].trim()),
      surname: oaTitleCase(sLabel[1]),
      source: 'labelled',
    };
  }
  return null;
}

// Parse a UK address out of OCR'd / extracted text. Strategy:
//
// 1. Split on newlines OR runs of 2+ spaces. Tesseract often outputs
//    the whole page as one long line with double-spaces between
//    visually separate text (especially on multi-column statements
//    like Revolut/Monzo). Splitting on either gives us back the
//    document's visual structure.
//
// 2. Find every postcode in the text. Skip postcodes that sit
//    immediately after a company-address marker ("Registered address",
//    "Head office", etc.) — those belong to the bank/utility, not the
//    customer.
//
// 3. Walk backwards from the chosen postcode taking 4 lines, filtering
//    obvious non-address noise (statement headers, dates, currency,
//    company law boilerplate).
const OA_ADDRESS_NOISE_RE = /\b(account\s+statement|invoice(?:\s+date)?|statement(?:\s+period)?|page\s+\d|customer\s+(number|id|reference)|reference\s+(no|number)|tel(?:ephone)?|phone|email|website|balance|total|cashback|pots|generated\s+on|tariff|firm\s+reference|authorised|conduct\s+authority|electronic\s+money|registered\s+(address|in|office)|ltd\.?|limited|company\s+(no|number)|vat\s+(no|number)|insurance\s+relat)/i;
const OA_REGISTERED_MARKER_RE = /\b(registered\s+(?:office|address)|head\s+office|company\s+(?:no|number)|firm\s+reference|authorised\s+by)\b/i;
// Transaction-line indicators — these appear in lines that contain a
// postcode by coincidence (e.g. a Co-op transaction at "London W12 9JF"
// happens to share the customer's postcode). We don't want to grab the
// transaction text as the address.
const OA_TRANSACTION_MARKER_RE = /\b(IBAN|BIC|sort\s+code|account\s+number\s+\d|card:\s*\d|card\s+\d|to:\s+[A-Z]|payee|amount|debit|credit|transaction|merchant|purchase|withdrawal|deposit|payment\s+(date|received|sent))\b/i;
function oaParseAddressFromText(text) {
  if (!text) return null;
  // Split on real newlines OR runs of 2+ whitespace chars (Tesseract's
  // way of representing "these were visually separated on the page").
  const lines = text.split(/\r?\n|\s{2,}/).map((l) => l.trim()).filter(Boolean);
  const postcodeRe = /\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b/i;

  // Score every postcode candidate by the context around it. The
  // customer's actual address typically appears as several short
  // consecutive lines (building / street / city / postcode), often
  // preceded by a name. Company footers have legal-boilerplate
  // markers nearby. Transaction lines have IBAN / sort code / card
  // number / "to:" markers nearby.
  const candidates = [];
  for (let i = 0; i < lines.length; i++) {
    if (!postcodeRe.test(lines[i])) continue;
    const ctxLines = lines.slice(Math.max(0, i - 4), i + 1);
    const ctxStr = ctxLines.join(' | ');
    let score = 0;
    // Strong negatives — these clearly aren't the customer's home address
    if (OA_REGISTERED_MARKER_RE.test(ctxStr)) score -= 30;
    if (OA_TRANSACTION_MARKER_RE.test(ctxStr)) score -= 30;
    // Positives — look for address-block shape:
    //  - A street-line pattern: "[number] [Word]" at the start of a line
    //  - A short city/area line (single word or two short words)
    //  - A name-like line (2-3 capitalised words, no digits)
    if (ctxLines.some((l) => /^\d+[a-z]?\s+[A-Z][a-z]/i.test(l))) score += 10;
    if (ctxLines.some((l) => /^[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}$/.test(l) && l.length < 30)) score += 5;
    if (ctxLines.some((l) => /^[A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,2}$/.test(l) && !/\d/.test(l))) score += 5;
    // Penalise long single-line postcodes (transaction-line indicator)
    if (lines[i].length > 80) score -= 15;
    // Penalise if the postcode appears mid-line with non-address text
    // immediately after (e.g. ".. W12 9JF London £52.43")
    if (/£|\$|€/.test(lines[i])) score -= 10;
    candidates.push({ idx: i, score, ctx: ctxLines });
  }
  if (candidates.length === 0) return null;
  candidates.sort((a, b) => b.score - a.score);
  const winner = candidates[0];
  // If the top candidate scored very negatively, the document
  // probably doesn't contain a clean customer address — bail rather
  // than surface transaction noise as the address.
  if (winner.score < -10) return null;
  const pcIdx = winner.idx;

  const out = [];
  for (let i = Math.max(0, pcIdx - 4); i <= pcIdx; i++) {
    const l = lines[i];
    if (!l || l.length < 3) continue;
    if (OA_ADDRESS_NOISE_RE.test(l)) continue;
    if (OA_TRANSACTION_MARKER_RE.test(l)) continue;
    // Skip lines that are purely digits/punct (often barcodes / totals)
    if (/^[\d\s\.\-\/£$€]+$/.test(l)) continue;
    // Skip date-only lines
    if (/^\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}(\s*[\-–to]+\s*\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4})?\s*$/.test(l)) continue;
    // Strip any postcode-trailing junk from the winning line: take
    // everything from the start to the postcode + a few extra chars.
    // Handles cases like ".. 24 Leysfield Road First Floor Flat W12 9JF London" by
    // including up to the postcode + "London" if present.
    if (i === pcIdx && l.length > 60) {
      const m = l.match(/.{0,80}[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}(?:\s+[A-Z][a-z]+)?/);
      if (m) out.push(m[0].trim()); else out.push(l);
    } else {
      out.push(l);
    }
  }
  if (out.length < 2) return null; // need at least street + postcode line

  // Detect a name line at the top of the address block. Bank /
  // utility statements usually print the customer name above the
  // address. Pulling it out here means (a) it doesn't pollute the
  // Address textarea and (b) the caller can use it to pre-fill
  // First Name + Surname when ID OCR hasn't already done so.
  let candidateName = null;
  if (out.length > 1) {
    const first = out[0];
    // Person name: 2-4 capitalised words, no digits, reasonable
    // length, doesn't contain typical address words (Road, Street,
    // Flat, etc.).
    const nameRe = /^[A-Z][a-zA-Z'\-]+(?:\s+[A-Z][a-zA-Z'\-]+){1,3}$/;
    const addressWordRe = /\b(Road|Street|Avenue|Lane|Drive|Court|Place|Square|Terrace|Apartment|Flat|Floor|House|Way|Close|Mews|Crescent|Park|Hill|Walk|Wharf|North|South|East|West)\b/i;
    if (
      nameRe.test(first) &&
      first.length <= 50 &&
      !addressWordRe.test(first) &&
      !/\d/.test(first)
    ) {
      candidateName = first;
      out.shift();
    }
  }
  if (out.length < 2) return null; // shouldn't strip the name down to a single line

  return { address: out.join('\n'), candidateName };
}

async function oaCheckPdfCreator(file) {
  try {
    const pdfjsLib = await oaEnsurePdfJs();
    const buf = await file.arrayBuffer();
    const pdf = await pdfjsLib.getDocument({ data: new Uint8Array(buf) }).promise;
    const meta = await pdf.getMetadata().catch(() => null);
    const info = (meta && meta.info) || {};
    const creator = String(info.Creator || '').toLowerCase();
    const producer = String(info.Producer || '').toLowerCase();
    const combined = `${creator} ${producer}`.trim();
    if (!combined) return { status: 'unknown', creator: '', producer: '' };
    if (OA_PDF_PRODUCER_BLACKLIST.some((b) => combined.includes(b))) {
      return { status: 'blacklisted', creator: info.Creator || '', producer: info.Producer || '' };
    }
    if (OA_PDF_PRODUCER_WHITELIST.some((w) => combined.includes(w))) {
      return { status: 'whitelisted', creator: info.Creator || '', producer: info.Producer || '' };
    }
    return { status: 'unknown', creator: info.Creator || '', producer: info.Producer || '' };
  } catch (e) {
    return { status: 'error', error: e.message };
  }
}

function oaNormaliseAddress(s) {
  if (!s) return '';
  return String(s).toLowerCase()
    .replace(/\bstreet\b/g, 'st')
    .replace(/\broad\b/g, 'rd')
    .replace(/\bavenue\b/g, 'ave')
    .replace(/\bdrive\b/g, 'dr')
    .replace(/\blane\b/g, 'ln')
    .replace(/\bcourt\b/g, 'ct')
    .replace(/\bplace\b/g, 'pl')
    .replace(/\bsquare\b/g, 'sq')
    .replace(/\bterrace\b/g, 'ter')
    .replace(/\bapartment\b/g, 'apt')
    .replace(/[.,]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

// V — Validate a UK postcode against postcodes.io (free, no key needed).
// Cached per-postcode so a typo + correction doesn't fire two lookups.
// Returns { ok, valid, region } where region is "England" / "Scotland" /
// "Wales" / "Northern Ireland" if resolved, or '' otherwise.
const oaPostcodeCache = new Map();
async function oaValidateUkPostcode(postcode) {
  const pc = String(postcode || '').replace(/\s+/g, '').toUpperCase();
  if (!pc) return { ok: false, valid: false, region: '' };
  if (oaPostcodeCache.has(pc)) return oaPostcodeCache.get(pc);
  try {
    const r = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(pc)}`);
    if (r.status === 404) {
      const result = { ok: true, valid: false, region: '' };
      oaPostcodeCache.set(pc, result);
      return result;
    }
    if (!r.ok) return { ok: false, valid: false, region: '' };
    const data = await r.json();
    const result = {
      ok: true,
      valid: !!data.result,
      region: data.result?.country || '',
    };
    oaPostcodeCache.set(pc, result);
    return result;
  } catch {
    return { ok: false, valid: false, region: '' };
  }
}

function oaExtractUkPostcodes(s) {
  if (!s) return [];
  // Loose UK postcode regex: A-Z{1,2} digit + optional letter/digit, space, digit + AA
  const m = s.match(/\b[A-Z]{1,2}\d[A-Z\d]?\s*\d[A-Z]{2}\b/gi);
  return m ? Array.from(new Set(m.map((p) => p.replace(/\s+/g, '').toUpperCase()))) : [];
}

const OA_ADDRESS_STOPWORDS = new Set([
  'flat', 'floor', 'first', 'second', 'third', 'house', 'court', 'apartment',
  'london', 'road', 'street', 'avenue', 'lane', 'drive', 'place', 'square',
  'terrace', 'apt', 'unit', 'building', 'block', 'rd', 'st', 'ave', 'ln',
  'dr', 'ct', 'pl', 'sq', 'the', 'and', 'with',
]);

function oaCheckAddressMatch(extractedText, applicantAddress) {
  if (!extractedText || !applicantAddress) return { match: false, signal: 'missing_input' };
  const extracted = oaNormaliseAddress(extractedText);
  const applicant = oaNormaliseAddress(applicantAddress);

  // Strong signal: postcode match
  const aPostcodes = oaExtractUkPostcodes(applicant);
  const ePostcodes = oaExtractUkPostcodes(extracted);
  if (aPostcodes.some((pc) => ePostcodes.includes(pc))) {
    return { match: true, signal: 'postcode' };
  }

  // Fallback: street number + at least one notable word from applicant
  const numMatch = applicant.match(/\b(\d+[a-z]?)\b/i);
  const applicantNumber = numMatch ? numMatch[1].toLowerCase() : null;
  if (applicantNumber && new RegExp(`\\b${applicantNumber}\\b`).test(extracted)) {
    const notableWords = applicant.split(/\s+/).filter(
      (w) => w.length > 3 && !OA_ADDRESS_STOPWORDS.has(w) && isNaN(Number(w))
    );
    const hits = notableWords.filter((w) => extracted.includes(w));
    if (hits.length >= 1) return { match: true, signal: 'street' };
  }

  return { match: false, signal: 'no_match' };
}

// Best-effort client-side check that an image is an original camera photo,
// not a screenshot or edited file. Combines the original screenshot check
// (no Make/Model = likely a screenshot) with N's deeper EXIF checks:
// flag images that have been opened in Photoshop/GIMP/image editors, or
// where DateTimeOriginal and DateTime disagree (suggests later modification).
//
// Missing-EXIF is treated as ambiguous, not bad: iOS Share Sheet strips
// EXIF accidentally on many transfers. Only positive-but-wrong signals
// trigger the warning.
const OA_EXIF_EDITOR_RE = /photoshop|gimp|affinity photo|pixelmator|lightroom|capture one|skylum/i;
async function oaDetectScreenshot(file) {
  if (!window.exifr) return { ok: true };
  if (file.type.startsWith('image/')) {
    try {
      const exif = await window.exifr.parse(file);
      // No EXIF at all → probably a screenshot (or a sharesheet-stripped
      // photo). Friendly nudge but not a hard stop — captured photos via
      // our own camera UI also have no EXIF.
      if (!exif || (!exif.Make && !exif.Model && !exif.LensModel)) {
        return { ok: false, warn: true, msg: 'This looks like a screenshot. We need the original camera photo for ID checks — please retake it with your phone or camera.' };
      }
      // N — image-editor signature
      const software = String(exif.Software || '');
      if (OA_EXIF_EDITOR_RE.test(software)) {
        return { ok: false, warn: true, msg: `This image was edited in ${software.split(/\s/)[0]}. We need the original camera photo — please retake or upload an unedited version.`, flag: 'exif_editor' };
      }
      // N — DateTime mismatch (file modified after capture)
      const dto = exif.DateTimeOriginal && new Date(exif.DateTimeOriginal);
      const dtm = exif.DateTime && new Date(exif.DateTime);
      if (dto && dtm && Math.abs(dto - dtm) > 5 * 60 * 1000) {
        // 5-minute tolerance for clock drift / timezone wobble
        return { ok: false, warn: true, msg: 'This image looks like it was modified after it was taken. Please upload the unedited original.', flag: 'exif_datetime_mismatch' };
      }
      return { ok: true };
    } catch { return { ok: true }; }
  }
  // PDFs are checked server-side; client-side PDF metadata parsing is fragile.
  return { ok: true };
}

// ===== Live face check for the selfie camera =====
// Lazy-loads MediaPipe Face Detection (~2.5MB) when the selfie camera
// opens. Runs detection at 4 fps and gates the shutter button until we
// see exactly one face with all six facial landmarks visible, decent
// confidence, and reasonable framing. Falls back to "unavailable" (lets
// capture proceed without the check) if the CDN can't load — we don't
// want a CDN outage to block real users entirely; the server-side
// multi-frame + liveness signals (O, Q in VERIFICATION.md) are the
// belt-and-braces backstop.

const OA_FACE_VERSION = '0.4.1646425229';
const OA_FACE_BASE = `https://cdn.jsdelivr.net/npm/@mediapipe/face_detection@${OA_FACE_VERSION}`;
let oaFaceLoading = null;
function oaEnsureFaceDetect() {
  if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
  if (window.FaceDetection) return Promise.resolve(window.FaceDetection);
  if (oaFaceLoading) return oaFaceLoading;
  oaFaceLoading = new Promise((resolve, reject) => {
    const script = document.createElement('script');
    script.src = `${OA_FACE_BASE}/face_detection.js`;
    script.crossOrigin = 'anonymous';
    script.onerror = () => { oaFaceLoading = null; reject(new Error('face_detection load failed')); };
    script.onload = () => {
      if (window.FaceDetection) resolve(window.FaceDetection);
      else { oaFaceLoading = null; reject(new Error('FaceDetection global missing')); }
    };
    document.head.appendChild(script);
  });
  return oaFaceLoading;
}

// Evaluate a MediaPipe detection result against our "good selfie"
// criteria. Returns { state, message } where state drives the camera
// modal's border colour and shutter-enabled state. Order of checks is
// deliberate — we surface the most specific actionable feedback first
// (e.g. "centre your face" beats "low confidence" because the user can
// act on it).
// Reused offscreen canvas for the per-frame eye-visibility check.
// Keeping a module-level instance avoids the GC churn of allocating a
// new canvas 8 times a second.
let oaEyeCheckCanvas = null;

// Sample a small patch around each eye landmark and check that the
// pixels there look like a real eye (high variance — iris/sclera/
// lashes contrast) rather than uniform skin (e.g. a hand covering
// them). MediaPipe's BlazeFace returns 6 landmarks even when some
// are occluded — it estimates positions, doesn't validate visibility
// — so we need this extra check to catch the "hand over eyes" case.
function oaCheckEyeVisibility(video, lm) {
  if (!video || !video.videoWidth || !lm || lm.length < 2) return { ok: true };
  const vw = video.videoWidth;
  const vh = video.videoHeight;
  if (!oaEyeCheckCanvas) oaEyeCheckCanvas = document.createElement('canvas');
  oaEyeCheckCanvas.width = vw;
  oaEyeCheckCanvas.height = vh;
  const ctx = oaEyeCheckCanvas.getContext('2d');
  if (!ctx) return { ok: true };
  try { ctx.drawImage(video, 0, 0, vw, vh); } catch { return { ok: true }; }

  const SAMPLE = 28; // patch side in pixels
  const sampleAround = (pt) => {
    const cx = Math.round(pt.x * vw);
    const cy = Math.round(pt.y * vh);
    const x0 = Math.max(0, cx - Math.floor(SAMPLE / 2));
    const y0 = Math.max(0, cy - Math.floor(SAMPLE / 2));
    const sw = Math.min(SAMPLE, vw - x0);
    const sh = Math.min(SAMPLE, vh - y0);
    if (sw < 4 || sh < 4) return null;
    try { return ctx.getImageData(x0, y0, sw, sh).data; } catch { return null; }
  };
  const grayVariance = (data) => {
    if (!data) return null;
    let sum = 0, n = 0;
    for (let i = 0; i < data.length; i += 4) {
      sum += (data[i] + data[i + 1] + data[i + 2]) / 3;
      n++;
    }
    const mean = sum / n;
    let varSum = 0;
    for (let i = 0; i < data.length; i += 4) {
      const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
      varSum += (gray - mean) * (gray - mean);
    }
    return varSum / n;
  };
  // MediaPipe order: lm[0] = right eye, lm[1] = left eye
  const rightVar = grayVariance(sampleAround(lm[0]));
  const leftVar = grayVariance(sampleAround(lm[1]));
  // Eyes (iris + sclera + lashes) typically have variance well above
  // 600 even in dim light. Skin / hand patches have variance < 200.
  // 350 is a forgiving threshold that flags obvious occlusion without
  // false-flagging closed eyes or low-light shots.
  const T = 350;
  const rightOccluded = rightVar != null && rightVar < T;
  const leftOccluded = leftVar != null && leftVar < T;
  if (rightOccluded && leftOccluded) {
    return { ok: false, message: "Something's covering your eyes — please move it out of the way." };
  }
  if (rightOccluded || leftOccluded) {
    return { ok: false, message: "We can only see one eye — make sure both are clearly visible." };
  }
  return { ok: true };
}

// Five broad states. Earlier versions surfaced a different specific
// message for every failure mode (too-high, too-low, too-small,
// off-center, partial, eyes-occluded, low-conf…), which created
// distracting message flicker as MediaPipe re-evaluated each frame
// and the failure mode bounced between categories. The internal
// state values are still distinct for styling / debugging, but the
// visible message collapses to one of four short prompts plus the
// ready prompt.
function oaEvaluateFaceFrame(results, video) {
  const dets = (results && results.detections) || [];
  if (dets.length === 0) {
    return { state: 'searching', message: 'Position your face in the frame.' };
  }
  if (dets.length > 1) {
    return { state: 'multiple', message: 'Only you should be in frame.' };
  }
  const d = dets[0];
  const bbox = d.boundingBox || {};
  const w = bbox.width || 0;
  const h = bbox.height || 0;
  const cx = bbox.xCenter || 0.5;
  const cy = bbox.yCenter || 0.5;
  const lm = d.landmarks || [];

  // Framing — face is detected but not properly positioned (too far,
  // off-centre, cut off at top or bottom).
  let framingIssue = false;
  if (w < 0.20 || h < 0.25) framingIssue = true;
  if (cx < 0.15 || cx > 0.85) framingIssue = true;
  if (cy < 0.10 || cy > 0.90) framingIssue = true;
  if (lm.length >= 4) {
    const eyeY = (lm[0].y + lm[1].y) / 2;
    const noseY = lm[2].y;
    const mouthY = lm[3].y;
    if (eyeY < 0.05 || mouthY > 0.95 || noseY > 0.95) framingIssue = true;
  }
  if (framingIssue) {
    return { state: 'framing', message: 'Move closer and centre your face in the frame.' };
  }

  // Features — landmarks missing or eyes obscured (hand / glasses /
  // glare). Single message covers all "we can see your face but not
  // your features" cases.
  if (lm.length < 6) {
    return { state: 'features', message: 'Make sure your eyes, nose, and mouth are all clearly visible.' };
  }
  const inFrame = lm.every((p) => p.x >= -0.05 && p.x <= 1.05 && p.y >= -0.05 && p.y <= 1.05);
  if (!inFrame) {
    return { state: 'features', message: 'Make sure your eyes, nose, and mouth are all clearly visible.' };
  }
  const eyes = oaCheckEyeVisibility(video, lm);
  if (!eyes.ok) {
    return { state: 'features', message: 'Make sure your eyes, nose, and mouth are all clearly visible.' };
  }
  return { state: 'ready', message: 'Looks good — hold steady and tap the shutter.' };
}

function OpenAccountPage({ onGoto }) {
  // Lazy-load exifr (~15kb gz) only on this page
  useEffectRentals(() => {
    if (window.exifr) return;
    const s = document.createElement('script');
    s.src = 'https://cdn.jsdelivr.net/npm/exifr@7.1.3/dist/lite.umd.js';
    s.async = true;
    document.head.appendChild(s);
  }, []);

  // Persistable state (everything except File objects, which can't be
  // serialised). EE save & resume (VERIFICATION.md round 3) — persist
  // to localStorage with a 7-day TTL so an applicant who closes the tab
  // can pick up where they left off. File uploads still have to be
  // redone because File objects can't be serialised, but the typed-form
  // state (name, email, references, etc.) survives. We DON'T silently
  // restore a stale partial — instead we set a flag so the wizard can
  // show a "Resume?" prompt on the welcome screen.
  const OA_RESUME_TTL_MS = 7 * 24 * 60 * 60 * 1000;
  const [resumeOffer, setResumeOffer] = useStateRentals(null); // null | { data, savedAt }
  const [data, setData] = useStateRentals(() => {
    try {
      const raw = localStorage.getItem(OA_STORAGE_KEY);
      if (!raw) return OA_DEFAULT_DATA;
      const parsed = JSON.parse(raw);
      // Legacy entries (pre-EE) had no envelope — treat as live data.
      if (!parsed || !parsed.savedAt) return { ...OA_DEFAULT_DATA, ...(parsed || {}) };
      const age = Date.now() - parsed.savedAt;
      if (age > OA_RESUME_TTL_MS) {
        // Stale — drop it silently
        try { localStorage.removeItem(OA_STORAGE_KEY); } catch {}
        return OA_DEFAULT_DATA;
      }
      // Only offer to resume if they were past the welcome step
      const restored = { ...OA_DEFAULT_DATA, ...(parsed.data || {}) };
      if (restored.step > 0) {
        // Stash the offer for the welcome screen to render; start fresh
        // until they confirm. Avoids the "I'm halfway through a form I
        // don't remember opening" disorientation.
        setTimeout(() => setResumeOffer({ data: restored, savedAt: parsed.savedAt }), 0);
        return OA_DEFAULT_DATA;
      }
      return restored;
    } catch {}
    return OA_DEFAULT_DATA;
  });
  useEffectRentals(() => {
    try {
      // Wrap in an envelope with savedAt so the loader can apply TTL.
      localStorage.setItem(OA_STORAGE_KEY, JSON.stringify({
        savedAt: Date.now(),
        data,
      }));
    } catch {}
  }, [data]);

  // Email pre-fill from URL: when the cart's "No verified account on
  // file" banner sends someone here via `/rentals/open-account?email=…`,
  // pre-populate the applicant email field so they don't retype.
  // No-op when the field already has a value (resume / typing wins).
  useEffectRentals(() => {
    try {
      const sp = new URLSearchParams(window.location.search);
      const seedEmail = (sp.get('email') || '').trim();
      if (!seedEmail) return;
      if (!/^\S+@\S+\.\S+$/.test(seedEmail)) return;
      setData((d) => (d.about?.email
        ? d
        : { ...d, about: { ...d.about, email: seedEmail } }));
    } catch {}
  // Run once on mount — re-runs would fight user typing.
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Back-fill the applicant's email + name onto the partial Notion
  // row as soon as they've typed a valid email on step 3. Necessary
  // for the resume-reminder cron to have somewhere to send if they
  // abandon between step 3 and submit. Debounced to avoid hammering
  // the endpoint on every keystroke. No-op until /start has fired
  // (no applicantId) or before the email is valid.
  useEffectRentals(() => {
    const id = data.applicantId;
    const email = (data.about.email || '').trim();
    if (!id) return;
    if (!/^\S+@\S+\.\S+$/.test(email)) return;
    const t = setTimeout(() => {
      fetch('/api/applications/update-progress', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          applicantId: id,
          applicantEmail: email,
          applicantFirstName: data.about.firstName || '',
          applicantLastName: data.about.surname || '',
        }),
      }).catch((e) => console.warn('[update-progress] fire-and-forget failed:', e?.message));
    }, 600);
    return () => clearTimeout(t);
  }, [data.applicantId, data.about.email, data.about.firstName, data.about.surname]);
  const acceptResume = () => {
    if (!resumeOffer) return;
    setData(resumeOffer.data);
    setResumeOffer(null);
  };
  const dismissResume = () => {
    setResumeOffer(null);
    try { localStorage.removeItem(OA_STORAGE_KEY); } catch {}
  };

  // Bot-trap (honeypot) — a hidden input bots love to fill in but
  // real humans never see. If it carries any value at submit time we
  // ship the submission anyway (so the bot doesn't know it tripped a
  // trap) but tag the Notion row so review-side flags catch it.
  const [honeypot, setHoneypot] = useStateRentals('');
  // Time-to-submit signal — record when the applicant FIRST leaves
  // the welcome step (i.e., genuinely starts the application). Stays
  // null until that transition. Persists in `data` so a resume from
  // localStorage keeps the original startedAt. Submission < 70s from
  // this point is flagged as suspicious server-side.
  const [files, setFiles] = useStateRentals({ id: null, selfie: null, proof1: null, proof2: null });
  const [fileMeta, setFileMeta] = useStateRentals({}); // { id: { thumb, warn, msg } }
  // Per-proof background address check. Keyed by slot ('proof1' / 'proof2').
  // State machine: pending (extracting) → matched | unmatched | inconclusive.
  // Stores extractedText so we can re-run the match if the applicant edits
  // their typed address later without re-extracting the PDF.
  const [addressCheck, setAddressCheck] = useStateRentals({});
  // Deterrent — after the real client-side checks (uploads + pdf.js
  // address match) all pass, we hold the step-1 dot in the pulsing
  // "in-progress" state for a few extra seconds before flipping it
  // green. The applicant can still advance to step 2 and carry on —
  // this is purely visual, designed to imply a deeper backend
  // verification is running. Reset to false if a step-1 file or
  // address check changes after the timer fired (mimics a real
  // re-check on swap-out).
  const [step1DeepCheckDone, setStep1DeepCheckDone] = useStateRentals(false);
  // V — live postcode validation via postcodes.io. State machine:
  //   null | { state: 'pending' | 'valid' | 'invalid', postcode, region }
  // The check runs after a short debounce on the address field so we
  // don't fire a request on every keystroke. Only surfaced as an
  // inline warning when the postcode is invalid — valid is silent.
  const [postcodeStatus, setPostcodeStatus] = useStateRentals(null);
  // OCR-based autofill from uploaded documents. When the applicant
  // uploads their Photo ID and proofs of address, we OCR the images
  // (or extract text from PDFs) and try to pull out their name and
  // address. Those values pre-fill the corresponding form fields IF
  // the applicant hasn't already typed something — their typing
  // always wins.
  const [docExtract, setDocExtract] = useStateRentals({
    nameSource: null,     // 'passport' | 'driving_licence' | 'labelled' | null
    addressSource: null,  // 'proof1' | 'proof2' | null
    nameStatus: 'idle',   // 'idle' | 'running' | 'done' | 'failed'
    addressStatus: 'idle',
    // Per-field tracking of "this value came from OCR-driven pre-fill".
    // Drives the split between the top (to-fill) section and the
    // bottom (pre-filled) section on step 3. Once set true, stays
    // true even if the user edits the field — the bottom section is
    // a layout choice, not a content claim.
    prefilledFields: { firstName: false, surname: false, address: false },
  });
  // Whether the pre-filled summary card on step 3 is showing the
  // editable fields (true) or just the collapsed summary line (false).
  const [prefillExpanded, setPrefillExpanded] = useStateRentals(false);
  // Per-section edit mode on the Review page (step 4). Each section
  // has its own Edit/Done toggle so the applicant can fix one block
  // without disturbing the others. Verification stays "Edit → step 1"
  // because file re-upload is best done in the original tiles UI.
  const [reviewEdit, setReviewEdit] = useStateRentals({ profile: false, details: false, references: false });
  // Reference-email check status — populated when the applicant
  // clicks Continue on step 2. Each entry: { state, email, reason? }
  // where state is 'idle' | 'checking' | 'ok' | 'fail'. Lets the step
  // indicator turn step 2 green when both addresses validate (and
  // red when they don't). The `email` field captures the address at
  // check time so we know if the applicant subsequently changed it
  // (forcing a re-check).
  const [refsCheck, setRefsCheck] = useStateRentals([{ state: 'idle', email: '' }, { state: 'idle', email: '' }]);
  const [refsChecking, setRefsChecking] = useStateRentals(false);
  // Tracks which steps have been validated on the corresponding
  // Continue click. The step indicator promotes a step's dot to
  // green ('passed') once it's in here. Reset to false if the
  // applicant edits values that would invalidate the prior check.
  const [stepsPassed, setStepsPassed] = useStateRentals({ 2: false, 3: false });
  // Selfie capture has moved to a step 3 → step 4 trigger. This flag
  // tracks "applicant clicked Continue on step 3 and we're waiting on
  // a selfie capture to advance to step 4". capturePhoto / closeCamera
  // check it to advance the wizard once the selfie file lands, or
  // abort cleanly if the camera modal is cancelled.
  const [step3PendingSelfie, setStep3PendingSelfie] = useStateRentals(false);
  // Auto-extract company name from the portfolio URL. Fires on a
  // 800ms debounce after the applicant stops typing in the portfolio
  // field. Server-side endpoint scrapes og:site_name / JSON-LD
  // Organization / <title>; if nothing extractable, the company
  // defaults to "Sole Trader" at submit time. Only overwrites
  // `data.about.company` if it's currently empty — applicant typing
  // always wins.
  useEffectRentals(() => {
    const portfolio = (data.about.portfolio || '').trim();
    if (!portfolio || data.about.company.trim()) return;
    if (oaPortfolioCheck(portfolio).state !== 'good') return;
    const timer = setTimeout(async () => {
      try {
        const r = await fetch('/api/extract-company', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ url: portfolio }),
        });
        if (!r.ok) return;
        const j = await r.json().catch(() => null);
        if (!j) return;
        console.info('[extract-company] result:', j);
        if (j.company) {
          setData((d) => {
            if (d.about.company.trim()) return d; // user typed in the meantime
            return { ...d, about: { ...d.about, company: j.company } };
          });
        }
      } catch (e) {
        console.warn('[extract-company] failed:', e && e.message);
      }
    }, 800);
    return () => clearTimeout(timer);
  }, [data.about.portfolio]);
  useEffectRentals(() => {
    const addr = data.about.address || '';
    const pcs = oaExtractUkPostcodes(addr);
    if (pcs.length === 0) {
      setPostcodeStatus(null);
      return;
    }
    const pc = pcs[pcs.length - 1]; // use the last postcode found (more likely to be the actual one)
    setPostcodeStatus({ state: 'pending', postcode: pc, region: '' });
    const t = setTimeout(async () => {
      const result = await oaValidateUkPostcode(pc);
      if (!result.ok) {
        // Network failure — keep silent rather than nag
        setPostcodeStatus(null);
        return;
      }
      setPostcodeStatus({
        state: result.valid ? 'valid' : 'invalid',
        postcode: pc,
        region: result.region,
      });
    }, 600);
    return () => clearTimeout(t);
  }, [data.about.address]);

  // Camera capture — used by both the selfie slot (front camera, mirrored)
  // and the photo-ID slot (back camera, unmirrored). cameraOpen holds
  // the active slot key ('selfie' | 'id') while the modal is up, or null.
  const [cameraOpen, setCameraOpen] = useStateRentals(null);
  const [cameraError, setCameraError] = useStateRentals('');
  // null = still detecting / unsupported, true/false = device has a video
  // input. Drives the selfie UI: if a camera is present we hide the upload
  // fallback and force a live capture; if no camera, only the upload is
  // offered. Detection uses enumerateDevices which does NOT require
  // permission — empty labels are fine, we only need the count.
  const [hasCamera, setHasCamera] = useStateRentals(null);
  const videoRef = React.useRef(null);
  const streamRef = React.useRef(null);
  // Live face-check state for the selfie camera. State machine:
  //   'loading'    — MediaPipe loading on first open
  //   'searching'  — 0 faces visible
  //   'multiple'   — 2+ faces visible
  //   'too-small'  — face too far from camera
  //   'off-center' — face outside the central 60% of the frame
  //   'low-conf'   — detection confidence < 0.7 (lighting, occlusion, etc.)
  //   'partial'    — some facial landmarks missing or out of frame
  //   'ready'      — all checks pass, shutter enabled
  //   'unavailable'— MediaPipe failed to load; fail open
  const [faceCheck, setFaceCheck] = useStateRentals({ state: 'loading', message: 'Setting up face check…' });
  const faceDetectorRef = React.useRef(null);
  const faceCheckActiveRef = React.useRef(false);
  // Stability buffer for the face-check state machine. The visible
  // message + outline only update when the same state has been seen
  // for STABLE_FRAMES consecutive detection ticks. Damps the flicker
  // between framing / features when MediaPipe oscillates per frame
  // while the applicant is repositioning.
  const faceCheckPendingRef = React.useRef({ state: null, value: null, count: 0 });
  // Selfie-camera fallback. If the face check doesn't unlock the shutter
  // within 7 seconds (CDN slow, awkward lighting/angle, MediaPipe model
  // misbehaving on a particular setup), offer an "upload a photo
  // instead" route so the applicant doesn't get stranded in front of
  // their camera.
  const [cameraFallbackOffered, setCameraFallbackOffered] = useStateRentals(false);

  useEffectRentals(() => {
    if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) {
      setHasCamera(false);
      return;
    }
    navigator.mediaDevices
      .enumerateDevices()
      .then((devices) => setHasCamera(devices.some((d) => d.kind === 'videoinput')))
      .catch(() => setHasCamera(false));
  }, []);
  const [submitting, setSubmitting] = useStateRentals(false);
  const [submitError, setSubmitError] = useStateRentals('');
  const [submitted, setSubmitted] = useStateRentals(false);
  // Toggles the "View what you submitted" summary on the success screen.
  // In-memory only — refreshing the page wipes both `data` and `files`,
  // so the summary lives for as long as the success screen is on screen.
  const [showSummary, setShowSummary] = useStateRentals(false);

  // Double-opt-in tri-state (VERIFICATION.md B + §1):
  //   null  — not applicable yet (e.g. /start hasn't fired). Don't
  //           block submit.
  //   false — /start sent a verification email; applicant hasn't
  //           clicked the link yet. Submit blocked + banner shown.
  //   true  — link clicked (verified via storage event from the
  //           verify-email landing page) OR existing-verified row.
  const [emailVerified, setEmailVerified] = useStateRentals(null);
  // Listen for the verification landing page's storage broadcast.
  // The /api/applications/verify-email success page writes the
  // 'vf-oa-email-verified' key into localStorage, which fires the
  // storage event in OTHER tabs of the same origin (i.e. this one).
  // Plain BroadcastChannel covers same-origin cross-window cases on
  // browsers that support it.
  useEffectRentals(() => {
    const onStorage = (e) => {
      if (e.key !== 'vf-oa-email-verified' || !e.newValue) return;
      try {
        const payload = JSON.parse(e.newValue);
        if (payload && payload.email && payload.email.toLowerCase() === (data.about.email || '').toLowerCase()) {
          setEmailVerified(true);
        }
      } catch {}
    };
    window.addEventListener('storage', onStorage);
    let ch = null;
    if (typeof BroadcastChannel === 'function') {
      try {
        ch = new BroadcastChannel('vf-oa');
        ch.onmessage = (ev) => {
          if (ev?.data?.kind === 'email-verified' && ev.data.email && ev.data.email.toLowerCase() === (data.about.email || '').toLowerCase()) {
            setEmailVerified(true);
          }
        };
      } catch {}
    }
    return () => {
      window.removeEventListener('storage', onStorage);
      if (ch) { try { ch.close(); } catch {} }
    };
  }, [data.about.email]);

  const step = data.step;
  const setStep = (n) => setData((d) => ({ ...d, step: n }));
  // Tracks the furthest step the applicant has ever reached in this
  // session — used to gate jump-to-step clicks on the top progress
  // bar (you can hop back to anything you've visited, but you can't
  // skip ahead to a step you haven't filled out).
  const [maxStepReached, setMaxStepReached] = useStateRentals(step);
  useEffectRentals(() => {
    if (step > maxStepReached) setMaxStepReached(step);
  }, [step, maxStepReached]);
  const setAbout = (k) => (e) => {
    const v = e.target.value;
    setData((d) => ({ ...d, about: { ...d.about, [k]: v } }));
    // Editing any of the step-3 validated fields demotes the green
    // "Your details verified" dot back to blue/in-progress so the
    // applicant re-validates on next Continue.
    if (k === 'email' || k === 'phone' || k === 'portfolio') {
      setStepsPassed((s) => ({ ...s, 3: false }));
    }
  };
  const setRef = (i, k) => (e) => {
    const v = e.target.value;
    setData((d) => ({
      ...d,
      references: d.references.map((r, idx) => (idx === i ? { ...r, [k]: v } : r))
    }));
    // If the applicant edits a reference email AFTER pre-flight check
    // succeeded, the cached check result is no longer trustworthy.
    // Reset that ref's check state so the dot drops back to "not yet
    // verified" until they hit Continue again.
    if (k === 'email') {
      setRefsCheck((arr) => arr.map((rr, idx) => (idx === i ? { state: 'idle', email: '' } : rr)));
      setStepsPassed((s) => ({ ...s, 2: false }));
    }
  };
  // Same invalidation for the About fields that step 3 validates on
  // Continue. Editing any of them after step 3 went green should
  // demote it back to blue/in-progress.
  const invalidateStep3Passed = () => setStepsPassed((s) => ({ ...s, 3: false }));
  const setConsent = (k) => (e) => setData((d) => ({ ...d, consent: { ...d.consent, [k]: e.target.checked } }));

  const onPickFile = async (slot, file, opts) => {
    if (!file) return;
    const fromCamera = !!(opts && opts.fromCamera);

    // Block obvious duplicate proof-of-address uploads. Two proofs need to
    // come from *different* providers — if the file in the other slot has
    // the same name and size, it's almost certainly the same document.
    // Compare on name + size (not hash) because hashing is async and we
    // want this to feel instant; the rare false negative (same file with
    // different name) gets caught at human review anyway.
    if (slot === 'proof1' || slot === 'proof2') {
      const otherKey = slot === 'proof1' ? 'proof2' : 'proof1';
      const otherDoc = slot === 'proof1' ? 'Document 2' : 'Document 1';
      const other = files[otherKey];
      if (other && other.name === file.name && other.size === file.size) {
        setFileMeta((m) => ({ ...m, [slot]: {
          warn: true,
          msg: `This looks like the same file as ${otherDoc}. Please upload a second proof of address from a different provider.`,
          pending: false,
        } }));
        return; // do NOT accept the file
      }
    }

    const thumb = file.type.startsWith('image/') ? URL.createObjectURL(file) : null;
    const startedAt = Date.now();
    setFiles((f) => ({ ...f, [slot]: file }));
    setFileMeta((m) => ({ ...m, [slot]: { thumb, pending: true } }));
    // Camera-captured files have no EXIF (canvas-derived blobs always
    // strip metadata) but we KNOW they're fresh, so skip the screenshot
    // heuristic for them.
    const check = fromCamera ? { ok: true } : await oaDetectScreenshot(file);
    // Hold the "pending / checking…" state for a minimum visible
    // duration so the applicant gets a clear "yes, we're inspecting
    // this" moment even when the local checks completed in <100ms.
    // Without this, fast paths feel like the upload didn't do any
    // work. 1200ms is short enough not to feel artificial, long
    // enough to register.
    const MIN_PENDING_MS = 1200;
    const elapsed = Date.now() - startedAt;
    if (elapsed < MIN_PENDING_MS) {
      await new Promise((r) => setTimeout(r, MIN_PENDING_MS - elapsed));
    }
    setFileMeta((m) => ({ ...m, [slot]: { thumb, ...check, pending: false } }));

    // OCR-based autofill (background). Don't block file acceptance —
    // even if OCR fails, the file is still uploaded and the human
    // reviewer can read it. Tesseract.js is heavy (~5MB) but only
    // loads once and is cached after that.
    //
    // Diagnostic logs at every routing decision so we can see in the
    // browser console why OCR did or didn't run on a specific file.
    if (slot === 'id') {
      console.info('[ID OCR] file received:', { name: file.name, type: file.type, size: file.size });
      if (file.type.startsWith('image/')) {
        console.info('[ID OCR] routing to image OCR (Tesseract)');
        runDocExtractName(file).catch((e) => console.warn('[ID OCR] failed:', e && e.message));
      } else if (file.type === 'application/pdf') {
        console.info('[ID OCR] routing to PDF text extraction (pdf.js)');
        runDocExtractNameFromPdf(file).catch((e) => console.warn('[ID OCR] PDF parse failed:', e && e.message));
      } else {
        console.warn('[ID OCR] file type not supported for OCR (need image/* or application/pdf):', file.type);
      }
    } else if (slot === 'proof1' || slot === 'proof2') {
      console.info(`[${slot} address] file received:`, { name: file.name, type: file.type, size: file.size });
      if (file.type.startsWith('image/')) {
        console.info(`[${slot} address] routing to image OCR (Tesseract)`);
        runDocExtractAddress(file, slot).catch((e) => console.warn(`[${slot} address] OCR failed:`, e && e.message));
      } else if (file.type === 'application/pdf') {
        console.info(`[${slot} address] routing to PDF text extraction (pdf.js)`);
        runDocExtractAddressFromPdf(file, slot).catch((e) => console.warn(`[${slot} address] PDF parse failed:`, e && e.message));
      } else {
        console.warn(`[${slot} address] file type not supported:`, file.type);
      }
    }
  };

  // Background OCR runners that drive the pre-fill. Each is a no-op if
  // the applicant has already typed something into the corresponding
  // form field — their typing always wins. The setData calls use the
  // functional form so we read the latest state, not a stale closure.
  const runDocExtractName = async (file) => {
    setDocExtract((s) => ({ ...s, nameStatus: 'running' }));
    let text;
    try {
      text = await oaOcrImage(file);
    } catch (e) {
      console.warn('[ID OCR] failed:', e && e.message);
      setDocExtract((s) => ({ ...s, nameStatus: 'failed' }));
      return;
    }
    const parsed = oaParseNameFromIdText(text);
    // Diagnostic log so we can see what OCR'd text looked like vs the
    // parser result. Surfaces in Safari Web Inspector → Console.
    // Truncated to keep the console readable. Not PII-leaking to the
    // server — purely local debug aid.
    console.info('[ID OCR] extracted text (first 400 chars):', (text || '').slice(0, 400));
    console.info('[ID OCR] parsed name:', parsed);
    if (!parsed) {
      setDocExtract((s) => ({ ...s, nameStatus: 'failed' }));
      return;
    }
    const willFillFirst = !data.about.firstName.trim() && !!parsed.firstName;
    const willFillSur = !data.about.surname.trim() && !!parsed.surname;
    if (willFillFirst || willFillSur) {
      setData((d) => {
        const next = { ...d, about: { ...d.about } };
        if (!next.about.firstName.trim() && parsed.firstName) next.about.firstName = parsed.firstName;
        if (!next.about.surname.trim() && parsed.surname) next.about.surname = parsed.surname;
        return next;
      });
    }
    setDocExtract((s) => ({
      ...s,
      nameStatus: 'done',
      nameSource: parsed.source,
      prefilledFields: {
        ...s.prefilledFields,
        firstName: willFillFirst || s.prefilledFields.firstName,
        surname: willFillSur || s.prefilledFields.surname,
      },
    }));
  };

  // PDF photo IDs (passport scans saved as PDF, official PDF copies of
  // a driving licence, etc.) get their text via pdf.js — same path as
  // proof-of-address PDFs. Saves a 5MB Tesseract download for the
  // common "scan to PDF" use case.
  const runDocExtractNameFromPdf = async (file) => {
    setDocExtract((s) => ({ ...s, nameStatus: 'running' }));
    let text;
    try {
      text = await oaExtractPdfText(file);
    } catch (e) {
      console.warn('[ID OCR] PDF parse failed:', e && e.message);
      setDocExtract((s) => ({ ...s, nameStatus: 'failed' }));
      return;
    }
    const parsed = oaParseNameFromIdText(text);
    console.info('[ID OCR] PDF extracted text (first 400 chars):', (text || '').slice(0, 400));
    console.info('[ID OCR] PDF parsed name:', parsed);
    if (!parsed) {
      setDocExtract((s) => ({ ...s, nameStatus: 'failed' }));
      return;
    }
    const willFillFirst = !data.about.firstName.trim() && !!parsed.firstName;
    const willFillSur = !data.about.surname.trim() && !!parsed.surname;
    if (willFillFirst || willFillSur) {
      setData((d) => {
        const next = { ...d, about: { ...d.about } };
        if (!next.about.firstName.trim() && parsed.firstName) next.about.firstName = parsed.firstName;
        if (!next.about.surname.trim() && parsed.surname) next.about.surname = parsed.surname;
        return next;
      });
    }
    setDocExtract((s) => ({
      ...s,
      nameStatus: 'done',
      nameSource: parsed.source,
      prefilledFields: {
        ...s.prefilledFields,
        firstName: willFillFirst || s.prefilledFields.firstName,
        surname: willFillSur || s.prefilledFields.surname,
      },
    }));
  };

  const runDocExtractAddress = async (file, slot) => {
    setDocExtract((s) => ({ ...s, addressStatus: 'running' }));
    let text;
    try {
      text = await oaOcrImage(file);
    } catch (e) {
      setDocExtract((s) => ({ ...s, addressStatus: 'failed' }));
      return;
    }
    applyExtractedAddress(text, slot);
  };

  const runDocExtractAddressFromPdf = async (file, slot) => {
    setDocExtract((s) => ({ ...s, addressStatus: 'running' }));
    let text;
    try {
      text = await oaExtractPdfText(file);
    } catch (e) {
      setDocExtract((s) => ({ ...s, addressStatus: 'failed' }));
      return;
    }
    applyExtractedAddress(text, slot);
  };

  const applyExtractedAddress = (text, slot) => {
    const parsed = oaParseAddressFromText(text);
    console.info(`[${slot} address] extracted text (first 400 chars):`, (text || '').slice(0, 400));
    console.info(`[${slot} address] parsed:`, parsed);
    if (!parsed) {
      setDocExtract((s) => ({ ...s, addressStatus: 'failed' }));
      return;
    }
    const { address, candidateName } = parsed;
    const willFillAddress = !data.about.address.trim() && !!address;
    let nameSplit = null;
    if (candidateName && !data.about.firstName.trim() && !data.about.surname.trim()) {
      nameSplit = oaSplitFullName(candidateName);
    }
    if (willFillAddress || nameSplit) {
      setData((d) => {
        const next = { ...d, about: { ...d.about } };
        if (willFillAddress) next.about.address = address;
        if (nameSplit) {
          if (nameSplit.firstName) next.about.firstName = nameSplit.firstName;
          if (nameSplit.surname) next.about.surname = nameSplit.surname;
        }
        return next;
      });
    }
    setDocExtract((s) => ({
      ...s,
      addressStatus: 'done',
      addressSource: slot,
      prefilledFields: {
        ...s.prefilledFields,
        address: willFillAddress || s.prefilledFields.address,
        firstName: (nameSplit && nameSplit.firstName) ? true : s.prefilledFields.firstName,
        surname: (nameSplit && nameSplit.surname) ? true : s.prefilledFields.surname,
      },
    }));
  };
  const removeFile = (slot) => {
    setFiles((f) => ({ ...f, [slot]: null }));
    setFileMeta((m) => { const n = { ...m }; delete n[slot]; return n; });
  };

  // --- Camera capture (selfie + photo ID) ---
  // Retake-selfie helper used by the review-page avatar button and by
  // the step 3 → step 4 transition. Opens the camera (or file picker
  // if no camera) and lets capturePhoto / onPickFile replace the
  // existing selfie file. capturePhoto checks step3PendingSelfie, so
  // a retake from review won't accidentally advance the wizard.
  const retakeSelfie = () => {
    if (hasCamera) {
      openCamera('selfie');
    } else {
      const input = document.createElement('input');
      input.type = 'file';
      input.accept = 'image/*';
      input.onchange = (e) => {
        const f = e.target.files && e.target.files[0];
        if (f) onPickFile('selfie', f);
      };
      input.click();
    }
  };

  const openCamera = async (slot) => {
    setCameraError('');
    // Selfie uses the front camera (mirrored preview, feels natural).
    // Photo ID uses the back camera (higher resolution, no mirror — the
    // document needs to read right-way-round). On laptops without a back
    // camera, 'environment' as `ideal` falls back to whatever's available.
    const facingMode = slot === 'id' ? { ideal: 'environment' } : 'user';
    const ideal = slot === 'id'
      ? { width: { ideal: 1920 }, height: { ideal: 1080 } }
      : { width: { ideal: 1280 }, height: { ideal: 720 } };
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode, ...ideal },
        audio: false,
      });
      streamRef.current = stream;
      setCameraOpen(slot);
    } catch (err) {
      const msg = err && err.name === 'NotAllowedError'
        ? "Camera permission was denied. You can allow it in your browser's site settings, or upload a photo instead."
        : "Couldn't open the camera. You can upload a photo instead.";
      setCameraError(msg);
    }
  };
  const closeCamera = () => {
    if (streamRef.current) {
      streamRef.current.getTracks().forEach((t) => t.stop());
      streamRef.current = null;
    }
    const wasPending = step3PendingSelfie;
    setCameraOpen(null);
    // If the modal closed without a selfie capture (user hit Cancel),
    // clear the "waiting to advance" flag so the wizard isn't stuck.
    // capturePhoto handles the success path itself before closeCamera
    // runs, so by the time this fires the flag is already cleared on
    // the success path.
    if (wasPending) {
      setStep3PendingSelfie(false);
    }
  };
  const capturePhoto = () => {
    const video = videoRef.current;
    if (!video || !video.videoWidth) return;
    const slot = cameraOpen; // 'selfie' or 'id'
    // Block selfie capture unless the live face check is in the 'ready'
    // state (or 'unavailable' — i.e. MediaPipe didn't load). 'unavailable'
    // falls through so a CDN outage doesn't lock real users out; the
    // server-side liveness checks (O, Q) are the safety net.
    if (slot === 'selfie' && faceCheck.state !== 'ready' && faceCheck.state !== 'unavailable') {
      return;
    }
    const canvas = document.createElement('canvas');
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const ctx = canvas.getContext('2d');
    // Capture is always unmirrored — the CSS scaleX(-1) only flips the
    // preview pixels on screen, not the underlying video frame.
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
    canvas.toBlob((blob) => {
      if (!blob) return;
      const filename = slot === 'id'
        ? `photo-id-${Date.now()}.jpg`
        : `selfie-${Date.now()}.jpg`;
      const file = new File([blob], filename, { type: 'image/jpeg' });
      onPickFile(slot, file, { fromCamera: true });
      // If we were waiting on this selfie to advance to step 4, do
      // that now. Clear the flag BEFORE closeCamera fires so its
      // "cancel" fallback doesn't fire.
      if (slot === 'selfie' && step3PendingSelfie) {
        setStep3PendingSelfie(false);
        setStep(4);
      }
      closeCamera();
    }, 'image/jpeg', 0.92);
  };
  // Attach the live stream to the video element once both exist. Important:
  // NO cleanup function here — that previously ran on every re-render with
  // a stale closure of cameraOpen and killed the stream right after we
  // acquired it (open → cleanup-from-previous-effect-thinks-we're-closing
  // → stops tracks → modal opens with a dead video). Stream lifecycle is
  // managed explicitly by openCamera/closeCamera + the unmount-only effect
  // below.
  useEffectRentals(() => {
    if (cameraOpen && videoRef.current && streamRef.current) {
      videoRef.current.srcObject = streamRef.current;
    }
  }, [cameraOpen]);

  // 7-second fallback timer for the selfie camera. After 7 seconds with
  // the shutter still disabled (face check not ready), surface an
  // "upload a photo instead" link so the applicant can escape if the
  // camera path is misbehaving. Reset on close.
  useEffectRentals(() => {
    if (cameraOpen !== 'selfie') {
      setCameraFallbackOffered(false);
      return;
    }
    const timer = setTimeout(() => setCameraFallbackOffered(true), 7000);
    return () => clearTimeout(timer);
  }, [cameraOpen]);

  // Live face-detection loop. Only runs while the selfie camera is open.
  // Lazy-loads MediaPipe on first selfie capture, then ticks at ~4 fps
  // updating faceCheck state. Cleanup tears down the detector on close.
  useEffectRentals(() => {
    if (cameraOpen !== 'selfie') return;
    let cancelled = false;
    setFaceCheck({ state: 'loading', message: 'Setting up face check…' });

    (async () => {
      let FaceDetectionCtor;
      try {
        FaceDetectionCtor = await oaEnsureFaceDetect();
      } catch (e) {
        if (cancelled) return;
        console.warn('Face detection unavailable:', e && e.message);
        setFaceCheck({
          state: 'unavailable',
          message: 'Face check unavailable — make sure your face is clearly visible, then tap the shutter.',
        });
        return;
      }
      if (cancelled) return;
      let detector;
      try {
        detector = new FaceDetectionCtor({
          locateFile: (file) => `${OA_FACE_BASE}/${file}`,
        });
        detector.setOptions({ model: 'short', minDetectionConfidence: 0.5 });
        detector.onResults((results) => {
          if (cancelled || !faceCheckActiveRef.current) return;
          const next = oaEvaluateFaceFrame(results, videoRef.current);
          // Stability gate — only commit to a new visible state when
          // we've seen the same state in N consecutive ticks. Damps
          // MediaPipe's frame-to-frame oscillation between framing /
          // features when the face is partially obscured.
          const STABLE_FRAMES = 2;
          const pending = faceCheckPendingRef.current;
          if (pending.state === next.state) {
            pending.value = next;
            pending.count++;
          } else {
            pending.state = next.state;
            pending.value = next;
            pending.count = 1;
          }
          if (pending.count >= STABLE_FRAMES) {
            setFaceCheck(pending.value);
          }
        });
      } catch (e) {
        if (cancelled) return;
        console.warn('Face detector init failed:', e && e.message);
        setFaceCheck({ state: 'unavailable', message: 'Face check unavailable — make sure your face is clearly visible.' });
        return;
      }
      faceDetectorRef.current = detector;
      faceCheckActiveRef.current = true;
      setFaceCheck({ state: 'searching', message: 'Position your face in the frame…' });

      // Detection loop at 3 fps (333ms cadence). Combined with the
      // 2-consecutive-frames stability gate in onResults, the visible
      // message changes at most every ~666ms. Each step in this
      // chain (8 → 5 → 3 fps + stability) has been a response to
      // user feedback that the message felt "scattered" or "flicker-y".
      const tick = async () => {
        if (cancelled || !faceCheckActiveRef.current) return;
        const v = videoRef.current;
        if (v && v.videoWidth && v.readyState >= 2) {
          try {
            await detector.send({ image: v });
          } catch (e) {
            // Detection can throw transiently (e.g. video paused during
            // tab switch). Swallow and retry next tick.
          }
        }
        if (!cancelled && faceCheckActiveRef.current) {
          setTimeout(tick, 333);
        }
      };
      tick();
    })();

    return () => {
      cancelled = true;
      faceCheckActiveRef.current = false;
      if (faceDetectorRef.current) {
        try { faceDetectorRef.current.close(); } catch {}
        faceDetectorRef.current = null;
      }
    };
  }, [cameraOpen]);

  // Belt-and-braces unmount cleanup so the camera light goes off if the
  // user navigates away mid-capture. Empty dep array = runs cleanup only
  // when the component unmounts, never on a re-render.
  useEffectRentals(() => {
    return () => {
      if (streamRef.current) {
        streamRef.current.getTracks().forEach((t) => t.stop());
        streamRef.current = null;
      }
    };
  }, []);

  // Background proof-of-address check. Runs whenever a proof file changes
  // OR the applicant's typed address changes. PDFs get text-extracted via
  // lazy-loaded pdf.js; images get marked inconclusive for now (server-side
  // OCR pipeline will catch them later). Re-runs the address match without
  // re-extracting if only the typed address changed.
  useEffectRentals(() => {
    for (const slot of ['proof1', 'proof2']) {
      const f = files[slot];
      if (!f) {
        // File removed — clear state for this slot
        setAddressCheck((c) => {
          if (!c[slot]) return c;
          const { [slot]: _, ...rest } = c;
          return rest;
        });
        continue;
      }

      const current = addressCheck[slot];
      // Same file we've already analysed?
      if (current && current.file === f) {
        // Maybe the applicant's address changed — re-run match if we have text
        if (current.text && data.about.address && current.matchedAddress !== data.about.address) {
          const result = oaCheckAddressMatch(current.text, data.about.address);
          setAddressCheck((c) => ({
            ...c,
            [slot]: {
              ...current,
              state: result.match ? 'matched' : 'unmatched',
              signal: result.signal,
              msg: result.match
                ? 'Address matched on this proof.'
                : "Couldn't find your address on this proof. Please double-check the document or your typed address.",
              matchedAddress: data.about.address,
            },
          }));
        }
        continue;
      }

      // New file — kick off fresh analysis
      if (f.type === 'application/pdf') {
        setAddressCheck((c) => ({ ...c, [slot]: { state: 'pending', file: f } }));
        (async () => {
          try {
            const text = await oaExtractPdfText(f);
            const result = data.about.address
              ? oaCheckAddressMatch(text, data.about.address)
              : null;
            setAddressCheck((c) => ({
              ...c,
              [slot]: {
                file: f,
                text,
                state: !result ? 'extracted' : (result.match ? 'matched' : 'unmatched'),
                signal: result ? result.signal : null,
                msg: !result
                  ? null
                  : result.match
                    ? 'Address matched on this proof.'
                    : "Couldn't find your address on this proof. Please double-check the document or your typed address.",
                matchedAddress: data.about.address || null,
              },
            }));
          } catch (e) {
            console.warn(`Proof address check (${slot}) failed:`, e);
            setAddressCheck((c) => ({
              ...c,
              [slot]: {
                file: f,
                state: 'inconclusive',
                msg: "Couldn't auto-check this proof. We'll review it manually.",
              },
            }));
          }
        })();
      } else {
        // Image file — defer to server-side / manual review
        setAddressCheck((c) => ({
          ...c,
          [slot]: {
            file: f,
            state: 'inconclusive',
            msg: 'Image proofs are reviewed manually after submission.',
          },
        }));
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [files.proof1, files.proof2, data.about.address]);

  // Deterrent — once the REAL client-side checks for step 1 (three
  // files uploaded, neither proof flagged, neither address-match
  // pending or unmatched) have all settled cleanly, hold the dot in
  // the pulsing in-progress state for a randomised 6–10 seconds
  // before flipping it green. The applicant can still click
  // Continue and carry on; this just looks like the backend is
  // chewing on something heavy. If anything changes mid-flight
  // (file swapped, address edited, check fails) the timer resets.
  useEffectRentals(() => {
    const STEP1_KEYS = ['id', 'proof1', 'proof2'];
    const step1Slots = OA_FILE_SLOTS.filter((s) => STEP1_KEYS.includes(s.key));
    const flagged = step1Slots.some((s) => fileMeta[s.key] && fileMeta[s.key].warn);
    const allUploaded = step1Slots.every((s) => files[s.key]);
    const proofPending = ['proof1', 'proof2'].some(
      (k) => addressCheck[k] && addressCheck[k].state === 'pending'
    );
    const proofUnmatched = ['proof1', 'proof2'].some(
      (k) => addressCheck[k] && addressCheck[k].state === 'unmatched'
    );
    const realChecksClean = allUploaded && !flagged && !proofPending && !proofUnmatched;
    if (!realChecksClean) {
      if (step1DeepCheckDone) setStep1DeepCheckDone(false);
      return;
    }
    if (step1DeepCheckDone) return;
    const delay = 6000 + Math.floor(Math.random() * 4000); // 6–10s
    const t = setTimeout(() => setStep1DeepCheckDone(true), delay);
    return () => clearTimeout(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [files.id, files.proof1, files.proof2, fileMeta, addressCheck, step1DeepCheckDone]);

  // Per-step completeness (0..1) for the progress bar
  const stepScore = (n) => {
    if (n === 1) {
      // Step 1 used to require all four slots (Photo ID + selfie +
      // two proofs). Selfie has moved to a step 3 → step 4 trigger,
      // so it's no longer required to leave step 1. The three
      // remaining slots — id, proof1, proof2 — are all required.
      const step1Keys = ['id', 'proof1', 'proof2'];
      const filled = step1Keys.filter((k) => files[k]).length;
      return filled / step1Keys.length;
    }
    if (n === 2) {
      // References (moved before Your Details so background OCR has
      // more time to populate the name/address fields before the
      // applicant reaches them). Email-only entry — referees fill in
      // their own identity on the response form.
      const r = data.references;
      const a = data.about;
      const aEmail = (a.email || '').toLowerCase().trim();
      const domainOf = (e) => (e && e.includes('@')) ? e.slice(e.lastIndexOf('@') + 1).toLowerCase().trim() : '';
      const d0 = domainOf(r[0].email);
      const d1 = domainOf(r[1].email);
      const sameRefDomain = d0 && d1 && d0 === d1;
      const parts = r.flatMap((x) => [
        /^\S+@\S+\.\S+$/.test(x.email) && !oaIsFreeEmail(x.email),
        !(aEmail && x.email && x.email.toLowerCase().trim() === aEmail),
      ]);
      parts.push(!sameRefDomain);
      return parts.filter(Boolean).length / parts.length;
    }
    if (n === 3) {
      // Your details (post-redesign: name and address moved to the
      // Review step. This page only collects email, phone, optional
      // company, and portfolio. Email + phone + portfolio are
      // required to advance; company is optional.)
      const a = data.about;
      // Bidirectional self-vouching block — the applicant's own
      // email can't match either reference email (references are
      // collected first on step 2, so the existing isSelfEmail
      // check there can't fire if the applicant hasn't typed their
      // own email yet). Block step 3 Continue if conflict.
      const aEmailLower = (a.email || '').toLowerCase().trim();
      const conflictsWithRef = data.references.some((r) =>
        r.email && aEmailLower && r.email.toLowerCase().trim() === aEmailLower
      );
      const reqs = [
        /^\S+@\S+\.\S+$/.test(a.email),
        a.phone.trim().length > 0,
        oaPortfolioCheck(a.portfolio).state === 'good',
        !conflictsWithRef,
      ];
      return reqs.filter(Boolean).length / reqs.length;
    }
    if (n === 4) {
      // Review step also gates on first-name, surname, and address
      // being filled in (they now live on the review page rather than
      // step 3). Submit stays disabled until all three plus the
      // consent checkbox.
      const a = data.about;
      const reqs = [
        a.firstName.trim().length > 0,
        a.surname.trim().length > 0,
        a.address.trim().length > 0,
        data.consent.data,
      ];
      return reqs.filter(Boolean).length / reqs.length;
    }
    return 0;
  };
  const stepValid = (n) => stepScore(n) >= (n === 4 ? 1 : 0.999);

  // Per-step verification status for the top progress bar. Drives the
  // colour states (blue in-progress, green passed, red failed) and
  // hover-tooltip copy. Plugs in to the future Chunk B verification
  // pipeline: once the server returns per-file scores, fileMeta gets
  // richer and this function reads them the same way.
  const STEP_NAMES = { 1: 'Verification', 2: 'References', 3: 'Your details', 4: 'Review' };
  const stepStatus = (n) => {
    if (n === 1) {
      // Step 1 only collects id + two proofs of address now — selfie
      // moved to a step 3 → step 4 trigger. Count just those three
      // slots so the dot can actually reach 'passed' (green) once
      // they're all in and the system is happy.
      const STEP1_KEYS = ['id', 'proof1', 'proof2'];
      const step1Slots = OA_FILE_SLOTS.filter((s) => STEP1_KEYS.includes(s.key));
      const flagged = step1Slots.filter((s) => fileMeta[s.key] && fileMeta[s.key].warn);
      const uploaded = step1Slots.filter((s) => files[s.key]).length;
      const total = step1Slots.length;
      // Address-match background check status (pdf.js extraction
      // running, plus matched/unmatched/inconclusive verdicts). We
      // hold the green state until both proofs finish checking, and
      // flip to red if either came back unmatched.
      const proofChecksPending = ['proof1', 'proof2'].some(
        (k) => addressCheck[k] && addressCheck[k].state === 'pending'
      );
      const proofUnmatched = ['proof1', 'proof2'].some(
        (k) => addressCheck[k] && addressCheck[k].state === 'unmatched'
      );
      if (flagged.length) {
        const issues = flagged.map((s) => `${s.label}: ${fileMeta[s.key].msg}`).join(' · ');
        return { state: 'failed', tooltip: `Issues to fix — ${issues}` };
      }
      if (proofUnmatched) {
        return { state: 'failed', tooltip: "One of the proofs of address doesn't match your typed address — please check." };
      }
      if (uploaded === 0 && n < step) return { state: 'pending', tooltip: 'Verification documents not uploaded.' };
      if (uploaded === total && proofChecksPending) {
        return { state: 'in-progress', tooltip: `All ${total} uploaded — verifying address match…` };
      }
      // Real checks all clean but the deterrent timer hasn't fired
      // yet — keep pulsing blue with a 'deep verification' tooltip.
      // (Applicant can still click Continue and advance — this is
      // purely a top-bar visual.)
      if (uploaded === total && !step1DeepCheckDone) {
        return { state: 'in-progress', tooltip: 'Running deeper verification on your documents…' };
      }
      if (uploaded === total) return { state: 'passed', tooltip: 'All documents look good — verified.' };
      if (uploaded > 0)  return { state: 'in-progress', tooltip: `${uploaded} of ${total} documents uploaded — checking…` };
      if (n === step)    return { state: 'in-progress', tooltip: 'Upload your verification documents.' };
      return { state: 'pending', tooltip: 'Verification documents not yet uploaded.' };
    }
    // For non-verification steps: pure progression by default, plus
    // a few explicit overrides:
    //   - 'prefill-pending' (pulse blue) on step 3 while OCR is running
    //   - 'failed' (red) on step 2 when reference checks came back bad
    //   - 'passed' (green) on step 2 once reference checks are OK and
    //     step 3 once email/phone/portfolio validation has fired on
    //     Continue.
    const ocrRunning = docExtract.nameStatus === 'running' || docExtract.addressStatus === 'running';
    if (n === 3 && ocrRunning) {
      return { state: 'prefill-pending', tooltip: 'Reading your documents to fill in this step…' };
    }
    if (n === 2 && refsCheck.some((r) => r.state === 'fail')) {
      // System-level send failures (Resend rejected) are SOFT — the
      // application can still progress, we just chase manually. Only
      // user-fixable reasons (no_mx, invalid_format, etc.) flip the
      // dot red. The text is friendlier for the soft case.
      const fails = refsCheck.filter((r) => r.state === 'fail');
      const allSoft = fails.every((r) => r.reason === 'send_failed');
      if (allSoft) {
        return { state: 'in-progress', tooltip: `${fails.length === 1 ? 'One reference email' : 'Reference emails'} couldn't go out — we'll follow up directly. Submission can still continue.` };
      }
      return { state: 'failed', tooltip: `${fails.length === 1 ? 'One reference email' : 'Both reference emails'} couldn't be sent — please correct on the References step.` };
    }
    // Send is non-blocking — the applicant carries on through
    // steps 3 + 4 while /start runs. Keep the step-2 dot pulsing
    // blue (in-progress) for the whole flight, regardless of which
    // step the wizard has moved on to.
    if (n === 2 && refsChecking) {
      return { state: 'in-progress', tooltip: 'Sending reference emails in the background…' };
    }
    if (n === 2 && stepsPassed[2]) {
      return { state: 'passed', tooltip: 'Reference emails sent.' };
    }
    if (n === 3 && stepsPassed[3]) {
      return { state: 'passed', tooltip: 'Your details validated — email, phone, and portfolio look good.' };
    }
    if (n < step)  return { state: 'completed', tooltip: `${STEP_NAMES[n]} — complete.` };
    if (n === step) return { state: 'in-progress', tooltip: `${STEP_NAMES[n]} — in progress.` };
    return { state: 'pending', tooltip: `${STEP_NAMES[n]} — not yet started.` };
  };

  const next = async () => {
    if (!(stepValid(step) || step === 0)) return;
    // First-ever transition off the welcome step — capture the
    // applicant's "actually started filling in the form" timestamp.
    // Only set once; subsequent step transitions don't reset it (and
    // a resume-from-localStorage carries the original startedAt
    // forward), so the elapsed time we measure on submit reflects
    // the whole journey, not the latest session.
    if (step === 0 && !data.applicationStartedAt) {
      setData((d) => ({ ...d, applicationStartedAt: Date.now() }));
    }
    // Step 2 → step 3 transition: kick off the reference send in the
    // BACKGROUND and advance immediately. The applicant fills in
    // steps 3 + 4 while /start finishes; the step-2 dot stays in its
    // pulsing in-progress state until the result comes back, then
    // turns green (sent) or red (failed). Submit on step 4 is
    // gated on refs being confirmed sent.
    //
    // applicantId returned by /start is stashed on data so the final
    // Submit updates the existing row and doesn't re-send refs.
    if (step === 2) {
      // Guard against double-clicks firing /start twice while the
      // first request is still in flight.
      if (refsChecking) return;
      setRefsChecking(true);
      setRefsCheck([
        { state: 'checking', email: data.references[0].email },
        { state: 'checking', email: data.references[1].email },
      ]);
      // Advance immediately — no await. References resolve in the
      // background while the applicant works through steps 3 + 4.
      setStep(step + 1);
      // Fire and let the promise resolve asynchronously. We don't
      // need to hold the wizard for the result, but we do need to
      // update refsCheck/stepsPassed when it lands so submit knows
      // whether to allow itself.
      (async () => {
        try {
          const r = await fetch('/api/applications/start', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              applicantEmail: data.about.email,
              applicantFirstName: data.about.firstName,
              applicantLastName: data.about.surname,
              applicantId: data.applicantId || null,
              references: [
                { email: data.references[0].email, firstName: data.references[0].name },
                { email: data.references[1].email, firstName: data.references[1].name },
              ],
            }),
          });
          const j = await r.json().catch(() => ({}));
          // Diagnostic — surface the full server response so failures
          // are visible in the browser Console without needing Vercel
          // logs. The `refs` array carries the per-ref reason for any
          // failure ('no_mx' / 'free_email' / 'send_failed' with the
          // raw Resend message, etc.).
          console.info('[start] response:', j);
          if (r.ok && Array.isArray(j.refs)) {
            // Store the applicantId + uploadToken returned by /start.
            // applicantId reuses the row at final Submit; uploadToken
            // is required by /upload (§3 — anyone-with-applicantId can
            // no longer overwrite another applicant's files).
            if (j.applicantId) {
              setData((d) => ({ ...d, applicantId: j.applicantId, uploadToken: j.uploadToken || d.uploadToken }));
            }
            // Double opt-in (B + §1): if /start fired the verification
            // email, gate submit until the applicant clicks the link.
            // Server is defensive too — submit returns email_not_verified.
            if (j.verifyEmailSent) {
              setEmailVerified(false);
            }
            // Map server statuses to client state machine. The server
            // returns 'sent' | 'failed' | 'invalid' per ref.
            //
            // Failure modes split into TWO buckets:
            //  - user-fixable  (no_mx, invalid_format, free_email,
            //                   same_as_applicant, same_domain_as_other)
            //                   → block progress, applicant should fix
            //  - system-side   (send_failed — Resend rejected the send;
            //                   not the applicant's fault)
            //                   → SOFT pass: let the applicant continue
            //                   and we follow up with refs manually.
            //                   Notion row still carries 'Failed' so the
            //                   queue surfaces it for staff.
            const SYS_REASONS = new Set(['send_failed']);
            const result = j.refs.map((rr) => ({
              state: rr.status === 'sent' ? 'ok' : 'fail',
              email: rr.email,
              reason: rr.reason || (rr.status === 'failed' ? 'send_failed' : undefined),
              messageId: rr.messageId,
            }));
            setRefsCheck(result);
            const blockingFailure = result.some((rr) => rr.state === 'fail' && !SYS_REASONS.has(rr.reason));
            setStepsPassed((s) => ({ ...s, 2: !blockingFailure }));
          } else {
            // Network/server error. Soft-pass — we can still take the
            // application, the references will be chased manually. The
            // copy on the warning makes the situation clear.
            console.warn('[start] non-OK response:', r.status, j);
            setRefsCheck([
              { state: 'fail', email: data.references[0].email, reason: 'send_failed' },
              { state: 'fail', email: data.references[1].email, reason: 'send_failed' },
            ]);
            setStepsPassed((s) => ({ ...s, 2: true }));
          }
        } catch (e) {
          console.warn('[start] threw:', e && e.message);
          setRefsCheck([
            { state: 'fail', email: data.references[0].email, reason: 'send_failed' },
            { state: 'fail', email: data.references[1].email, reason: 'send_failed' },
          ]);
          setStepsPassed((s) => ({ ...s, 2: true }));
        } finally {
          setRefsChecking(false);
        }
      })();
      return;
    }
    // Step 3 → step 4 transition: validation already passed (stepValid).
    // Mark step 3 as 'passed' so its dot goes green. If the applicant
    // hasn't taken a selfie yet, this is where we trigger the capture
    // (camera modal on devices with a camera; file picker fallback on
    // those without). The advance to step 4 only happens once the
    // selfie is in.
    if (step === 3) {
      setStepsPassed((s) => ({ ...s, 3: true }));
      if (!files.selfie) {
        if (hasCamera) {
          // Camera path — openCamera + flag for capturePhoto / closeCamera
          // to advance once the file lands.
          setStep3PendingSelfie(true);
          openCamera('selfie');
        } else {
          // No-camera path — programmatically open the OS file picker.
          // Must happen synchronously inside this click handler or the
          // browser will block it.
          const input = document.createElement('input');
          input.type = 'file';
          input.accept = 'image/*';
          input.onchange = (e) => {
            const f = e.target.files && e.target.files[0];
            if (!f) return;
            onPickFile('selfie', f);
            setStep(4);
          };
          input.click();
        }
        return;
      }
    }
    setStep(step + 1);
  };
  const prev = () => { if (step > 0) setStep(step - 1); };

  const submit = async () => {
    // Defence in depth — the button is already disabled while refs
    // are sending or failed, but guard the handler so a stale click
    // (or programmatic dispatch) can't bypass it.
    if (refsChecking) return;
    // Only BLOCK on user-fixable ref failures (bad email format, no MX,
    // etc). System-side send failures (Resend rejected) are soft —
    // submit can still go through; staff chase manually.
    if (refsCheck.some((r) => r.state === 'fail' && r.reason !== 'send_failed')) return;
    setSubmitting(true); setSubmitError('');
    // Short ref the applicant can quote in an email so support can correlate
    // their attempt with whatever's in the logs. Looks like "20260513-1325Z".
    const errorRef = new Date().toISOString().slice(0, 16).replace(/[T:-]/g, '') + 'Z';
    const friendlyFallback = `We've hit a snag on our end (ref ${errorRef}). Please email rentals@valley.film and we'll sort it out.`;
    try {
      // 1. Create the applicant + send reference emails (text-only).
      const resp = await fetch('/api/applications/submit', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          about: data.about,
          references: data.references,
          consent: data.consent,
          // applicantId is set when /start fired earlier on step 2.
          // The submit endpoint uses it to UPDATE the existing partial
          // row + skip re-sending the already-sent reference emails.
          applicantId: data.applicantId || null,
          // Bot-trap (honeypot) — real humans never see this field
          // and shouldn't fill it. Server tags the row if non-empty
          // but accepts the submission so the bot doesn't realise.
          _hp: honeypot || '',
          // Time-to-submit signal — server flags rows where elapsed
          // since this timestamp is under 70 seconds.
          applicationStartedAt: data.applicationStartedAt || null,
        })
      });
      const result = await resp.json().catch(() => ({}));
      if (!resp.ok) {
        // Surface whatever the server actually said — generic "couldn't
        // submit" is the last resort. Helps both real applicants (when
        // their input fails validation) and us (when something's wrong
        // server-side and we need to know what).
        if (result.errors?.length) {
          setSubmitError(result.errors.length === 1
            ? result.errors[0]
            : 'Please check the following: ' + result.errors.join('; '));
        } else if (result.message && resp.status >= 400 && resp.status < 500) {
          // 4xx with a free-text message that isn't from errors[]: looks
          // like a polished user-facing message from the server (e.g. a
          // policy block). Trust it.
          setSubmitError(result.message);
        } else {
          // Anything else is a server-side failure the applicant can't
          // act on. Keep the visible message friendly and stash the full
          // detail in the console so we can debug from a screenshot of
          // devtools rather than making the user read JSON payloads.
          console.error('Open Account submit failed:', { status: resp.status, ref: errorRef, response: result });
          setSubmitError(friendlyFallback);
        }
        return;
      }
      const applicantId = result.applicantId;
      // Prefer a freshly-issued uploadToken from /submit; fall back
      // to whatever /start gave us if the server didn't refresh
      // (older deploys). Without it the upload endpoint 401s.
      const uploadToken = result.uploadToken || data.uploadToken;

      // 2. Upload files in parallel. Failures don't block — the applicant
      // is already in Notion and admin can chase missing docs by email.
      const uploads = OA_FILE_SLOTS
        .filter((slot) => files[slot.key])
        .map((slot) => {
          const f = files[slot.key];
          const url = `/api/applications/upload?applicantId=${encodeURIComponent(applicantId)}&slot=${slot.key}`;
          return fetch(url, {
            method: 'POST',
            headers: {
              'Content-Type': f.type || 'application/octet-stream',
              'X-Filename': f.name,
              ...(uploadToken ? { 'X-Upload-Token': uploadToken } : {}),
            },
            body: f
          }).catch((e) => ({ ok: false, reason: e?.message }));
        });
      await Promise.allSettled(uploads);

      setSubmitted(true);
      try { localStorage.removeItem(OA_STORAGE_KEY); } catch {}
    } catch (e) {
      // Network failure / JSON parse fault — same friendly fallback, full
      // detail to the console.
      console.error('Open Account submit network error:', { ref: errorRef, error: e?.message, stack: e?.stack });
      setSubmitError(friendlyFallback);
    } finally {
      setSubmitting(false);
    }
  };

  // Format file size for previews
  const fmtSize = (b) => b < 1024 ? `${b} B` : b < 1024 * 1024 ? `${Math.round(b / 1024)} KB` : `${(b / 1024 / 1024).toFixed(1)} MB`;

  return (
    <main className="page active rentals-light" data-screen-label="03b Rentals · Open Account">
      <RentalsSubHero
        idx="VR—4.0"
        label="Open Account"
        title="One verification.<br/><em>Faster</em> bookings, credit terms."
        lead="Open a Valley Rentals account once and skip verification on every future booking. After a successful first hire, accounts unlock 30-day credit terms on invoices over £250 and access to repeat-client rates." />

      <section id="open-account" className="rentals-open-account">
        <div className={`container oa-grid ${step > 0 ? 'is-focused' : ''}`}>
          {/* LEFT — always rendered so the collapse to single-column past
              step 0 can animate. Hidden via CSS when .is-focused. */}
          <div className="oa-intro" aria-hidden={step > 0}>
            <p className="oa-who">
              For production companies, agencies and freelance crew. First-time clients complete a short verification — ID, two proofs of address, and two trade references.
            </p>
            <h3>What you'll need to apply</h3>
            <ul className="oa-checklist">
              <li>
                <span className="oa-checklist-icon" aria-hidden="true"><Icon name="file" size={16} /></span>
                Proof of address &amp; ID
              </li>
              <li>
                <span className="oa-checklist-icon" aria-hidden="true"><Icon name="mail" size={16} /></span>
                2 Trade References
              </li>
              <li>
                <span className="oa-checklist-icon" aria-hidden="true"><Icon name="arrow-up-right" size={16} /></span>
                Portfolio link
              </li>
            </ul>
            {/* Trust signal — small grey line that puts a human face
                on the review. Reads better than "our team will get
                back to you" because it implies real eyes on every
                application without overpromising response time. */}
            <p className="oa-trust-line">
              Reviewed in person by our team — usually back to you within an hour or two during UK working hours.
            </p>
            <InsuranceCard compact />
            {/* OaOpenNotice removed — the open-status copy moved to
                OaVerifyStat on the right side of the welcome card. */}
          </div>

          {/* RIGHT — the wizard, inside a form-card */}
          <div className="form-card oa-wizard">
            {submitted ? (
              <div className="contact-sent">
                <div className="check"><Icon name="check" /></div>
                <h3>Application received</h3>
                <p>Thanks {data.about.firstName ? data.about.firstName : 'for applying'}. We've sent a copy of this to <strong>{data.about.email}</strong> for your records. Here's where things stand:</p>
                {/* 4-stage status strip so the applicant can see the
                    application is in motion and that the next things
                    they're waiting on are referee responses + our
                    manual review. Only stage 1 is lit on submit — the
                    others fill in over time and the applicant can
                    check progress via the confirmation email. */}
                <ol className="oa-status-track" aria-label="Application progress">
                  <li className="is-done">
                    <span className="oa-status-track-dot" aria-hidden="true"><Icon name="check" /></span>
                    <span className="oa-status-track-label">
                      <strong>Application submitted</strong>
                      <span>Your documents and references are in.</span>
                    </span>
                  </li>
                  <li className="is-pending">
                    <span className="oa-status-track-dot" aria-hidden="true">2</span>
                    <span className="oa-status-track-label">
                      <strong>References confirmed</strong>
                      <span>Waiting on your two referees to reply.</span>
                    </span>
                  </li>
                  <li className="is-pending">
                    <span className="oa-status-track-dot" aria-hidden="true">3</span>
                    <span className="oa-status-track-label">
                      <strong>Manual check</strong>
                      <span>Our team reviews everything.</span>
                    </span>
                  </li>
                  <li className="is-pending">
                    <span className="oa-status-track-dot" aria-hidden="true">4</span>
                    <span className="oa-status-track-label">
                      <strong>Account confirmed</strong>
                      <span>We email you the green light.</span>
                    </span>
                  </li>
                </ol>
                <button
                  type="button"
                  className="oa-summary-toggle"
                  onClick={(e) => { e.preventDefault(); setShowSummary((s) => !s); }}>
                  {showSummary ? 'Hide summary' : 'View what you submitted'}
                </button>
                {showSummary && (
                  <div className="oa-summary" aria-live="polite">
                    <div className="oa-summary-section">
                      <h4>Verification documents</h4>
                      <dl>
                        {OA_FILE_SLOTS.map((slot) => (
                          <div key={slot.key}>
                            <dt>{slot.label}</dt>
                            <dd>{files[slot.key]?.name || <em>missing</em>}</dd>
                          </div>
                        ))}
                      </dl>
                    </div>
                    <div className="oa-summary-section">
                      <h4>Your details</h4>
                      <dl>
                        <div><dt>Name</dt><dd>{`${data.about.firstName} ${data.about.surname}`.trim()}</dd></div>
                        <div><dt>Email</dt><dd>{data.about.email}</dd></div>
                        <div><dt>Phone</dt><dd>{data.about.phone}</dd></div>
                        {data.about.company && <div><dt>Company</dt><dd>{data.about.company}</dd></div>}
                        <div><dt>Address</dt><dd>{data.about.address}</dd></div>
                        {data.about.billingAddress && <div><dt>Billing</dt><dd>{data.about.billingAddress}</dd></div>}
                        {data.about.portfolio && <div><dt>Portfolio</dt><dd>{data.about.portfolio}</dd></div>}
                      </dl>
                    </div>
                    <div className="oa-summary-section">
                      <h4>Trade references</h4>
                      {data.references.map((r, i) => (
                        <div className="oa-summary-ref" key={i}>
                          <div className="oa-summary-ref-num">Reference {i + 1}</div>
                          <dl>
                            <div><dt>Email</dt><dd>{r.email}</dd></div>
                          </dl>
                        </div>
                      ))}
                      <p className="oa-summary-ref-note">Your referees will fill in their own name, company and relationship when they respond.</p>
                    </div>
                  </div>
                )}
                <OaSecurityBadge />
              </div>
            ) : (
              // <form> wrapper is required for browsers / iOS Keychain /
              // 1Password / Chrome's password manager to recognise the
              // fields as autofill candidates. Submission is handled via
              // the explicit button click; this onSubmit guard only
              // catches accidental Enter-key submission.
              <form
                className="oa-wizard-form"
                name="rentals-open-account"
                autoComplete="on"
                onSubmit={(e) => e.preventDefault()}
              >
                {/* Bot-trap (honeypot). Real humans never see this
                    field. Bots that crawl the DOM and fill every
                    input will populate it; the server tags the
                    submission as suspicious but still accepts it
                    so the bot doesn't know it tripped a trap.
                    Hidden via CSS (.oa-honeypot is offscreen + a11y
                    label below) rather than display:none, which
                    some bots filter out. tabindex=-1 + autocomplete
                    off + aria-hidden keep it inert to real users. */}
                <div className="oa-honeypot" aria-hidden="true">
                  <label htmlFor="oa-website">Leave this field blank</label>
                  <input
                    id="oa-website"
                    type="text"
                    name="website"
                    autoComplete="off"
                    tabIndex={-1}
                    value={honeypot}
                    onChange={(e) => setHoneypot(e.target.value)}
                  />
                </div>
                {/* Progress: hidden on welcome. Each segment carries a
                    state class (in-progress / passed / failed / pending /
                    completed) which the CSS maps to blue / green / red /
                    grey. Hover reveals a tooltip with the section title +
                    current detail; clicking a previously-visited step
                    jumps the wizard back there. */}
                {step > 0 && (
                  <div className="step-indicator">
                    {[1, 2, 3, 4].map((i) => {
                      const fill = i < step ? 1 : i === step ? stepScore(step) : 0;
                      const status = stepStatus(i);
                      // Jump-clickable if the applicant has previously
                      // reached this step (or it's the current step).
                      // Forward jumps to unvisited steps stay locked so
                      // the validation order (verification → references
                      // → details → review) holds.
                      const jumpable = i <= maxStepReached;
                      const jumpTo = () => { if (jumpable && i !== step) setStep(i); };
                      return (
                        <div
                          key={i}
                          className={`dot is-${status.state} ${fill > 0 ? 'filling' : ''} ${jumpable ? 'is-jumpable' : ''}`}
                          tabIndex={jumpable ? 0 : -1}
                          role={jumpable ? 'button' : undefined}
                          aria-label={`${STEP_NAMES[i]}${jumpable && i !== step ? ' — jump to this step' : ''}. ${status.tooltip}`}
                          onClick={(e) => { e.preventDefault(); jumpTo(); }}
                          onKeyDown={(e) => {
                            if (!jumpable) return;
                            if (e.key === 'Enter' || e.key === ' ') {
                              e.preventDefault();
                              jumpTo();
                            }
                          }}
                        >
                          <span className="fill" style={{ transform: `scaleX(${fill})` }} />
                          <span className="dot-tooltip" role="tooltip">
                            <strong className="dot-tooltip-title">{STEP_NAMES[i]}</strong>
                            <span className="dot-tooltip-detail">{status.tooltip}</span>
                          </span>
                        </div>
                      );
                    })}
                  </div>
                )}

                {step === 0 && (
                  <React.Fragment>
                    <h3>Open an account</h3>
                    <p className="desc">Once we've verified you, every future booking skips the paperwork.</p>
                    <div className="oa-welcome-meta">
                      <div><div className="num">2 mins</div><div className="lbl">To apply</div></div>
                      <div className="sep" />
                      <OaVerifyStat />
                    </div>
                    {resumeOffer && (
                      <div className="oa-resume-prompt" role="status" aria-live="polite">
                        <div className="oa-resume-prompt-body">
                          <strong>Pick up where you left off?</strong>
                          <span>We saved your progress from earlier. File uploads will need to be redone.</span>
                        </div>
                        <div className="oa-resume-prompt-actions">
                          <button type="button" className="oa-resume-yes" onClick={(e) => { e.preventDefault(); acceptResume(); }}>Resume</button>
                          <button type="button" className="oa-resume-no" onClick={(e) => { e.preventDefault(); dismissResume(); }}>Start fresh</button>
                        </div>
                      </div>
                    )}
                    <p className="oa-priv">Your information is stored securely and only used to verify and manage your rental account. See our <a href="/privacy" onClick={(e) => { e.preventDefault(); onGoto('/privacy'); }}>privacy notice</a> for full retention details.</p>
                    <button type="button" className="submit" onClick={(e) => { e.preventDefault(); next(); }}>
                      Let's begin <Icon name="arrow-right" />
                    </button>
                    <OaUpdateAccountStub />
                  </React.Fragment>
                )}

                {step === 1 && (
                  <React.Fragment>
                    <h3>Verification documents</h3>
                    <p className="desc">Start uploading. Each file is checked in the background.</p>

                    {/* Photo ID — upload primary, take-a-photo secondary.
                        Upload is the more reliable path (passport scans,
                        existing licence photos, etc.); camera capture is
                        an option for applicants who only have a physical
                        ID to hand. Inverse of the selfie slot's emphasis. */}
                    {(() => {
                      const slot = OA_FILE_SLOTS.find((s) => s.key === 'id');
                      const f = files.id;
                      const meta = fileMeta.id || {};
                      // Mirror the proof-of-address treatment: a clean
                      // upload (file present, no per-file warning) flips
                      // the preview to the green "matched" border + tick
                      // badge. A flagged file uses the red "unmatched"
                      // state instead. Pdf.js doesn't run on the ID, so
                      // the only signals are "clean" or "flagged".
                      const idAccepted = f && !meta.warn;
                      const idFlagged  = f && meta.warn;
                      const idStateClass = idAccepted ? 'oa-proof-matched'
                        : idFlagged ? 'oa-proof-unmatched'
                        : '';
                      return (
                        <div className="field full oa-file-field" key="id">
                          <label>
                            {slot.label}
                            <span className="oa-required-mark" aria-hidden="true">*</span>
                            {slot.details && <OaHelp text={slot.details} label={slot.label} />}
                          </label>
                          {f ? (
                            <div className={`oa-file-preview ${idStateClass}`}>
                              <div className="oa-file-thumb">
                                {meta.thumb ? <img src={meta.thumb} alt="" /> : <Icon name="file" />}
                              </div>
                              <div className="oa-file-info">
                                <div className="oa-file-name">{f.name}</div>
                                <div className="oa-file-meta">{fmtSize(f.size)} · {f.type || 'file'}</div>
                              </div>
                              {idAccepted && (
                                <span className="oa-proof-badge oa-proof-badge--ok" title="Photo ID accepted"><Icon name="check" /></span>
                              )}
                              {idFlagged && (
                                <span className="oa-proof-badge oa-proof-badge--bad" title="Issue with this file">!</span>
                              )}
                              <button type="button" className="oa-file-remove" onClick={() => removeFile('id')}>Remove</button>
                            </div>
                          ) : (
                            <React.Fragment>
                              <label className="oa-file-drop">
                                <Icon name="upload" />
                                <span className="oa-file-drop-lbl">Choose file or drop here</span>
                                <input type="file" accept={slot.accept} onChange={(e) => onPickFile('id', e.target.files[0])} />
                              </label>
                              {hasCamera !== false && (
                                <button
                                  type="button"
                                  className="oa-secondary-camera-link"
                                  onClick={() => openCamera('id')}
                                >
                                  <Icon name="camera" />
                                  Or take a photo of your ID
                                </button>
                              )}
                            </React.Fragment>
                          )}
                          {meta.warn && !meta.pending && (
                            <div className="oa-file-warn">{meta.msg}</div>
                          )}
                        </div>
                      );
                    })()}

                    {/* Selfie moved out of step 1 — now captured automatically
                        when the applicant clicks Continue on step 3 (Your
                        Details). See the next() handler for the trigger
                        logic. If they ever need to retake, the Review page
                        (step 4) profile-hero block has the controls. */}

                    {/* Proof of address — single grouped label with inline
                        help, two documents side-by-side. The fuller "two
                        recent documents from different providers..." copy
                        lives inside the OaHelp tooltip rather than as a
                        visible paragraph. */}
                    {(() => {
                      // Combined proof-of-address upload — single field
                      // that needs two files from different providers.
                      // Accepts multi-select (Cmd-click in OS picker),
                      // sequential adds, and drag-drop of multiple files.
                      // Step 1 won't advance until both slots are filled.
                      const proofFilled = ['proof1', 'proof2'].filter((k) => files[k]).length;
                      const handleProofMulti = (e) => {
                        const picked = Array.from(e.target.files || []);
                        if (picked.length === 0) return;
                        let i = 0;
                        if (!files.proof1 && i < picked.length) { onPickFile('proof1', picked[i++]); }
                        if (!files.proof2 && i < picked.length) { onPickFile('proof2', picked[i++]); }
                        e.target.value = '';
                      };
                      const proofAccept = '.pdf,application/pdf,image/*';
                      return (
                        <div className="field full oa-file-field oa-proof-group">
                          <label>
                            Proof of address
                            <span className="oa-required-mark" aria-hidden="true">*</span>
                            <OaHelp
                              label="Proof of address"
                              text="Two recent documents from different providers (bank statement, utility bill or council tax), dated within the last three months. If your first document is a bank statement, the second should be from a different bank or service."
                            />
                          </label>
                          <div className="oa-proof-combined">
                            {/* Existing files (one or two tiles) */}
                            {['proof1', 'proof2'].map((key) => (
                              files[key] ? (
                                <div className="oa-proof-combined-tile" key={key}>
                                  {renderOaFileSlot(
                                    OA_FILE_SLOTS.find((s) => s.key === key),
                                    files[key],
                                    fileMeta[key] || {},
                                    onPickFile,
                                    removeFile,
                                    fmtSize,
                                    { compact: true, check: addressCheck[key], hideEmptyDropzone: true }
                                  )}
                                </div>
                              ) : null
                            ))}

                            {/* Upload slot — visible until both files are in.
                                Same dashed-tile + "Choose file or drop here"
                                design as the Photo ID slot, so the whole
                                Verification page reads as one consistent
                                pattern. <input multiple> lets the applicant
                                grab both files in one go from the OS picker. */}
                            {proofFilled < 2 && (
                              <label className="oa-file-drop">
                                <Icon name="upload" />
                                <span className="oa-file-drop-lbl">Choose file or drop here</span>
                                <input
                                  type="file"
                                  accept={proofAccept}
                                  multiple
                                  onChange={handleProofMulti}
                                />
                              </label>
                            )}

                            {/* Small status line beneath the tile. */}
                            <div className={`oa-proof-progress is-${proofFilled === 2 ? 'complete' : 'partial'}`} aria-live="polite">
                              {proofFilled === 0 && '0 of 2 uploaded'}
                              {proofFilled === 1 && '1 of 2 uploaded'}
                              {proofFilled === 2 && '2 of 2 uploaded ✓'}
                            </div>
                          </div>
                        </div>
                      );
                    })()}
                  </React.Fragment>
                )}

                {step === 3 && (() => {
                  const portfolioCheck = oaPortfolioCheck(data.about.portfolio);
                  // System-happy booleans for each field — drive the
                  // green-outline (oa-input-ok) treatment. Email is
                  // valid format, phone has 10+ digits after stripping
                  // non-digits, portfolio passes the existing look-good
                  // check.
                  const emailOk = /^\S+@\S+\.\S+$/.test(data.about.email);
                  const phoneOk = data.about.phone.replace(/\D/g, '').length >= 10;
                  // Self-vouching guard: the applicant's email can't
                  // match either reference email. The same check fires
                  // on step 2 if they're typed in that order; this
                  // covers the reverse order (refs first, then email).
                  const aEmailLower = (data.about.email || '').toLowerCase().trim();
                  const conflictingRefIdx = data.references.findIndex((r) =>
                    r.email && aEmailLower && r.email.toLowerCase().trim() === aEmailLower
                  );
                  const emailMatchesRef = conflictingRefIdx >= 0;
                  return (
                    <React.Fragment>
                      <h3>Your details</h3>
                      <p className="desc">Just the basics. We'll review your name and address (pre-filled from your documents) on the next step.</p>
                      {!files.selfie && (
                        <div className="oa-step3-selfie-notice" role="status" aria-live="polite">
                          <Icon name="camera" />
                          <span>When you continue, we'll take a quick selfie to verify it's you.</span>
                        </div>
                      )}
                      <div className="field-row">
                        <div className="field full">
                          <label htmlFor="oa-email">Email <span className="oa-required-mark" aria-hidden="true">*</span></label>
                          <input
                            id="oa-email"
                            type="email"
                            name="email"
                            autoComplete="email"
                            inputMode="email"
                            className={emailMatchesRef ? 'oa-input-warn' : (emailOk ? 'oa-input-ok' : '')}
                            value={data.about.email}
                            onChange={setAbout('email')}
                            placeholder="you@example.com"
                            required
                          />
                          {emailMatchesRef && (
                            <div className="oa-field-warn">
                              This is the same email as Reference {conflictingRefIdx + 1}. Your own email can't be used as a trade reference — please use a different address.
                            </div>
                          )}
                        </div>
                      </div>
                      <div className="field-row">
                        <div className="field full">
                          <label htmlFor="oa-phone">Phone <span className="oa-required-mark" aria-hidden="true">*</span></label>
                          <input
                            id="oa-phone"
                            type="tel"
                            name="tel"
                            autoComplete="tel"
                            inputMode="tel"
                            className={phoneOk ? 'oa-input-ok' : ''}
                            value={data.about.phone}
                            onChange={setAbout('phone')}
                            placeholder="+44 7…"
                            required
                          />
                        </div>
                      </div>
                      <div className="field-row">
                        <div className="field full">
                          <label htmlFor="oa-portfolio">
                            Portfolio link <span className="oa-required-mark" aria-hidden="true">*</span>
                          </label>
                          <input
                            id="oa-portfolio"
                            type="url"
                            name="url"
                            autoComplete="url"
                            inputMode="url"
                            className={
                              portfolioCheck.state === 'good' ? 'oa-input-ok'
                                : portfolioCheck.state === 'bad' ? 'oa-input-warn'
                                : ''
                            }
                            value={data.about.portfolio}
                            onChange={setAbout('portfolio')}
                            placeholder="vimeo.com/you or your-site.com"
                            required
                          />
                          {portfolioCheck.state === 'bad' && (
                            <div className="oa-field-warn">{portfolioCheck.msg}</div>
                          )}
                        </div>
                      </div>
                    </React.Fragment>
                  );
                })()}

                {step === 2 && (() => {
                  // The applicant only enters the two reference EMAILS here.
                  // The referee provides their own name / company / relationship
                  // / phone when responding to the request email. Reduces typing
                  // for the applicant and gives us referee-self-reported identity
                  // (which is a stronger signal than the applicant claiming it
                  // on their behalf).
                  const aEmail = (data.about.email || '').toLowerCase().trim();
                  const domainOf = (e) => (e && e.includes('@')) ? e.slice(e.lastIndexOf('@') + 1).toLowerCase().trim() : '';
                  const refDomains = data.references.map((r) => domainOf(r.email));
                  const sameRefDomain = refDomains[0] && refDomains[1] && refDomains[0] === refDomains[1];
                  return (
                  <React.Fragment>
                    <h3>
                      Trade references
                      <OaHelp
                        label="Trade references"
                        text="Two business email addresses for people who can vouch for you. They'll fill in their own name, company, and relationship to you when they respond. Business addresses only — Gmail, Hotmail, Outlook, Yahoo, iCloud and similar free providers aren't accepted. References must be at two different organisations and can't be you."
                      />
                    </h3>
                    <p className="desc">Just the two email addresses. Your references will fill in the rest when they respond.</p>
                    <div className="oa-ref-aside">
                      <span className="oa-ref-aside-label">We'll ask them:</span>
                      <ul>
                        <li>Whether they'd recommend you as a reliable, trustworthy renter</li>
                        <li>About any past rentals with you, and how those went</li>
                        <li>Whether they have any concerns we should know about</li>
                      </ul>
                    </div>
                    {data.references.map((r, i) => {
                      const isFreeMail = r.email && /^\S+@\S+\.\S+$/.test(r.email) && oaIsFreeEmail(r.email);
                      const isSelfEmail = aEmail && r.email && r.email.toLowerCase().trim() === aEmail;
                      const isSharedDomain = sameRefDomain && r.email;
                      // Pre-flight check result (from /api/applications/start
                      // — fires when the applicant clicks Continue). If
                      // this ref came back 'fail', surface the reason
                      // alongside the existing inline warnings.
                      const checkRes = refsCheck[i];
                      const checkFailed = checkRes && checkRes.state === 'fail' && checkRes.email && checkRes.email.toLowerCase().trim() === (r.email || '').toLowerCase().trim();
                      const checkOk = checkRes && checkRes.state === 'ok' && checkRes.email && checkRes.email.toLowerCase().trim() === (r.email || '').toLowerCase().trim();
                      const checkChecking = checkRes && checkRes.state === 'checking';
                      const checkReasonMsg = (() => {
                        if (!checkFailed) return null;
                        switch (checkRes.reason) {
                          case 'no_mx': return "We couldn't reach this email's domain — double-check the spelling.";
                          case 'invalid_format': return "That doesn't look like a valid email address.";
                          case 'send_failed': return "We couldn't reach this reference's inbox right now — but your application will still go through. We'll follow up with them directly.";
                          // free_email / same_as_applicant / same_domain_as_other
                          // are already surfaced by the inline warnings above
                          // (isFreeMail / isSelfEmail / isSharedDomain) — don't
                          // duplicate the message.
                          default: return null;
                        }
                      })();
                      return (
                        <div className="oa-ref" key={i}>
                          <div className="oa-ref-title">
                            Reference {i + 1}
                            {checkOk && <span className="oa-ref-check-badge oa-ref-check-badge--ok" aria-label="Email sent" title="Reference email sent"><Icon name="check" /></span>}
                            {checkChecking && <span className="oa-ref-check-badge oa-ref-check-badge--checking" aria-label="Sending…" title="Sending the reference email…"><span className="oa-spinner-inline" /></span>}
                          </div>
                          <div className="field-row">
                            <div className="field full">
                              <label>Business email</label>
                              <input
                                type="email"
                                autoComplete="off"
                                inputMode="email"
                                className={(isFreeMail || isSelfEmail || isSharedDomain || checkFailed) ? 'oa-input-warn' : (checkOk ? 'oa-input-ok' : '')}
                                value={r.email}
                                onChange={setRef(i, 'email')}
                                placeholder="name@company.com"
                              />
                              {isSelfEmail && (
                                <div className="oa-field-warn">
                                  This is your own email address. References must be from someone other than you.
                                </div>
                              )}
                              {!isSelfEmail && isFreeMail && (
                                <div className="oa-field-warn">
                                  Free email providers (Gmail, Hotmail, Outlook, Yahoo, iCloud…) aren't accepted. Please use a business address on the referee's company domain.
                                </div>
                              )}
                              {!isSelfEmail && !isFreeMail && isSharedDomain && (
                                <div className="oa-field-warn">
                                  Both references are at <strong>{refDomains[0]}</strong>. Please use a contact at a different organisation for one of them.
                                </div>
                              )}
                              {checkReasonMsg && (
                                <div className="oa-field-warn">{checkReasonMsg}</div>
                              )}
                            </div>
                          </div>
                        </div>
                      );
                    })}
                  </React.Fragment>
                  );
                })()}

                {step === 4 && (() => {
                  // Review step — heavily redesigned. Top of the page
                  // is a Profile hero: circular selfie + name. Below it,
                  // sections (Your details / References / Verification)
                  // each with an Edit button that toggles inline edit
                  // for just that section.
                  //
                  // OCR-failed-but-required fields (name, address) show
                  // in red with a "Please add" prompt — the applicant
                  // can't submit until they fill them in via the
                  // section's Edit toggle.
                  const fullName = `${data.about.firstName} ${data.about.surname}`.trim();
                  const initials = (data.about.firstName[0] || '').toUpperCase() + (data.about.surname[0] || '').toUpperCase();
                  const selfieUrl = files.selfie && fileMeta.selfie?.thumb ? fileMeta.selfie.thumb : null;
                  const missingFirst = !data.about.firstName.trim();
                  const missingSur = !data.about.surname.trim();
                  const missingAddr = !data.about.address.trim();
                  const portfolioCheck = oaPortfolioCheck(data.about.portfolio);
                  return (
                  <React.Fragment>
                    <h3>Review and submit</h3>
                    <p className="desc">Have a quick look. Tap Edit on any section to fix anything we got wrong, then submit.</p>

                    {/* Profile hero — circular selfie + name. Replaces
                        the Name row in the Your Details section. The
                        small retake button (camera icon) sits on the
                        bottom-right of the avatar; clicking it reopens
                        the camera modal (or file picker on no-camera
                        devices) so the applicant can replace the selfie
                        without leaving the Review step. */}
                    <div className="oa-review-profile">
                      <div className="oa-review-avatar">
                        {selfieUrl
                          ? <img src={selfieUrl} alt="" />
                          : (initials || <Icon name="check" />)}
                        <button
                          type="button"
                          className="oa-review-avatar-retake"
                          onClick={retakeSelfie}
                          aria-label={selfieUrl ? 'Retake selfie' : 'Take a selfie'}
                          title={selfieUrl ? 'Retake selfie' : 'Take a selfie'}
                        >
                          <Icon name="camera" />
                        </button>
                      </div>
                      <div className="oa-review-identity">
                        {reviewEdit.profile ? (
                          <div className="oa-review-identity-edit">
                            <input
                              type="text"
                              autoComplete="given-name"
                              placeholder="First name(s)"
                              className={data.about.firstName.trim() ? 'oa-input-ok' : ''}
                              value={data.about.firstName}
                              onChange={setAbout('firstName')}
                            />
                            <input
                              type="text"
                              autoComplete="family-name"
                              placeholder="Surname"
                              className={data.about.surname.trim() ? 'oa-input-ok' : ''}
                              value={data.about.surname}
                              onChange={setAbout('surname')}
                            />
                            <button
                              type="button"
                              className="oa-review-done"
                              onClick={() => setReviewEdit((r) => ({ ...r, profile: false }))}
                            >Done</button>
                          </div>
                        ) : (
                          <React.Fragment>
                            <div className={`oa-review-firstname ${missingFirst ? 'is-missing' : ''}`}>
                              {data.about.firstName || 'Add your first name'}
                            </div>
                            <div className={`oa-review-surname ${missingSur ? 'is-missing' : ''}`}>
                              {data.about.surname || 'Add your surname'}
                            </div>
                            <button
                              type="button"
                              className="oa-review-edit oa-review-edit--profile"
                              onClick={() => setReviewEdit((r) => ({ ...r, profile: true }))}
                            >Edit</button>
                          </React.Fragment>
                        )}
                      </div>
                    </div>

                    <div className="oa-review">
                      {/* YOUR DETAILS — minus Name (in the hero) */}
                      <div className="oa-review-block">
                        <div className="oa-review-head">
                          <span>Your details</span>
                          <button
                            type="button"
                            className="oa-review-edit"
                            onClick={() => setReviewEdit((r) => ({ ...r, details: !r.details }))}
                          >{reviewEdit.details ? 'Done' : 'Edit'}</button>
                        </div>
                        {reviewEdit.details ? (() => {
                          // Same self-vouching guard as step 3 — guard
                          // against the applicant editing their email
                          // on Review to match a reference.
                          const aEmailLowerRev = (data.about.email || '').toLowerCase().trim();
                          const reviewConflictIdx = data.references.findIndex((r) =>
                            r.email && aEmailLowerRev && r.email.toLowerCase().trim() === aEmailLowerRev
                          );
                          const reviewEmailMatchesRef = reviewConflictIdx >= 0;
                          return (
                          <div className="oa-review-fields">
                            <div className="field-row">
                              <div className="field full">
                                <label>Email</label>
                                <input
                                  type="email"
                                  autoComplete="email"
                                  inputMode="email"
                                  className={reviewEmailMatchesRef ? 'oa-input-warn' : (/^\S+@\S+\.\S+$/.test(data.about.email) ? 'oa-input-ok' : '')}
                                  value={data.about.email}
                                  onChange={setAbout('email')}
                                />
                                {reviewEmailMatchesRef && (
                                  <div className="oa-field-warn">
                                    This is the same email as Reference {reviewConflictIdx + 1}. Your own email can't be used as a trade reference — please use a different address.
                                  </div>
                                )}
                              </div>
                            </div>
                            <div className="field-row">
                              <div className="field">
                                <label>Phone</label>
                                <input
                                  type="tel"
                                  autoComplete="tel"
                                  inputMode="tel"
                                  className={data.about.phone.replace(/\D/g, '').length >= 10 ? 'oa-input-ok' : ''}
                                  value={data.about.phone}
                                  onChange={setAbout('phone')}
                                />
                              </div>
                              <div className="field">
                                <label>Company <span className="oa-opt">(optional)</span></label>
                                <input autoComplete="organization" value={data.about.company} onChange={setAbout('company')} />
                              </div>
                            </div>
                            <div className="field-row">
                              <div className="field full">
                                <label>Address</label>
                                <textarea
                                  autoComplete="street-address"
                                  rows="3"
                                  className={postcodeStatus && postcodeStatus.state === 'valid' ? 'oa-input-ok' : (postcodeStatus && postcodeStatus.state === 'invalid' ? 'oa-input-warn' : '')}
                                  value={data.about.address}
                                  onChange={setAbout('address')}
                                  placeholder="Street, city, postcode"
                                />
                                {postcodeStatus && postcodeStatus.state === 'invalid' && (
                                  <div className="oa-field-warn">
                                    We couldn't find <strong>{postcodeStatus.postcode}</strong> in UK postcode data.
                                  </div>
                                )}
                              </div>
                            </div>
                            <div className="field-row">
                              <div className="field full">
                                <label className="oa-inline-check">
                                  <input
                                    type="checkbox"
                                    checked={data.about.billingDifferent}
                                    onChange={(e) => setData((d) => ({
                                      ...d,
                                      about: {
                                        ...d.about,
                                        billingDifferent: e.target.checked,
                                        billingAddress: e.target.checked ? d.about.billingAddress : '',
                                      },
                                    }))}
                                  />
                                  <span>My billing address is different from above</span>
                                </label>
                              </div>
                            </div>
                            {data.about.billingDifferent && (
                              <div className="field-row oa-billing-reveal">
                                <div className="field full">
                                  <label>Billing address</label>
                                  <textarea autoComplete="billing street-address" rows="2" value={data.about.billingAddress} onChange={setAbout('billingAddress')} placeholder="Different billing address for invoices" />
                                </div>
                              </div>
                            )}
                            <div className="field-row">
                              <div className="field full">
                                <label>Portfolio link</label>
                                <input
                                  type="url"
                                  autoComplete="url"
                                  inputMode="url"
                                  className={portfolioCheck.state === 'bad' ? 'oa-input-warn' : portfolioCheck.state === 'good' ? 'oa-input-ok' : ''}
                                  value={data.about.portfolio}
                                  onChange={setAbout('portfolio')}
                                />
                                {portfolioCheck.state === 'bad' && (
                                  <div className="oa-field-warn">{portfolioCheck.msg}</div>
                                )}
                              </div>
                            </div>
                          </div>
                          );
                        })() : (
                          <React.Fragment>
                            <dl>
                              <div><dt>Email</dt><dd className={!data.about.email ? 'is-missing' : ''}>{data.about.email || 'Please add your email'}</dd></div>
                              <div><dt>Phone</dt><dd className={!data.about.phone ? 'is-missing' : ''}>{data.about.phone || 'Please add your phone'}</dd></div>
                              {data.about.company && <div><dt>Company</dt><dd>{data.about.company}</dd></div>}
                              <div><dt>Portfolio</dt><dd className={!data.about.portfolio ? 'is-missing' : ''}>{data.about.portfolio || 'Please add a portfolio link'}</dd></div>
                              <div><dt>Address</dt><dd className={missingAddr ? 'is-missing' : ''}>{data.about.address || 'Please add your address'}</dd></div>
                              {data.about.billingDifferent && (
                                <div><dt>Billing</dt><dd className={!data.about.billingAddress ? 'is-missing' : ''}>{data.about.billingAddress || 'Please add your billing address'}</dd></div>
                              )}
                            </dl>
                            {/* Billing-address toggle sits as a small
                                button just below the address row, in
                                place of the old in-edit-panel checkbox.
                                Toggling it on reveals the field in the
                                next edit pass; off clears any typed
                                billing value. */}
                            {!data.about.billingDifferent ? (
                              <button
                                type="button"
                                className="oa-review-billing-toggle"
                                onClick={() => setData((d) => ({
                                  ...d,
                                  about: { ...d.about, billingDifferent: true },
                                }))}
                              >+ My billing address is different from above</button>
                            ) : (
                              <button
                                type="button"
                                className="oa-review-billing-toggle is-on"
                                onClick={() => setData((d) => ({
                                  ...d,
                                  about: { ...d.about, billingDifferent: false, billingAddress: '' },
                                }))}
                              >− Use the same address for billing</button>
                            )}
                          </React.Fragment>
                        )}
                      </div>

                      {/* REFERENCES — emails (and optional first name) */}
                      <div className="oa-review-block">
                        <div className="oa-review-head">
                          <span>References</span>
                          <button
                            type="button"
                            className="oa-review-edit"
                            onClick={() => setReviewEdit((r) => ({ ...r, references: !r.references }))}
                          >{reviewEdit.references ? 'Done' : 'Edit'}</button>
                        </div>
                        {reviewEdit.references ? (
                          <div className="oa-review-fields">
                            {data.references.map((r, i) => (
                              <div className="field-row" key={i}>
                                <div className="field"><label>Ref {i + 1} email</label><input type="email" inputMode="email" value={r.email} onChange={setRef(i, 'email')} /></div>
                                <div className="field"><label>First name <span className="oa-opt">(optional)</span></label><input value={r.name} onChange={setRef(i, 'name')} /></div>
                              </div>
                            ))}
                          </div>
                        ) : (
                          <dl>
                            {data.references.map((r, i) => (
                              <div key={i}><dt>Ref {i + 1}</dt><dd className={!r.email ? 'is-missing' : ''}>{r.email ? (r.name ? `${r.name} · ${r.email}` : r.email) : 'Please add this reference email'}</dd></div>
                            ))}
                          </dl>
                        )}
                      </div>

                      {/* VERIFICATION DOCUMENTS — Edit sends back to step 1 */}
                      <div className="oa-review-block">
                        <div className="oa-review-head">
                          <span>Verification documents</span>
                          <button type="button" className="oa-review-edit" onClick={() => setStep(1)}>Edit</button>
                        </div>
                        <dl>
                          {OA_FILE_SLOTS.map((s) => (
                            <div key={s.key}><dt>{s.label}</dt><dd className={!files[s.key] ? 'is-missing' : ''}>{files[s.key] ? files[s.key].name : 'missing'}</dd></div>
                          ))}
                        </dl>
                      </div>
                    </div>

                    <label className="oa-check">
                      <input type="checkbox" checked={data.consent.data} onChange={setConsent('data')} />
                      <span>
                        <strong>I agree to Valley Rentals' terms.</strong>
                        <span className="oa-check-sub">You consent to Valley Rentals storing this information for the purpose of verifying and managing your rental account, per the <a href="/privacy" onClick={(e) => { e.preventDefault(); onGoto('/privacy'); }}>privacy notice</a>.</span>
                      </span>
                    </label>
                    <label className="oa-check">
                      <input type="checkbox" checked={data.consent.newsletter} onChange={setConsent('newsletter')} />
                      <span>
                        <strong>Send me occasional updates.</strong>
                        <span className="oa-check-sub">New kit, special offers, the occasional behind-the-scenes. Unsubscribe any time.</span>
                      </span>
                    </label>

                    {submitError && <div className="form-error" role="alert">{submitError}</div>}
                  </React.Fragment>
                  );
                })()}

                {step > 0 && (() => {
                  // If the background proof-of-address check is still
                  // running on either slot, hold the Submit button so
                  // the applicant doesn't blow past the verification.
                  // (Doesn't apply to mid-flow Continues — only Submit.)
                  const proofChecksPending = ['proof1', 'proof2'].some(
                    (k) => addressCheck[k] && addressCheck[k].state === 'pending'
                  );
                  // Reference send is now non-blocking — the request
                  // fires when the applicant leaves step 2 and resolves
                  // in the background. On the review step we have to
                  // hold Submit until it's settled (either green = all
                  // sent, or red = needs the applicant to go back and
                  // fix the addresses).
                  const refsAllOk = refsCheck.every((r) => r.state === 'ok');
                  const refsAnyFailed = refsCheck.some((r) => r.state === 'fail');
                  const refsFailures = refsCheck.filter((r) => r.state === 'fail');
                  // Soft failures (Resend rejected the send) don't
                  // block — we'll chase manually. Only blocking
                  // failures (bad email format, no MX) hold submit.
                  const refsBlocking = refsFailures.some((r) => r.reason !== 'send_failed');
                  const refsAllSoftFail = refsAnyFailed && refsFailures.every((r) => r.reason === 'send_failed');
                  const refsHoldingSubmit = step === OA_REAL_STEPS && (refsChecking || refsBlocking);
                  return (
                    <React.Fragment>
                      {step === OA_REAL_STEPS && proofChecksPending && (
                        <div className="oa-verify-status">
                          <span className="oa-spinner-inline" aria-hidden="true" />
                          Please wait — we're still verifying your documents. This usually takes a few seconds.
                        </div>
                      )}
                      {step === OA_REAL_STEPS && !proofChecksPending && refsChecking && (
                        <div className="oa-verify-status">
                          <span className="oa-spinner-inline" aria-hidden="true" />
                          Just finishing the reference emails — you'll be able to submit in a moment.
                        </div>
                      )}
                      {step === OA_REAL_STEPS && !refsChecking && refsAllSoftFail && (
                        <div className="oa-verify-status">
                          We couldn't reach {refsFailures.length === 1 ? "one reference's inbox" : "your references' inboxes"} right now — your application can still go through and we'll follow up directly with {refsFailures.length === 1 ? 'them' : 'both'}.
                        </div>
                      )}
                      {step === OA_REAL_STEPS && !refsChecking && refsAnyFailed && !refsAllSoftFail && (
                        <div className="oa-verify-status oa-verify-status--bad">
                          {refsFailures.length === 1 ? 'One reference email' : 'Your reference emails'} need attention.{' '}
                          <button
                            type="button"
                            className="oa-verify-link"
                            onClick={(e) => { e.preventDefault(); setStep(2); }}
                          >
                            Go back to References
                          </button>{' '}to fix and retry.
                        </div>
                      )}
                      <div className="oa-actions">
                        <button type="button" className="oa-back" onClick={(e) => { e.preventDefault(); prev(); }}>Back</button>
                        {step < OA_REAL_STEPS ? (
                          <button
                            type="button"
                            className="submit"
                            disabled={!stepValid(step)}
                            onClick={(e) => { e.preventDefault(); next(); }}
                          >
                            {(step === 3 && !files.selfie)
                              ? <>Take selfie <Icon name="camera" /></>
                              : <>Continue <Icon name="arrow-right" /></>
                            }
                          </button>
                        ) : (
                          <React.Fragment>
                            {emailVerified === false && (
                              <div className="oa-verify-banner" role="status" aria-live="polite">
                                <strong>Check your inbox.</strong>
                                <span>We sent a verification link to <code>{data.about.email}</code>. Click it to enable submit — the page will unlock automatically.</span>
                              </div>)}
                            <button
                              type="button"
                              className="submit"
                              disabled={submitting || !stepValid(step) || proofChecksPending || refsHoldingSubmit || emailVerified === false}
                              onClick={(e) => { e.preventDefault(); submit(); }}
                            >
                              {submitting
                                ? 'Submitting…'
                                : proofChecksPending
                                  ? <><span className="oa-spinner-inline oa-spinner-inline--on-button" aria-hidden="true" /> Verifying documents…</>
                                  : refsChecking
                                    ? <><span className="oa-spinner-inline oa-spinner-inline--on-button" aria-hidden="true" /> Sending references…</>
                                    : refsAnyFailed
                                      ? <>Fix references to submit</>
                                      : emailVerified === false
                                        ? <>Waiting for email verification…</>
                                        : <>Submit application <Icon name="arrow-right" /></>
                              }
                            </button>
                          </React.Fragment>
                        )}
                      </div>
                    </React.Fragment>
                  );
                })()}
                {/* Security-theatre footer — only rendered on the
                    Review step (step 4) so it sits next to the
                    Submit button where any fraudster about to send
                    sees it last. Hidden on earlier steps to keep
                    them visually clean. The success screen has its
                    own copy of the badge. */}
                {step === OA_REAL_STEPS && <OaSecurityBadge />}
              </form>
            )}
          </div>
        </div>
      </section>

      {/* Camera modal — only mounted when open so getUserMedia isn't kept
          alive in the background. The video element is wired up via
          useEffect (videoRef.srcObject = streamRef.current). */}
      {cameraOpen && (() => {
        // For the selfie slot, the live face check drives the border
        // colour, hint text, and shutter-enabled state. For the photo-ID
        // slot, no face check — the shutter is always enabled and the
        // hint is the existing static text.
        const isSelfie = cameraOpen === 'selfie';
        const faceState = isSelfie ? faceCheck.state : null;
        const shutterDisabled = isSelfie && !(faceState === 'ready' || faceState === 'unavailable');
        const hint = isSelfie
          ? faceCheck.message
          : 'Fill the frame with your ID. Hold steady, avoid glare, make sure the text is legible.';
        const videoClass = [
          'oa-camera-video',
          cameraOpen === 'id' ? 'oa-camera-video--unflipped' : '',
          isSelfie ? `oa-camera-video--face-${faceState}` : '',
        ].filter(Boolean).join(' ');
        return (
        <div className="oa-camera-modal" role="dialog" aria-modal="true" aria-label={cameraOpen === 'id' ? 'Photograph your ID' : 'Take a selfie'}>
          <div className="oa-camera-modal-inner">
            <video
              ref={videoRef}
              autoPlay
              playsInline
              muted
              className={videoClass}
            />
            <p className={`oa-camera-hint oa-camera-hint--${faceState || 'static'}`} aria-live="polite">{hint}</p>
            <div className="oa-camera-actions">
              <button type="button" className="oa-camera-cancel" onClick={closeCamera}>Cancel</button>
              <button
                type="button"
                className="oa-camera-shutter"
                onClick={capturePhoto}
                disabled={shutterDisabled}
                aria-label="Capture"
                aria-disabled={shutterDisabled}
              >
                <span className="oa-camera-shutter-ring" />
                <span className="oa-camera-shutter-dot" />
              </button>
              <span className="oa-camera-actions-spacer" aria-hidden="true" />
            </div>
            {/* 7-second selfie-camera fallback. Only renders for the
                selfie slot, once the timer has fired and the shutter is
                still disabled (i.e. face check hasn't unlocked it). */}
            {isSelfie && cameraFallbackOffered && shutterDisabled && (
              <label className="oa-camera-fallback">
                Having trouble? <span className="oa-camera-fallback-link">Upload a photo instead →</span>
                <input
                  type="file"
                  accept="image/*"
                  onChange={(e) => {
                    const file = e.target.files && e.target.files[0];
                    if (!file) return;
                    closeCamera();
                    onPickFile('selfie', file);
                  }}
                />
              </label>
            )}
          </div>
        </div>
        );
      })()}

      <MegaFooter onGoto={onGoto} isRentals />
    </main>);

}

// ===================================================================
// Top-level switch — picks a sub-page based on rentalsSection prop.
// ===================================================================
// ───────────────────────────────────────────────────────────────────
// Floating search FAB — replaces the Booqable cart launcher in the
// bottom-right. Hovering reveals 3 mini results above the button:
// recent searches if the user has any, otherwise a curated set of
// popular starting points. Clicking the button goes straight to the
// equipment page; clicking a mini result jumps to that item's drawer.
//
// Shares the recent-search store (`vf-recent-eq-searches`) with the
// nav-bar EquipmentNavSearch in chrome, so the two surfaces stay in
// sync without an extra context.
// ───────────────────────────────────────────────────────────────────
const SUGGESTED_QUERIES = ['Alexa', 'Aputure', 'Anamorphic', 'Lavalier', 'Wireless video'];

// ───────────────────────────────────────────────────────────────────
// Cart store. Each line is { id, name, sku, dayRate, image, qty }.
// Persisted to localStorage so a refresh / accidental tab-close
// doesn't blow the request away. Cross-tab sync via 'storage' event.
// All cart mutations go through this hook so the count badge, drawer,
// and submit form share a single source of truth.
// ───────────────────────────────────────────────────────────────────
const CART_STORAGE_KEY = 'vf-rentals-cart';

// ---------------------------------------------------------------------
// Share cart by URL — encode a cart into a query param.
// ---------------------------------------------------------------------
// Format: `?cart=slug:qty,slug:qty,slug:qty`
// Custom items (synth lines, no Booqable slug) are skipped — the share
// link only carries the public catalog items. Quantities are clamped to
// 1–999 so a malformed URL can't blow up the cart. On EquipmentPage
// mount we read the URL, REPLACE localStorage cart with the decoded
// set, show a one-shot banner, then strip the `cart` param so a manual
// refresh doesn't keep re-hydrating.
function encodeCartForShareUrl(items) {
  if (!Array.isArray(items)) return '';
  const parts = [];
  for (const it of items) {
    if (!it || !it.id || it.isCustom) continue;
    const qty = Math.min(999, Math.max(1, parseInt(it.qty, 10) || 1));
    // ids are already URL-safe slugs (a-z, 0-9, -) so no escape needed.
    parts.push(`${it.id}:${qty}`);
  }
  return parts.join(',');
}

function decodeCartFromShareUrl(str) {
  if (!str || typeof str !== 'string') return [];
  const out = [];
  for (const chunk of str.split(',')) {
    const [id, qtyStr] = chunk.split(':');
    if (!id || !/^[a-z0-9-]+$/.test(id)) continue;
    const qty = Math.min(999, Math.max(1, parseInt(qtyStr || '1', 10) || 1));
    out.push({ id, qty });
  }
  return out;
}

function readCart() {
  if (typeof window === 'undefined') return [];
  try {
    const raw = localStorage.getItem(CART_STORAGE_KEY);
    if (!raw) return [];
    const arr = JSON.parse(raw);
    if (!Array.isArray(arr)) return [];
    const clean = arr.filter((i) => i && typeof i.id === 'string' && typeof i.qty === 'number' && i.qty > 0);

    // Self-heal stale lines. Cart entries added before the `ownership`
    // field was carried through useCart.add() lack the field entirely;
    // discountPercentFor then defaults them to 'owned' and a
    // consignment item incorrectly shows the full WINTERLIN50 percent
    // instead of half. Look each line up against the canonical
    // RENTALS_CATALOG and back-fill ownership + ownerInitials when
    // missing. No-op for items already carrying the fields, and for
    // custom items (which carry their own ownership='custom').
    const catalog = Array.isArray(window.RENTALS_CATALOG) ? window.RENTALS_CATALOG : null;
    if (!catalog) return clean;
    return clean.map((i) => {
      if (i.isCustom) return i;
      if (i.ownership) return i;
      const cat = catalog.find((c) => c && c.id === i.id);
      if (!cat) return i;
      return {
        ...i,
        ownership: cat.ownership || 'owned',
        ...(cat.ownerInitials ? { ownerInitials: cat.ownerInitials } : {}),
      };
    });
  } catch (e) { return []; }
}

function writeCart(items) {
  try { localStorage.setItem(CART_STORAGE_KEY, JSON.stringify(items)); }
  catch (e) { /* localStorage disabled */ }
}

// Custom-order persistence. Set when staff manually drags cart items
// into a specific order (staff mode only). Once set, the cart renders
// in this order regardless of category groupings; until then, the
// auto-sort logic (category > price desc) drives the display.
// Format: ['itemId1', 'itemId2', …] — exact ids in the user's order.
// Null/missing key = auto-sort mode.
const CART_ORDER_KEY = 'vf-rentals-cart-order';
function readCartOrder() {
  if (typeof window === 'undefined') return null;
  try {
    const raw = localStorage.getItem(CART_ORDER_KEY);
    if (!raw) return null;
    const arr = JSON.parse(raw);
    return Array.isArray(arr) && arr.every((s) => typeof s === 'string') ? arr : null;
  } catch (e) { return null; }
}
function writeCartOrder(arr) {
  try {
    if (arr && Array.isArray(arr) && arr.length) {
      localStorage.setItem(CART_ORDER_KEY, JSON.stringify(arr));
    } else {
      localStorage.removeItem(CART_ORDER_KEY);
    }
  } catch (e) { /* localStorage disabled */ }
}

// Lightweight pub-sub for in-page cart updates. Multiple components
// listen (count badge + drawer + add-to-request button) so we need a
// way to broadcast without prop drilling. Cross-tab gets handled by
// the browser's 'storage' event automatically.
const CART_EVENT = 'vf-cart-change';
function broadcastCartChange() {
  try { window.dispatchEvent(new CustomEvent(CART_EVENT)); } catch (e) { /* */ }
}

// Look up an item's main category. Cart lines don't store category
// (kept out of the localStorage shape to keep it small), so we resolve
// against the canonical RENTALS_CATALOG by id at display time. Custom
// items get a sentinel "Custom" category so they group together at the
// bottom; anything we can't find a catalog entry for falls back to
// "Other" so they don't silently disappear from a group.
function getCartItemCategory(item) {
  if (!item) return 'Other';
  if (item.isCustom) return 'Custom';
  const catalog = (typeof window !== 'undefined' && Array.isArray(window.RENTALS_CATALOG))
    ? window.RENTALS_CATALOG
    : null;
  if (!catalog) return 'Other';
  const cat = catalog.find((c) => c && c.id === item.id);
  return (cat && cat.category) || 'Other';
}

// Group cart items by their main category. Categories ordered by the
// max dayRate within each (most-expensive section first — feels right
// for a rental quote where you want the hero kit at the top), items
// within each category ordered by dayRate desc. Custom items always
// group at the bottom regardless of dayRate so hand-quoted lines stay
// visually distinct from the catalog kit.
//
// Returns: [{ category: 'Camera', items: [...] }, …]
function groupItemsByCategory(items) {
  if (!Array.isArray(items) || items.length === 0) return [];
  const byCat = new Map();
  for (const it of items) {
    const cat = getCartItemCategory(it);
    if (!byCat.has(cat)) byCat.set(cat, []);
    byCat.get(cat).push(it);
  }
  const groups = [];
  for (const [category, group] of byCat) {
    const sorted = [...group].sort((a, b) => (b.dayRate || 0) - (a.dayRate || 0));
    groups.push({ category, items: sorted, _max: Math.max(...sorted.map((x) => x.dayRate || 0)) });
  }
  groups.sort((a, b) => {
    // Custom items always last.
    if (a.category === 'Custom' && b.category !== 'Custom') return 1;
    if (b.category === 'Custom' && a.category !== 'Custom') return -1;
    return b._max - a._max;
  });
  return groups.map(({ category, items }) => ({ category, items }));
}

// Resolve the display order of cart items. Two modes:
//   - customOrder set → render in exactly that order (flat). Items not
//     in the customOrder array (e.g. just-added) go to the end so
//     nothing silently disappears.
//   - customOrder null → auto-sort by groupItemsByCategory. Caller
//     decides whether to actually render category headers based on
//     group count.
// Always returns { groups, flat } so callers can pick whichever shape
// suits their renderer.
function resolveCartDisplay(items, customOrder) {
  if (Array.isArray(customOrder) && customOrder.length) {
    const byId = new Map(items.map((it) => [it.id, it]));
    const ordered = [];
    for (const id of customOrder) {
      const it = byId.get(id);
      if (it) { ordered.push(it); byId.delete(id); }
    }
    for (const it of byId.values()) ordered.push(it);  // recently added, not yet in customOrder
    return { groups: [{ category: null, items: ordered }], flat: ordered, custom: true };
  }
  const groups = groupItemsByCategory(items);
  const flat = groups.flatMap((g) => g.items);
  return { groups, flat, custom: false };
}

function useCart() {
  const [items, setItems] = useStateRentals(() => readCart());
  const [customOrder, setCustomOrderState] = useStateRentals(() => readCartOrder());
  useEffectRentals(() => {
    const sync = () => {
      setItems(readCart());
      setCustomOrderState(readCartOrder());
    };
    window.addEventListener('storage', sync);
    window.addEventListener(CART_EVENT, sync);
    return () => {
      window.removeEventListener('storage', sync);
      window.removeEventListener(CART_EVENT, sync);
    };
  }, []);

  const persist = (next) => {
    setItems(next);
    writeCart(next);
    broadcastCartChange();
  };
  const persistOrder = (next) => {
    setCustomOrderState(next);
    writeCartOrder(next);
    broadcastCartChange();
  };

  const add = (item, qty) => {
    const existing = items.find((i) => i.id === item.id);
    const addQty = Number.isFinite(qty) ? qty : (Number.isFinite(item.qty) ? item.qty : 1);
    if (existing) {
      persist(items.map((i) => i.id === item.id ? { ...i, qty: i.qty + addQty } : i));
      // Defensive: keep customOrder in lockstep. Normally the existing
      // id is already in the array (since it must have been added at
      // some point in the past), but a stale order from a previous
      // session could omit it — re-appending in that case keeps the
      // display order consistent rather than dropping the item to the
      // resolveCartDisplay "tail" silently.
      if (Array.isArray(customOrder) && customOrder.length && !customOrder.includes(item.id)) {
        persistOrder([...customOrder, item.id]);
      }
    } else {
      // Whitelist the fields we carry into the cart. ownership +
      // ownerInitials drive the discount router and the C-XX / X-XX
      // admin code; isCustom + notes feed the custom-item rendering
      // path; discountOverride starts null (auto-routed). Older calls
      // without these fields still work — readCart() back-fills
      // ownership from RENTALS_CATALOG on next load.
      persist([
        ...items,
        {
          id: item.id,
          name: item.name,
          sku: item.sku,
          dayRate: item.dayRate,
          image: item.image,
          ownership: item.ownership,
          ownerInitials: item.ownerInitials,
          isCustom: !!item.isCustom,
          notes: item.notes || '',
          internalNote: '',
          discountOverride: null,
          qty: addQty,
        },
      ]);
      // If we're currently in custom-order mode, append the new id to
      // the order array so the new line lands at the bottom rather
      // than sneaking into an undefined position.
      if (Array.isArray(customOrder) && customOrder.length) {
        persistOrder([...customOrder, item.id]);
      }
    }
  };
  const setQty = (id, qty) => {
    if (qty <= 0) return remove(id);
    persist(items.map((i) => i.id === id ? { ...i, qty } : i));
  };
  const remove = (id) => {
    persist(items.filter((i) => i.id !== id));
    // Keep customOrder in lockstep — leaving stale ids in the array is
    // harmless (resolveCartDisplay ignores unknown ids) but slowly
    // bloats localStorage.
    if (Array.isArray(customOrder) && customOrder.includes(id)) {
      persistOrder(customOrder.filter((x) => x !== id));
    }
  };
  const clear = () => {
    persist([]);
    persistOrder(null);
  };

  // Staff-mode mutators. setDiscountOverride writes null to remove
  // the override (returns the line to auto-routed discount). The cart
  // line's other fields are untouched.
  const setDiscountOverride = (id, percent) => {
    const value = (percent === null || percent === undefined || Number.isNaN(percent))
      ? null
      : Math.max(0, Math.min(100, Number(percent)));
    persist(items.map((i) => i.id === id ? { ...i, discountOverride: value } : i));
  };
  // Replace the entire custom order. Pass null/[] to revert to
  // auto-sort. Caller is responsible for making sure all ids exist in
  // the current cart (resolveCartDisplay tolerates extras anyway).
  const setCustomOrder = (orderArray) => {
    persistOrder(Array.isArray(orderArray) && orderArray.length ? orderArray : null);
  };

  // Staff-only per-line internal note. Never customer-facing — only
  // surfaces in the staff-mode cart UI + the Comet appendix on both
  // the PDF and the admin email. Useful for "back-up unit", "Pete OK'd
  // cross-hire", "this one goes with the Cooke kit" etc. Pass empty
  // string to clear.
  const setInternalNote = (id, text) => {
    const v = String(text || '');
    persist(items.map((i) => i.id === id ? { ...i, internalNote: v } : i));
  };

  // Bulk apply a discount percent to a subset of cart lines, identified
  // by ownership target. `target` is 'all' | 'owned' | 'consignment' |
  // 'cross-hire'. Passing `null` for `percent` clears overrides on the
  // matching subset (returns them to auto-routed). Skips custom-items
  // with ownership='custom' unless target is 'all' AND they have a
  // recognised ownership tag.
  const bulkSetDiscount = (target, percent) => {
    const value = (percent === null || percent === undefined || Number.isNaN(percent))
      ? null
      : Math.max(0, Math.min(100, Number(percent)));
    persist(items.map((i) => {
      const ownership = i.ownership || 'owned';
      const match = target === 'all'
        ? true
        : ownership === target;
      return match ? { ...i, discountOverride: value } : i;
    }));
  };

  const lineCount = items.reduce((s, i) => s + i.qty, 0);
  const subtotal = items.reduce((s, i) => s + (i.dayRate || 0) * i.qty, 0);

  // Replace the whole cart wholesale. Used by the shared-cart-URL
  // hydration path on EquipmentPage mount. Clears customOrder too so
  // the freshly-loaded cart sorts naturally rather than inheriting a
  // stale manual ordering from whatever was here before.
  const replace = (nextItems) => {
    persist(Array.isArray(nextItems) ? nextItems : []);
    persistOrder(null);
  };

  return {
    items, add, remove, setQty, clear, replace, lineCount, subtotal,
    customOrder, setCustomOrder, setDiscountOverride,
    setInternalNote, bulkSetDiscount,
  };
}

// ───────────────────────────────────────────────────────────────────
// Cart drawer — slide-from-right wizard. 2 steps + a sent-state.
//   1. Request — kit list + dates + (optional) custom code, all in
//                one screen. Year hidden on the date inputs; time
//                pickers are selects of half-hour slots with an
//                "Include after-hours" toggle that expands the list.
//   2. Review  — summary + optional notes + two CTAs:
//                "Download quote" (client-side PDF, via jsPDF loaded
//                from CDN on first use) and "Send request" (POSTs
//                to the API). Both pop an inline email prompt in
//                the footer before doing the action — email is
//                never asked for inline in the form, only when one
//                of the CTAs is fired.
//
// Auto-fill defaults follow the day-rate math (see memory/day_rate_math.md
// and the "Hire periods & late returns" FAQ entry): a 1-rental-day hire
// spans 3 calendar days — collect from 15:00 the day before, return by
// 11:00 the day after. Pickup lands today @ 16:00 if the page is
// opened before 13:00 local; after that, a same-day pickup before the
// 15:00 window isn't realistic so we roll forward to tomorrow. Return
// is pickup + 2 calendar days @ 10:00. Billed as 1 rental day.
//
// Submit currently logs the payload + shows the sent-state. The
// real `/api/rentals/request` endpoint ships next iteration.
// ───────────────────────────────────────────────────────────────────
// CART_STEPS constant removed in the May 2026 reshape — the cart no
// longer has a wizard; everything lives on one screen. Kept as a
// comment marker so anyone grepping for the old name lands here.

// Time-slot model. Each weekday has its own staffed-hours window
// (mirrors the /rentals/about hours card). Slots within the window
// are every whole hour, plus 08:30 as a deliberate boundary on
// days where the window opens at 08:30 (heavily-requested pre-work
// pickup slot). Two sentinel string values — TIME_SLOT_EARLY /
// TIME_SLOT_LATE — represent "After hours · Early" and "After hours
// · Late" inline in the dropdown; picking either adds £25 per event
// to the quote (so pickup early + return late = £50). Replaces the
// older checkbox + half-hour lists.
const TIME_SLOT_EARLY = 'early';
const TIME_SLOT_LATE  = 'late';
const AFTER_HOURS_CHARGE = 25;

// ── Coupon code parsing + per-item discount routing ────────────────
// Any number embedded in a coupon code is treated as the headline
// discount percent (last number wins if there are several, capped at
// 100). The percent is then routed by item ownership:
//   owned       → full percent
//   consignment → half percent (we honour half the discount since the
//                 consignor shares the revenue with us)
//   cross-hire  → 0% (we don't own the kit; we can't discount it)
//   anything else (after-hours, delivery, fees) → 0%
// Hardwired promotional codes — recognised exactly (case-insensitive)
// regardless of any embedded number. Each entry maps the canonical
// code to its discount percent. Known codes win over the trailing-
// number heuristic below so a typo like "WETHIRE3" won't accidentally
// give a 3% discount (it'd fall to heuristic; we treat that as best-
// effort backwards-compat behaviour). Add new public promos here.
const KNOWN_COUPONS = {
  'WETHIRE30':   30,
  'WINTERLIN50': 50,
};

function parseCoupon(code) {
  const raw = String(code || '').trim();
  if (!raw) return { raw: '', percent: 0 };
  // Hardwired list — case-insensitive exact match wins. Preserves the
  // user's original casing in `raw` (so the email/PDF show the code as
  // they typed it) but routes the percent from the canonical entry.
  const known = KNOWN_COUPONS[raw.toUpperCase()];
  if (typeof known === 'number') {
    return { raw, percent: Math.min(100, Math.max(0, known)) };
  }
  // Backwards-compat heuristic: trailing number wins. Lets per-customer
  // codes (admin-set in Notion, arbitrary names) keep working without
  // needing to register each one above.
  const nums = raw.match(/\d+/g);
  if (!nums || nums.length === 0) return { raw, percent: 0 };
  const last = parseInt(nums[nums.length - 1], 10);
  if (!Number.isFinite(last)) return { raw, percent: 0 };
  return { raw, percent: Math.min(100, Math.max(0, last)) };
}
function discountPercentFor(item, couponPercent) {
  // Staff-mode per-line override wins. A number (including 0) is a
  // deliberate "use this exact percent on this line"; null/undefined
  // means "fall back to the auto-routed value below". Lets staff
  // discount a cross-hire item, zero-rate an owned one, etc., without
  // changing the underlying coupon string.
  if (item && typeof item.discountOverride === 'number' && !Number.isNaN(item.discountOverride)) {
    return Math.max(0, Math.min(100, item.discountOverride));
  }
  if (!couponPercent) return 0;
  // Routing is by `ownership` — custom items historically short-
  // circuited to 0 here (since their default ownership was 'custom'),
  // but staff can now pick an explicit ownership for custom items so
  // the coupon should apply when they do. The 'custom' sentinel
  // below still produces 0 for un-set custom items, preserving the
  // old "hand-quoted = no coupon stack" behaviour for the default
  // case.
  const ownership = (item && item.ownership) || 'owned';
  if (ownership === 'cross-hire') return 0;
  if (ownership === 'custom') return 0;
  if (ownership === 'consignment') return couponPercent / 2;
  return couponPercent; // 'owned' or anything else (default to full)
}

// Auto-computed discount percent (what discountPercentFor would return
// IF there were no override). Lets the staff-mode UI show "Auto: 25%
// (consignment)" next to the override input so they know what the
// non-overridden value would be, and to compute the reset-to-auto.
function autoDiscountPercentFor(item, couponPercent) {
  if (!couponPercent) return 0;
  const ownership = (item && item.ownership) || 'owned';
  if (ownership === 'cross-hire') return 0;
  if (ownership === 'custom') return 0;
  if (ownership === 'consignment') return couponPercent / 2;
  return couponPercent;
}
const HOURS_BY_DAY = {
  0: { earliest: '10:00', latest: '16:00' }, // Sunday
  1: { earliest: '08:30', latest: '18:00' }, // Monday
  2: { earliest: '08:30', latest: '18:00' }, // Tuesday
  3: { earliest: '08:30', latest: '18:00' }, // Wednesday
  4: { earliest: '08:30', latest: '18:00' }, // Thursday
  5: { earliest: '08:30', latest: '18:00' }, // Friday
  6: { earliest: '08:30', latest: '17:00' }, // Saturday
};
function timeSlotsForDate(iso) {
  const fallback = HOURS_BY_DAY[1];
  if (!iso) return _buildSlots(fallback.earliest, fallback.latest);
  const d = new Date(iso + 'T00:00:00');
  if (isNaN(d)) return _buildSlots(fallback.earliest, fallback.latest);
  const h = HOURS_BY_DAY[d.getDay()] || fallback;
  return _buildSlots(h.earliest, h.latest);
}
function _buildSlots(earliest, latest) {
  const out = [];
  if (earliest === '08:30') out.push('08:30');
  const startH = earliest === '08:30' ? 9 : parseInt(earliest, 10);
  const endH = parseInt(latest, 10);
  for (let h = startH; h <= endH; h++) out.push(`${String(h).padStart(2, '0')}:00`);
  return out;
}
function isAfterHoursTime(t) { return t === TIME_SLOT_EARLY || t === TIME_SLOT_LATE; }
function afterHoursEventCount(details) {
  let n = 0;
  if (isAfterHoursTime(details.pickupTime)) n++;
  if (isAfterHoursTime(details.returnTime)) n++;
  return n;
}
function afterHoursFee(details) { return afterHoursEventCount(details) * AFTER_HOURS_CHARGE; }
function fmtTimeForDisplay(t) {
  if (t === TIME_SLOT_EARLY) return 'After hours · Early';
  if (t === TIME_SLOT_LATE)  return 'After hours · Late';
  return t || '—';
}

function suggestPickupDate() {
  const now = new Date();
  const cutoff = new Date(now);
  cutoff.setHours(13, 0, 0, 0);
  // Before 13:00 → today; from 13:00 onwards → tomorrow (the 15:00
  // pickup window is too close to be a realistic same-day option).
  const d = now >= cutoff ? new Date(now.getTime() + 24 * 60 * 60 * 1000) : now;
  const yyyy = d.getFullYear();
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  return `${yyyy}-${mm}-${dd}`;
}
function suggestReturnDate(pickupISO) {
  // 1-rental-day hire spans 3 calendar days: pickup day, the rental
  // day, then return-grace morning. So return = pickup + 2 days.
  const d = new Date(pickupISO + 'T12:00:00');
  d.setDate(d.getDate() + 2);
  const yyyy = d.getFullYear();
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  return `${yyyy}-${mm}-${dd}`;
}

const CART_DEFAULT_DETAILS = (() => {
  const pickup = suggestPickupDate();
  return {
    pickupDate: pickup,
    pickupTime: '16:00',
    returnDate: suggestReturnDate(pickup),
    returnTime: '10:00',
    // After-hours is no longer a toggle — TIME_SLOT_EARLY / LATE live
    // inline in the time dropdowns. Whether either time is an
    // after-hours sentinel is derived via isAfterHoursTime() at
    // render/total time.
    code: '',          // optional custom code: discount / agency ref / partner code
    email: '',         // captured via the email modal, not inline
    notes: '',
    // Per-leg location — pickup (out) and return are independent so
    // mix-and-match is supported (e.g. delivered to set, collected
    // back by the client). Default is collection both ways (the
    // dominant pattern in current bookings).
    //
    // The zone + travelMin fields are auto-derived from the postcode
    // lookup (postcodes.io) in CartLocationBlock and persisted here
    // so price-line rendering can read them synchronously without
    // re-fetching. Manual overrides aren't exposed in the UI — Max's
    // direction was that the zone chips disappear once auto works.
    pickupMode: 'collection',     // 'collection' | 'delivery'
    pickupAddress: '',
    pickupPostcode: '',
    pickupZone: null,             // null until classified; 'ccz'|'m25'|'small-drop'|'outside'
    pickupCoords: null,           // { lat, lng } from postcodes.io
    pickupTravelMin: 0,           // estimated minutes one-way
    returnMode: 'collection',
    returnAddress: '',
    returnPostcode: '',
    returnZone: null,
    returnCoords: null,
    returnTravelMin: 0,
  };
})();

// Driven-delivery pricing — rates per hour mirror the figures on the
// About page's Collection/Delivery table (CCZ £65/hr, Within M25
// £45/hr, Small drop flat £25). Outside M25 is null = quote on
// request. The Location block auto-classifies the zone from the
// delivered postcode + cart contents (see classifyDeliveryZone),
// then deliveryLegPrice multiplies the per-hour rate by the rounded
// travel hours (Max's rule: round down if ≤15 min over the hour,
// otherwise round up; 1 hr minimum).
const DELIVERY_RATES_PER_HOUR = { ccz: 65, m25: 45 };
const DELIVERY_SMALL_DROP_FLAT = 25;
// Customer-facing labels for the auto-classified delivery type.
// The user only ever sees "Small drop" or "Van delivery" as the
// type — the zone (ccz / m25 / outside) is still tracked underneath
// to drive the per-hour rate but isn't surfaced as a separate
// chip. 'small-drop' wins whenever the cart qualifies (single
// non-Grip item, inside M25). Otherwise it's a van delivery
// priced by zone.
const DELIVERY_ZONE_LABELS = {
  ccz:           'Van delivery',  // CCZ rate applied (£65/hr)
  m25:           'Van delivery',  // Inside M25 rate (£45/hr)
  'small-drop':  'Small drop',    // Flat £25
  outside:       'Outside M25 — quote on request',
};
// Zone-specific addendum shown alongside the price so the customer
// understands why a van delivery is priced higher in the CCZ. Empty
// for small drops (the flat-rate label is self-explanatory) and
// outside (which doesn't get an auto-price anyway).
const DELIVERY_ZONE_NOTES = {
  ccz:           'CCZ surcharge',
  m25:           'inside M25',
  'small-drop':  '',
  outside:       '',
};
function deliveryLegPrice(mode, zone, travelHours) {
  if (mode !== 'delivery') return 0;
  if (zone === 'small-drop') return DELIVERY_SMALL_DROP_FLAT;
  if (zone === 'ccz' || zone === 'm25') {
    const hrs = Math.max(1, travelHours || 1);
    return DELIVERY_RATES_PER_HOUR[zone] * hrs;
  }
  return 0;  // outside / unknown → quote on request, no auto price
}

// VF HQ coordinates (W3 7QS) — origin for all delivery travel-time
// computations. Hand-validated against Google Maps for the Vale.
const VF_HQ_COORDS = { lat: 51.5061, lng: -0.2620 };

// Congestion Charge Zone — rough bounding box covering the actual
// irregular boundary (Marylebone Rd north, City Road east, Vauxhall
// south, Park Lane / Edgware Rd west). Coarse but good enough for
// auto-classification; a manual override path isn't shipped per
// Max's "chips disappear" direction, but the postcode being on the
// edge would round-up to ccz which errs on the right side (higher
// rate, no surprise charges later).
const CCZ_BBOX = { south: 51.4940, north: 51.5305, west: -0.1700, east: -0.0720 };

// M25 is roughly a circle ~17 miles from central London. Use a
// distance-from-HQ threshold; HQ is in west London so 20 miles from
// HQ comfortably covers the M25's western edge while pulling in
// most of the south + east. Anything beyond → "Outside M25".
const M25_RADIUS_MILES_FROM_HQ = 20;

// Haversine — straight-line miles between two lat/lng points. Used
// to derive an estimated road-trip time from the delivered postcode
// to the studio. Multiply by ROAD_DETOUR_FACTOR to approximate the
// driving distance vs the crow-flies line.
const ROAD_DETOUR_FACTOR = 1.4;
function haversineMiles(lat1, lng1, lat2, lng2) {
  const R = 3958.7613;  // earth radius in miles
  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.asin(Math.sqrt(a));
}

// Travel-time speed assumptions per zone — accounts for traffic.
// CCZ is the slowest (heavy congestion + frequent stops); within M25
// is suburban speed; outside is motorway-ish. Numbers tuned against
// typical Acton → X drive times.
const TRAVEL_SPEED_MPH = { ccz: 12, m25: 22, outside: 35 };

function estimateTravelMinutes(distMiles, zone) {
  const roadMiles = distMiles * ROAD_DETOUR_FACTOR;
  const speed = TRAVEL_SPEED_MPH[zone] || TRAVEL_SPEED_MPH.m25;
  return Math.round((roadMiles / speed) * 60);
}

// Max's rounding: round DOWN to whole hours if the remainder over
// the hour is ≤15 minutes; otherwise round UP. 1-hour minimum.
function roundToBilledHours(minutes) {
  if (!minutes || minutes < 0) return 1;
  const wholeHours = Math.floor(minutes / 60);
  const remainder = minutes % 60;
  const rounded = remainder <= 15 ? wholeHours : wholeHours + 1;
  return Math.max(1, rounded);
}

function fmtTravelHours(minutes) {
  if (!minutes || minutes < 0) return '';
  if (minutes < 60) return `${minutes} min`;
  const hrs = Math.floor(minutes / 60);
  const mins = minutes % 60;
  if (mins === 0) return `${hrs} hr${hrs > 1 ? 's' : ''}`;
  return `${hrs}h ${mins}m`;
}

// Inside CCZ — coarse bbox check. False = postcode not in the
// congestion zone (most of Greater London).
function isInsideCCZ(coords) {
  if (!coords) return false;
  return coords.lat >= CCZ_BBOX.south && coords.lat <= CCZ_BBOX.north
      && coords.lng >= CCZ_BBOX.west  && coords.lng <= CCZ_BBOX.east;
}

function isInsideM25(coords) {
  if (!coords) return false;
  const d = haversineMiles(VF_HQ_COORDS.lat, VF_HQ_COORDS.lng, coords.lat, coords.lng);
  return d <= M25_RADIUS_MILES_FROM_HQ;
}

// Small-drop qualifier per Max's safety rule: ≤3 items total (qty-
// summed) AND no Grip kit (grip items skew heavier than the rest
// of the catalog — sandbags, c-stands, frames). Erring on the
// "heavier than you think" side per his direction.
function qualifiesForSmallDrop(items) {
  if (!Array.isArray(items) || items.length !== 1) return false;
  const only = items[0];
  if (!only || (only.qty || 1) !== 1) return false;
  if (only.category === 'Grip') return false;
  return true;
}

// Single auto-classifier — given a postcode-coords lookup result and
// the current cart, returns one of 'ccz' | 'm25' | 'small-drop' |
// 'outside'. Small-drop only fires inside M25 (no flat-rate delivery
// out to Manchester). Returns null if coords aren't available yet
// (postcode not validated) so the caller can show "looking up…".
function classifyDeliveryZone(coords, items) {
  if (!coords) return null;
  if (!isInsideM25(coords)) return 'outside';
  if (qualifiesForSmallDrop(items)) return 'small-drop';
  if (isInsideCCZ(coords)) return 'ccz';
  return 'm25';
}

// Postcode → coords via postcodes.io (free, no key). Cached per-
// postcode so a typo + correction doesn't fire two lookups. Returns
// { lat, lng } or null on 404 / network error.
const _postcodeCoordsCache = new Map();
async function lookupPostcodeCoords(rawPostcode) {
  const pc = String(rawPostcode || '').replace(/\s+/g, '').toUpperCase();
  if (!pc) return null;
  if (_postcodeCoordsCache.has(pc)) return _postcodeCoordsCache.get(pc);
  try {
    const r = await fetch(`https://api.postcodes.io/postcodes/${encodeURIComponent(pc)}`);
    if (r.status === 404) {
      _postcodeCoordsCache.set(pc, null);
      return null;
    }
    if (!r.ok) return null;
    const data = await r.json();
    if (!data || !data.result) {
      _postcodeCoordsCache.set(pc, null);
      return null;
    }
    const coords = { lat: data.result.latitude, lng: data.result.longitude };
    _postcodeCoordsCache.set(pc, coords);
    return coords;
  } catch {
    return null;
  }
}

function VfAddressIcon() {
  // Tiny pin icon used in the Location block. Inline SVG so it
  // ships without an extra HTTP request and inherits currentColor.
  return (
    <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
      <circle cx="12" cy="10" r="3" />
    </svg>);
}
// Valley HQ collection address — surfaces in the Collection hover hint
// and on the PDF when collection is the chosen leg. Single source of
// truth; matches llms.txt and the JSON-LD postalAddress in index.html.
const VF_COLLECTION_ADDRESS = {
  name: 'Valley Rentals',
  line1: 'Access House',
  line2: '207–211 The Vale',
  city:  'Acton, London',
  postcode: 'W3 7QS',
};

// ── Quote PDF — built client-side via jsPDF loaded lazily from CDN ──
// Loader is fire-once: the first "Download quote" click triggers the
// fetch, subsequent clicks reuse the cached module. ~70 KB gzipped,
// only paid for by people who actually want the file.
let _jsPdfLoadPromise = null;
function loadJsPdf() {
  if (typeof window === 'undefined') return Promise.reject(new Error('SSR'));
  if (window.jspdf && window.jspdf.jsPDF) return Promise.resolve(window.jspdf.jsPDF);
  if (_jsPdfLoadPromise) return _jsPdfLoadPromise;
  _jsPdfLoadPromise = new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
    s.async = true;
    s.onload = () => {
      if (window.jspdf && window.jspdf.jsPDF) resolve(window.jspdf.jsPDF);
      else reject(new Error('jsPDF loaded but jspdf.jsPDF not found'));
    };
    s.onerror = () => { _jsPdfLoadPromise = null; reject(new Error('Failed to load jsPDF')); };
    document.head.appendChild(s);
  });
  return _jsPdfLoadPromise;
}

// Rental-day count per the day-rate math (see memory/day_rate_math.md):
// N rental days = (return − pickup) calendar-day difference − 1. The
// minimum is 1 — single-day hires still bill a day even if the dates
// arithmetic suggests otherwise (e.g. same-day pickup/return).
function calcRentalDays(pickupISO, returnISO) {
  if (!pickupISO || !returnISO) return 0;
  const pickup = new Date(pickupISO + 'T00:00:00');
  const ret = new Date(returnISO + 'T00:00:00');
  if (isNaN(pickup) || isNaN(ret)) return 0;
  const diff = Math.round((ret - pickup) / 86400000);
  return Math.max(1, diff - 1);
}

// "Wed 27 May" — long-form date for the PDF (the in-cart short form
// drops the year + weekday; the PDF can show more because it has
// room and isn't tracking real-time relative dates).
function fmtDateLongForPdf(iso) {
  if (!iso) return '—';
  const d = new Date(iso + 'T00:00:00');
  if (isNaN(d)) return iso;
  return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' });
}

// Promise-wrapped Image loader — used by buildQuotePdf to fetch the
// Valley Rentals logo from the live site before drawing the PDF.
// crossOrigin='anonymous' ensures the image can be embedded in a
// canvas (jsPDF's addImage path) — the asset host returns ACAO:*.
// Resolves to the loaded HTMLImageElement or null on failure (the
// PDF still renders without the logo, just without the mark).
function loadImageForPdf(url) {
  return new Promise((resolve) => {
    if (typeof window === 'undefined' || typeof Image === 'undefined') return resolve(null);
    let settled = false;
    const done = (val) => { if (settled) return; settled = true; resolve(val); };
    // Primary path: <img crossOrigin='anonymous'>. Works when the CDN
    // returns Access-Control-Allow-Origin:* (Booqable's normally does).
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => done(img);
    img.onerror = async () => {
      // Fallback path: fetch the bytes ourselves and re-load via a
      // data URL. Catches some browser/CDN quirks where Image() taints
      // the response even though fetch() can still read it — also
      // helps when the live site (https://valley.film) sees a
      // different CORS preflight outcome than localhost.
      try {
        const resp = await fetch(url, { mode: 'cors' });
        if (!resp.ok) return done(null);
        const blob = await resp.blob();
        const dataUrl = await new Promise((res) => {
          const reader = new FileReader();
          reader.onload = () => res(reader.result);
          reader.onerror = () => res(null);
          reader.readAsDataURL(blob);
        });
        if (!dataUrl) return done(null);
        // Re-load into an Image so callers get naturalWidth/Height.
        const dataImg = new Image();
        dataImg.onload = () => done(dataImg);
        dataImg.onerror = () => done(null);
        dataImg.src = dataUrl;
      } catch (e) { done(null); }
    };
    img.src = url;
  });
}

// Brand tokens used by the PDF — mirror colors_and_type.css so the
// document reads as a Valley artefact rather than a default jsPDF.
const PDF_BRAND = {
  blue:    [9, 94, 223],   // --vf-blue   #095EDF
  blue50:  [241, 246, 254],// --vf-blue-50 tinted background
  ink:     [10, 15, 26],   // body text
  ink2:    [75, 84, 104],  // muted body
  ink3:    [126, 134, 152],// fine print
  line:    [225, 228, 235],// hairlines
  white:   [255, 255, 255],
};

// Build + save the quote PDF. `ref` is the human-readable reference
// string (e.g. "VR-26052481X") shown in the header and used as the
// filename. `account` is the result of /api/rentals/verify-email —
// null if no lookup happened, otherwise { found, verified, name,
// photo, company, phone, address, billingAddress, status }.
//
// Layout (A4 595 × 842pt, 48pt margins):
//   1. Header — logo top-left, "QUOTE {ref}" + date top-right
//   2. Brand-blue accent rule
//   3. Meta row — "Prepared for" (left) + "Hire period" (right)
//   4. Account flag — verified card OR "Need to open account" callout
//   5. Kit table — striped rows, right-aligned numerics
//   6. Totals — subtotal, optional after-hours, optional period total
//   7. Code + Notes (optional)
//   8. Footer — contact + social + validity disclaimer
//
// Typography uses jsPDF's stock Helvetica (Acumin Pro would require
// embedding ~150 KB of TTF data; Helvetica reads close enough). All
// arrows are ASCII (`->`) since stock Helvetica only encodes WinAnsi
// and Unicode → / × glyphs render as boxes. Checkmarks and warning
// icons are drawn as paths via doc.line + doc.triangle — no Unicode
// dependency. doc.splitTextToSize handles long item names (wraps to
// multiple lines; row height tracks line count so no overlap).
async function buildQuotePdf({ items, subtotal, details, ref, account, cometMode = false }) {
  const JsPDF = await loadJsPdf();
  // Load logo in parallel with jsPDF init. On failure the PDF still
  // renders — just without the brand mark.
  const logo = await loadImageForPdf('https://valley.film/assets/rentals-logo-blue.png');

  const doc = new JsPDF({ unit: 'pt', format: 'a4' });
  const PW = 595.28, PH = 841.89; // A4 in pt
  const M = 48;
  const setFill = (rgb) => doc.setFillColor(rgb[0], rgb[1], rgb[2]);
  const setText = (rgb) => doc.setTextColor(rgb[0], rgb[1], rgb[2]);
  const setDraw = (rgb) => doc.setDrawColor(rgb[0], rgb[1], rgb[2]);

  // Column anchors — right-aligned numeric columns so digits line up.
  const COL_QTY_R = PW - M - 200;
  const COL_RATE_R = PW - M - 100;
  const COL_SUB_R = PW - M - 10;
  const ITEM_MAX_W = COL_QTY_R - (M + 10) - 14;

  const days = calcRentalDays(details.pickupDate, details.returnDate);
  // Coupon — parsed here once so per-line + totals share the same
  // value. Routed by ownership in the same way as the cart preview.
  // `pdfCoupon` is referenced by the kit-row renderer above (defined
  // before that loop runs).
  const pdfCoupon = parseCoupon(details && details.code);
  const couponDiscountTotal = items.reduce((s, i) => (
    s + (Number(i.dayRate || 0) * i.qty * (discountPercentFor(i, pdfCoupon.percent) / 100))
  ), 0);
  const discountedSubtotal = subtotal - couponDiscountTotal;
  const periodTotal = discountedSubtotal * days;
  const ahFee = afterHoursFee(details);
  const ahCount = afterHoursEventCount(details);
  // Delivery fees mirror the in-cart math: per-leg, zone × billed
  // hours (Max's rounding rule, 1-hour minimum). Outside-M25 fares
  // return 0 here too (quote-on-request shown separately on the
  // line).
  const pickupHours = roundToBilledHours(details.pickupTravelMin);
  const returnHours = roundToBilledHours(details.returnTravelMin);
  const pickupDeliveryFee = deliveryLegPrice(details.pickupMode, details.pickupZone, pickupHours);
  const returnDeliveryFee = deliveryLegPrice(details.returnMode, details.returnZone, returnHours);
  const deliveryFee = pickupDeliveryFee + returnDeliveryFee;
  const grandTotal = periodTotal + ahFee + deliveryFee;
  const today = new Date().toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' });

  // Local eyebrow renderer — mono-uppercase small-caps headers in
  // brand blue. Used at the top of every section so the document
  // reads consistently with the website's eyebrow pattern.
  const eyebrow = (label, x, yy) => {
    setText(PDF_BRAND.blue);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
    doc.text(label.toUpperCase(), x, yy);
    setText(PDF_BRAND.ink);
  };

  // Draw a checkmark via two lines (Unicode ✓ doesn't render in
  // stock Helvetica's WinAnsi encoding). `size` is the bounding box.
  const drawCheck = (x, yy, size, color) => {
    setDraw(color);
    doc.setLineWidth(size * 0.18);
    doc.setLineCap('round');
    doc.line(x, yy + size * 0.55, x + size * 0.38, yy + size * 0.9);
    doc.line(x + size * 0.38, yy + size * 0.9, x + size, yy + size * 0.18);
  };
  // Draw a warning "!" triangle for the no-account callout.
  const drawWarning = (x, yy, size, color) => {
    setFill(color);
    doc.triangle(
      x + size / 2, yy,
      x + size, yy + size,
      x, yy + size,
      'F'
    );
    setText(PDF_BRAND.white);
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(size * 0.7);
    doc.text('!', x + size / 2, yy + size * 0.85, { align: 'center' });
  };

  // ── 1. HEADER ────────────────────────────────────────────────────
  let y = M;
  if (logo) {
    const targetW = 120;
    const ratio = logo.naturalHeight / logo.naturalWidth || 0.28;
    const targetH = targetW * ratio;
    doc.addImage(logo, 'PNG', M, y, targetW, targetH);
  } else {
    setText(PDF_BRAND.blue);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(18);
    doc.text('Valley Rentals', M, y + 16);
  }
  // Top-right: small "QUOTE" eyebrow → ref → date stack
  setText(PDF_BRAND.ink3);
  doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
  doc.text('QUOTE', PW - M, y + 6, { align: 'right' });
  setText(PDF_BRAND.ink);
  doc.setFont('helvetica', 'bold'); doc.setFontSize(15);
  doc.text(ref, PW - M, y + 26, { align: 'right' });
  setText(PDF_BRAND.ink2);
  doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
  doc.text(today, PW - M, y + 42, { align: 'right' });

  // Brand-blue accent rule under the header
  y += 70;
  setFill(PDF_BRAND.blue);
  doc.rect(M, y, 56, 2.5, 'F');
  y += 26;

  // ── 2. ACCOUNT FLAG (top-left, paired with hire period right) ─────
  // Moved up from below the meta row per May 2026 feedback — the
  // account state is the single most important context for the
  // recipient (do we have a record of them? do they need to open
  // one?) so it leads. Verified gets a soft-blue card with billing
  // details; not-verified gets a stronger amber "Account needed"
  // callout with the open-account link.
  const ACCOUNT_W = (PW - 2 * M) * 0.56;  // ~56% width on the left
  const META_X = M + ACCOUNT_W + 18;      // hire-period column starts here
  const accountY = y;
  if (account?.found && account.verified) {
    const lines = [];
    if (account.name) lines.push(account.name);
    if (account.company) lines.push(account.company);
    if (details.email) lines.push(details.email);
    if (account.phone) lines.push(account.phone);
    if (account.address) lines.push(account.address);
    if (account.billingAddress && account.billingAddress !== account.address) {
      lines.push(`Billing: ${account.billingAddress}`);
    }
    const cardH = 22 + lines.length * 14 + 6;
    setFill(PDF_BRAND.blue50);
    doc.roundedRect(M, accountY, ACCOUNT_W, cardH, 6, 6, 'F');
    drawCheck(M + 14, accountY + 14, 12, PDF_BRAND.blue);
    setText(PDF_BRAND.blue);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
    doc.text('VERIFIED VALLEY RENTALS ACCOUNT', M + 34, accountY + 18);
    setText(PDF_BRAND.ink);
    doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
    let ly = accountY + 36;
    for (let i = 0; i < lines.length; i++) {
      doc.setFont('helvetica', i === 0 ? 'bold' : 'normal');
      setText(i === 0 ? PDF_BRAND.ink : PDF_BRAND.ink2);
      doc.text(lines[i], M + 34, ly);
      ly += 14;
    }
    var accountBottom = accountY + cardH;
  } else {
    // Stronger "Account needed" callout: amber border + chunkier
    // heading + the email shown inline + an explicit CTA URL block.
    // Bookings are gated on a verified account, so this needs to read
    // as action-required, not advisory.
    const AMBER = [196, 140, 28];
    const AMBER_BG = [253, 245, 224];
    const AMBER_BORDER = [232, 174, 60];
    const cardH = 100;
    setFill(AMBER_BG);
    doc.roundedRect(M, accountY, ACCOUNT_W, cardH, 6, 6, 'F');
    setDraw(AMBER_BORDER); doc.setLineWidth(1.2);
    doc.roundedRect(M, accountY, ACCOUNT_W, cardH, 6, 6, 'S');
    doc.setLineWidth(0.6);
    drawWarning(M + 14, accountY + 14, 14, AMBER);
    setText(AMBER);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
    doc.text('ACCOUNT NEEDED', M + 36, accountY + 22);
    setText(PDF_BRAND.ink);
    doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
    doc.text(`No verified account for ${details.email || 'this email'}.`, M + 14, accountY + 44);
    doc.text("Open one before we can confirm — takes a few minutes:", M + 14, accountY + 58);
    setText(PDF_BRAND.blue);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
    doc.textWithLink(
      'valley.film/rentals/open-account',
      M + 14, accountY + 78,
      { url: 'https://valley.film/rentals/open-account' }
    );
    setText(PDF_BRAND.ink);
    doc.setFont('helvetica', 'normal');
    var accountBottom = accountY + cardH;
  }

  // ── 2b. HIRE PERIOD (right column, paired with account left) ─────
  eyebrow('Hire period', META_X, accountY + 4);
  doc.setFont('helvetica', 'normal'); doc.setFontSize(11);
  setText(PDF_BRAND.ink);
  doc.text(`${fmtDateLongForPdf(details.pickupDate)}  ·  ${fmtTimeForDisplay(details.pickupTime)}`, META_X, accountY + 22);
  setText(PDF_BRAND.ink2); doc.setFontSize(10);
  doc.text(`->  ${fmtDateLongForPdf(details.returnDate)}  ·  ${fmtTimeForDisplay(details.returnTime)}`, META_X, accountY + 38);
  setText(PDF_BRAND.ink3); doc.setFontSize(9);
  const ahNote = ahFee > 0 ? `  ·  After-hours × ${ahCount}` : '';
  doc.text(`${days} Day Hire${ahNote}`, META_X, accountY + 52);
  setText(PDF_BRAND.ink);

  y = accountBottom + 22;

  // Hairline divider between meta/account block and kit
  setDraw(PDF_BRAND.line);
  doc.setLineWidth(0.6);
  doc.line(M, y, PW - M, y);
  y += 22;

  // ── 4. KIT SECTION ────────────────────────────────────────────────
  eyebrow('Kit', M, y);
  y += 16;
  doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
  setText(PDF_BRAND.ink3);
  doc.text('Item', M + 10, y);
  doc.text('Qty', COL_QTY_R, y, { align: 'right' });
  doc.text('Day rate', COL_RATE_R, y, { align: 'right' });
  doc.text('Subtotal / day', COL_SUB_R, y, { align: 'right' });
  y += 6;
  setDraw(PDF_BRAND.line);
  doc.setLineWidth(0.4);
  doc.line(M, y, PW - M, y);
  y += 12;
  setText(PDF_BRAND.ink); doc.setFontSize(11); doc.setFont('helvetica', 'normal');

  let rowIdx = 0;
  for (const i of items) {
    const nameLines = doc.splitTextToSize(String(i.name || ''), ITEM_MAX_W);
    const rowH = Math.max(20, nameLines.length * 14);
    if (y + rowH > PH - 200) { doc.addPage(); y = M; }
    if (rowIdx % 2 === 1) {
      setFill(PDF_BRAND.blue50);
      doc.rect(M, y - 13, PW - 2 * M, rowH, 'F');
    }
    setText(PDF_BRAND.ink);
    doc.text(nameLines, M + 10, y);
    // Admin-only ownership code (e.g. "C-PK", "X-SB") rendered small,
    // grey, and inline just after the last line of the item name.
    // Same convention as the website drawer + cart line — opaque to
    // the recipient but informative to internal review.
    const code = lineAdminCode(i);
    if (code) {
      const lastLine = nameLines[nameLines.length - 1];
      const lastW = doc.getTextWidth(lastLine);
      const codeY = y + (nameLines.length - 1) * 14;
      setText(PDF_BRAND.ink3);
      doc.setFontSize(8);
      doc.text(code, M + 10 + lastW + 6, codeY);
      doc.setFontSize(11);
    }
    setText(PDF_BRAND.ink);
    doc.text(String(i.qty), COL_QTY_R, y, { align: 'right' });
    setText(PDF_BRAND.ink2);
    doc.text(`£${Number(i.dayRate || 0).toFixed(2)}`, COL_RATE_R, y, { align: 'right' });
    // Per-line subtotal — discount-aware. When a coupon (or staff
    // override) is in play we print the net figure (still bold) and
    // emit a small green "-50%" tag in the gap BETWEEN the rate and
    // subtotal columns. Earlier placement (left of the rate using a
    // unicode minus and fractional fontSize) caused jsPDF helvetica
    // to render the discount label with character spacing that
    // collided with the rate text — see the May 2026 PDF regression.
    const linePct = discountPercentFor(i, pdfCoupon.percent);
    const grossLine = Number(i.dayRate || 0) * i.qty;
    const netLine = grossLine * (1 - linePct / 100);
    if (linePct > 0) {
      const linePctLabel = linePct % 1 === 0 ? `${linePct}` : linePct.toFixed(1);
      setText([21, 122, 74]);
      doc.setFontSize(8);  // integer — half-points kerned weirdly
      // ASCII hyphen-minus, not U+2212 — helvetica's WinAnsi mapping
      // misses the typographic minus and renders it as a multi-byte
      // sequence with stray characters.
      doc.text(`-${linePctLabel}%`, COL_RATE_R + 4, y, { align: 'left' });
      doc.setFontSize(11);
    }
    setText(PDF_BRAND.ink); doc.setFont('helvetica', 'bold');
    doc.text(`£${netLine.toFixed(2)}`, COL_SUB_R, y, { align: 'right' });
    doc.setFont('helvetica', 'normal');
    y += rowH;
    rowIdx++;
  }

  // ── 5. TOTALS ─────────────────────────────────────────────────────
  y += 8;
  setDraw(PDF_BRAND.ink); doc.setLineWidth(1.2);
  doc.line(M, y, PW - M, y);
  y += 22;

  const totalRow = (label, value, opts = {}) => {
    setText(opts.muted ? PDF_BRAND.ink2 : PDF_BRAND.ink);
    doc.setFont('helvetica', opts.bold ? 'bold' : 'normal');
    doc.setFontSize(opts.big ? 13 : 11);
    doc.text(label, M + 10, y);
    doc.text(value, COL_SUB_R, y, { align: 'right' });
  };
  // Show the gross day-rate, then a green discount line, then the net.
  // When there's no coupon we just print the single bold day-rate row
  // (same as before).
  if (couponDiscountTotal > 0) {
    totalRow('Day-rate subtotal (gross)', `£${Number(subtotal).toFixed(2)} / day`, { muted: true });
    y += 18;
    setText([21, 122, 74]);
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(11);
    doc.text(`Discount · ${pdfCoupon.raw}`, M + 10, y);
    // ASCII '-' to match the per-line tag — helvetica's WinAnsi map
    // mangles the typographic minus into wide-spaced fallback chars.
    doc.text(`-£${couponDiscountTotal.toFixed(2)} / day`, COL_SUB_R, y, { align: 'right' });
    doc.setFont('helvetica', 'normal');
    y += 18;
    totalRow('Day-rate subtotal (after discount)', `£${discountedSubtotal.toFixed(2)} / day`, { bold: true });
  } else {
    totalRow('Day-rate subtotal', `£${Number(subtotal).toFixed(2)} / day`, { bold: true });
  }
  y += 18;
  if (ahFee > 0) {
    const ahLabel = ahCount === 2 ? 'After-hours surcharge (pickup + return)'
                  : isAfterHoursTime(details.pickupTime) ? 'After-hours surcharge (pickup)'
                  : 'After-hours surcharge (return)';
    totalRow(ahLabel, `£${ahFee.toFixed(2)}`, { muted: true });
    y += 18;
  }
  if (pickupDeliveryFee > 0) {
    totalRow(`Out · ${DELIVERY_ZONE_LABELS[details.pickupZone]}`, `£${pickupDeliveryFee.toFixed(2)}`, { muted: true });
    y += 18;
  }
  if (returnDeliveryFee > 0) {
    totalRow(`Back · ${DELIVERY_ZONE_LABELS[details.returnZone]}`, `£${returnDeliveryFee.toFixed(2)}`, { muted: true });
    y += 18;
  }
  if (days > 1 || ahFee > 0 || deliveryFee > 0) {
    const extras = [];
    if (ahFee > 0) extras.push('after-hours');
    if (deliveryFee > 0) extras.push('delivery');
    const extrasNote = extras.length ? ` (incl. ${extras.join(' + ')})` : '';
    const grandLabel = days > 1
      ? `Estimated period total · ${days} days${extrasNote}`
      : `Total${extrasNote}`;
    const finalAmount = days > 1 ? grandTotal : discountedSubtotal + ahFee + deliveryFee;
    setDraw(PDF_BRAND.line); doc.setLineWidth(0.6);
    doc.line(M, y - 6, PW - M, y - 6);
    y += 6;
    totalRow(grandLabel, `£${finalAmount.toFixed(2)}`, { bold: true, big: true });
    y += 22;
  }
  setText(PDF_BRAND.ink3); doc.setFont('helvetica', 'italic'); doc.setFontSize(8.5);
  doc.text('no VAT applied', COL_SUB_R, y, { align: 'right' });
  doc.setFont('helvetica', 'normal');
  y += 24;

  // ── 6. CODE + NOTES (optional) ────────────────────────────────────
  setText(PDF_BRAND.ink);
  if (details.code) {
    eyebrow('Code applied', M, y);
    y += 14;
    doc.setFont('helvetica', 'normal'); doc.setFontSize(11);
    doc.text(String(details.code), M, y);
    y += 22;
  }
  if (details.notes) {
    eyebrow('Notes', M, y);
    y += 14;
    doc.setFont('helvetica', 'normal'); doc.setFontSize(11);
    setText(PDF_BRAND.ink2);
    const lines = doc.splitTextToSize(String(details.notes), PW - 2 * M);
    doc.text(lines, M, y);
    y += lines.length * 13;
  }

  // ── 6b. COLLECTION / DELIVERY ─────────────────────────────────────
  // Per-leg summary: each leg renders as a "FROM / TO" pair so the
  // driver / studio staff can read it at a glance. Sits just above
  // the footer so it's the last thing on the page — easy to find
  // when prepping the booking. Always rendered (even if both legs
  // are collection) so the addresses are part of the audit trail.
  y += 16;
  if (y > PH - 200) { doc.addPage(); y = M; }
  eyebrow('Collection & delivery', M, y);
  y += 16;
  const renderLeg = (legLabel, mode, address, postcode, zone) => {
    setText(PDF_BRAND.ink3); doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
    doc.text(legLabel.toUpperCase(), M + 10, y);
    setText(PDF_BRAND.ink); doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
    if (mode === 'delivery') {
      const zoneLabel = DELIVERY_ZONE_LABELS[zone] || 'Delivery';
      const addrText = address || postcode ? `${address || ''}${address && postcode ? ', ' : ''}${postcode || ''}` : 'Address to be confirmed';
      doc.text(`Driven delivery · ${zoneLabel}`, M + 80, y);
      setText(PDF_BRAND.ink2); doc.setFontSize(9);
      doc.text(addrText, M + 80, y + 13);
      setText(PDF_BRAND.ink);
    } else {
      doc.text(`Collection at Valley Rentals`, M + 80, y);
      setText(PDF_BRAND.ink2); doc.setFontSize(9);
      doc.text(
        `${VF_COLLECTION_ADDRESS.line1}, ${VF_COLLECTION_ADDRESS.line2}, ${VF_COLLECTION_ADDRESS.city} ${VF_COLLECTION_ADDRESS.postcode}`,
        M + 80, y + 13
      );
      setText(PDF_BRAND.ink);
    }
    y += 28;
  };
  renderLeg('Out',  details.pickupMode, details.pickupAddress, details.pickupPostcode, details.pickupZone);
  renderLeg('Back', details.returnMode, details.returnAddress, details.returnPostcode, details.returnZone);

  // ── 7. FOOTER ─────────────────────────────────────────────────────
  // Three-row stack: company line, contact + social, fine print.
  const footerTop = PH - 78;
  setDraw(PDF_BRAND.line); doc.setLineWidth(0.6);
  doc.line(M, footerTop, PW - M, footerTop);

  // Row 1: brand mark + tagline
  setText(PDF_BRAND.blue);
  doc.setFont('helvetica', 'bold'); doc.setFontSize(10);
  doc.text('Valley Rentals', M, footerTop + 14);
  setText(PDF_BRAND.ink3);
  doc.setFont('helvetica', 'normal'); doc.setFontSize(8.5);
  doc.text('Acton, West London  ·  registered film + commercial rentals', M + 88, footerTop + 14);

  // Row 2: contact + social on one line (no Unicode icons — just
  // text labels separated by middots)
  setText(PDF_BRAND.ink2);
  doc.setFont('helvetica', 'normal'); doc.setFontSize(9);
  const contactLine = 'rentals@valley.film  ·  valley.film/rentals  ·  Instagram @rentals.valley  ·  WhatsApp +44 7771 039043';
  doc.text(contactLine, M, footerTop + 30);

  // Row 3: validity / disclaimer
  setText(PDF_BRAND.ink3);
  doc.setFontSize(8.5);
  doc.text('Quote valid 14 days. Final pricing depends on availability, hire length, delivery and any cross-hire.', M, footerTop + 48);

  // ── 8. COMET INSTRUCTIONS (staff-only, optional) ──────────────────
  // Appended as one or more extra pages when the "PDF Comet
  // instructions" toggle is on (Staff Tools menu). The customer-
  // facing pages above are unchanged — these pages exist only so
  // Claude Comet can read a single PDF and create the matching
  // booking in Booqable without further prompting from Max.
  if (cometMode) {
    doc.addPage();
    let cy = M;

    // Header
    setText(PDF_BRAND.blue);
    doc.setFont('helvetica', 'bold'); doc.setFontSize(11);
    doc.text('INTERNAL — COMET IMPORT INSTRUCTIONS', M, cy);
    cy += 6;
    setDraw(PDF_BRAND.blue); doc.setLineWidth(1.5);
    doc.line(M, cy, PW - M, cy);
    cy += 18;

    setText(PDF_BRAND.ink2);
    doc.setFont('helvetica', 'normal'); doc.setFontSize(9.5);
    const introLines = doc.splitTextToSize(
      'This page is for the rentals admin to feed into Claude Comet so Comet can ' +
      'create the matching booking in Booqable. Do not send this page to the customer — ' +
      'remove these pages (or generate a fresh PDF with PDF Comet instructions OFF) ' +
      'before forwarding the quote externally.',
      PW - 2 * M
    );
    for (const l of introLines) { doc.text(l, M, cy); cy += 13; }
    cy += 10;

    // Build the mono prompt block — wrapped lines, courier 9pt,
    // paginated using the same chunk pattern as buildReviewPdf so
    // long carts don't spill off the page.
    const promptLines = [];
    const coupon = parseCoupon(details && details.code);
    const email = String(details.email || '').trim() || '(not provided)';
    const customerLabel = account?.found
      ? `${account.name || '(no name)'}${account.verified ? ' — verified' : ' — unverified'}`
      : '(no account match)';
    const fmtTime = (t) => t === TIME_SLOT_EARLY ? 'After hours · Early'
                         : t === TIME_SLOT_LATE  ? 'After hours · Late'
                         : (t || '—');
    const days = calcRentalDays(details.pickupDate, details.returnDate);
    let ahCount = 0;
    if (isAfterHoursTime(details.pickupTime)) ahCount++;
    if (isAfterHoursTime(details.returnTime)) ahCount++;

    promptLines.push(`Quote reference: VR-QUOTE-${ref}`);
    promptLines.push(`Custom booking number (paste into Booqable): VR-QUOTE-${ref}`);
    promptLines.push('');
    promptLines.push(`Customer email:   ${email}`);
    promptLines.push(`Customer record:  ${customerLabel}`);
    if (account?.status) promptLines.push(`Account status:   ${account.status}`);
    promptLines.push('');
    promptLines.push('HIRE PERIOD');
    promptLines.push(`  Pickup: ${details.pickupDate || '(no date)'} · ${fmtTime(details.pickupTime)}`);
    promptLines.push(`  Return: ${details.returnDate || '(no date)'} · ${fmtTime(details.returnTime)}`);
    promptLines.push(`  Billed: ${days} day${days === 1 ? '' : 's'}${ahCount > 0 ? ` · after-hours × ${ahCount}` : ''}`);
    promptLines.push('');
    promptLines.push('LOGISTICS');
    const pickupSummary = details.pickupMode === 'delivery'
      ? `VF delivers (${DELIVERY_ZONE_LABELS[details.pickupZone] || 'zone tbc'}${details.pickupAddress ? ' — ' + details.pickupAddress : ''})`
      : `Customer collects from W3 7QS`;
    const returnSummary = details.returnMode === 'delivery'
      ? `VF collects (${DELIVERY_ZONE_LABELS[details.returnZone] || 'zone tbc'}${details.returnAddress ? ' — ' + details.returnAddress : ''})`
      : `Customer returns to W3 7QS`;
    promptLines.push(`  Out:  ${pickupSummary}`);
    promptLines.push(`  Back: ${returnSummary}`);
    promptLines.push('');

    // Itemised list with ownership + per-line discount info
    promptLines.push(`ITEMS (${items.length})`);
    items.forEach((i, idx) => {
      const linePct = discountPercentFor(i, coupon.percent);
      const grossLine = (Number(i.dayRate) || 0) * (Number(i.qty) || 0);
      const netLine = grossLine * (1 - linePct / 100);
      const ownership = (i.ownership || 'owned').toUpperCase();
      const codeTag = lineAdminCode(i);
      const codeLabel = codeTag ? `  [${codeTag}]` : '';
      promptLines.push(`  ${idx + 1}. ${i.qty}× ${i.name || '(unnamed)'}${codeLabel}`);
      promptLines.push(`     ownership: ${ownership} · day rate £${(Number(i.dayRate) || 0).toFixed(2)}`);
      if (linePct > 0) {
        const pctLabel = linePct % 1 === 0 ? `${linePct}` : linePct.toFixed(1);
        promptLines.push(`     discount:  -${pctLabel}% → £${netLine.toFixed(2)}/day net (gross £${grossLine.toFixed(2)})`);
      } else {
        promptLines.push(`     subtotal:  £${grossLine.toFixed(2)}/day`);
      }
      if (i.isCustom) {
        promptLines.push(`     ! CUSTOM ITEM — not in Booqable catalog; needs to be added as a one-off line or stop and ask Max.`);
      }
      if (i.notes) {
        const noteWrap = doc.splitTextToSize(`     notes: ${i.notes}`, PW - 2 * M - 8);
        for (const l of noteWrap) promptLines.push(l);
      }
      if (i.internalNote) {
        // Staff-only internal note. Render with a "STAFF" marker so
        // Comet (and any human eyes on the PDF) understand this is
        // an internal annotation, not the customer's notes field.
        const noteWrap = doc.splitTextToSize(`     STAFF: ${i.internalNote}`, PW - 2 * M - 8);
        for (const l of noteWrap) promptLines.push(l);
      }
    });
    promptLines.push('');

    // Discount summary
    if (coupon.raw) {
      promptLines.push('DISCOUNT');
      promptLines.push(`  Code applied: ${coupon.raw}  (${coupon.percent}% headline)`);
      promptLines.push(`    → owned items get ${coupon.percent}%`);
      promptLines.push(`    → consignment items get ${coupon.percent / 2}%`);
      promptLines.push(`    → cross-hire items get 0%`);
      const hasOverride = items.some((i) => typeof i.discountOverride === 'number');
      if (hasOverride) {
        promptLines.push(`  NOTE: one or more lines carry a manual override that beats the routing above —`);
        promptLines.push(`        use the per-line "discount" value shown in ITEMS, not the headline %.`);
      }
      promptLines.push('');
    }

    if (details.notes) {
      promptLines.push('CUSTOMER NOTES');
      const wrap = doc.splitTextToSize(details.notes, PW - 2 * M - 8);
      for (const l of wrap) promptLines.push(`  ${l}`);
      promptLines.push('');
    }

    promptLines.push('STEP-BY-STEP — for Comet');
    promptLines.push('  1. Open Booqable admin: https://valley-rentals.booqable.com/');
    promptLines.push('  2. Create a new booking via the New booking button.');
    promptLines.push('  3. Customer:');
    promptLines.push('       a. Search the customer list using the email above.');
    promptLines.push('       b. If a customer exists with that email — select them.');
    promptLines.push('       c. If not — STOP and ask Max before creating one. Do not guess a name.');
    promptLines.push('  4. Booking dates: use the HIRE PERIOD values above exactly.');
    promptLines.push('  5. Custom reference / booking number: paste VR-QUOTE-' + ref + ' into Booqable\'s');
    promptLines.push('       Number / Reference field (the exact field name varies by Booqable UI).');
    promptLines.push('  6. Add each line from ITEMS above with its quantity. For any line marked');
    promptLines.push('       [X-...] (cross-hire) — STOP and confirm with Max before adding;');
    promptLines.push('       cross-hire normally has separate handling.');
    promptLines.push('  7. CUSTOM ITEM lines need to be created in Booqable first OR added as a');
    promptLines.push('       one-off line — STOP and ask Max which.');
    promptLines.push('  8. Apply the discount: search Booqable\'s Discounts panel for the code above.');
    promptLines.push('       If no exact match exists, create a one-off discount with the per-line');
    promptLines.push('       percents shown in ITEMS. Do NOT apply a single flat percent across all');
    promptLines.push('       lines — consignment and cross-hire are routed differently.');
    promptLines.push('  9. Add the customer notes (if any) into the Booqable booking\'s notes field.');
    promptLines.push(' 10. SAVE the booking but DO NOT confirm or send the booking confirmation email.');
    promptLines.push('       Final step: take a screenshot of the saved booking, paste the Booqable');
    promptLines.push('       booking ID back to Max, then wait for Max to review before sending');
    promptLines.push('       the confirmation.');
    promptLines.push('');
    promptLines.push('If anything looks unusual (price mismatch, unfamiliar item, customer with no');
    promptLines.push('account row) — STOP and ask Max. Do not improvise.');

    // Render — courier font, paginated chunks. Pattern mirrors
    // buildReviewPdf so long item lists wrap onto follow-on pages.
    doc.setFont('courier', 'normal');
    doc.setFontSize(9);
    const lineH = 11.2;
    const PROMPT_PAD = 12;
    const PAGE_FLOOR = PH - 36;  // leave 36pt at the bottom
    const CONTENT_W = PW - 2 * M;

    let curChunk = [];
    const chunks = [];
    let cursor = cy + PROMPT_PAD + 4;
    const lineCapacityFirst = Math.floor((PAGE_FLOOR - cursor) / lineH);
    const lineCapacityNew = Math.floor((PAGE_FLOOR - (M + PROMPT_PAD + 4)) / lineH);
    let capacity = lineCapacityFirst;
    for (const ln of promptLines) {
      curChunk.push(ln);
      if (curChunk.length >= capacity) {
        chunks.push(curChunk);
        curChunk = [];
        capacity = lineCapacityNew;
      }
    }
    if (curChunk.length > 0) chunks.push(curChunk);

    let chunkStartY = cy;
    for (let pageIdx = 0; pageIdx < chunks.length; pageIdx++) {
      const chunk = chunks[pageIdx];
      if (pageIdx > 0) {
        doc.addPage();
        chunkStartY = M;
        // Repeat the header on each follow-on Comet page
        setText(PDF_BRAND.blue);
        doc.setFont('helvetica', 'bold'); doc.setFontSize(11);
        doc.text('INTERNAL — COMET IMPORT INSTRUCTIONS (cont.)', M, chunkStartY);
        chunkStartY += 6;
        setDraw(PDF_BRAND.blue); doc.setLineWidth(1.5);
        doc.line(M, chunkStartY, PW - M, chunkStartY);
        chunkStartY += 18;
      }
      const chunkH = chunk.length * lineH + PROMPT_PAD * 2 + 2;
      setFill([248, 249, 251]);
      setDraw(PDF_BRAND.line);
      doc.setLineWidth(0.3);
      doc.roundedRect(M, chunkStartY, CONTENT_W, chunkH, 6, 6, 'FD');
      setText(PDF_BRAND.ink);
      doc.setFont('courier', 'normal');
      doc.setFontSize(9);
      let linePy = chunkStartY + PROMPT_PAD + 4;
      for (const line of chunk) {
        doc.text(line, M + PROMPT_PAD, linePy);
        linePy += lineH;
      }
    }
  }

  doc.save(`valley-rentals-quote-${ref}.pdf`);
}

// ── Internal review PDF — for the chord-gated Booqable issue tool ──
// Layout: a working checklist for internal teams. Header with the
// per-action breakdown chips, then sections grouped by ACTION first
// (Remove / Edit / Add) so each owner can grab "their" pages and
// work through. Every row has a checkbox to tick off, a product
// thumbnail, a colour-coded action pill, item meta, and the note.
// Filename embeds the date so multiple exports don't clobber.
async function buildReviewPdf(flags) {
  if (!Array.isArray(flags) || flags.length === 0) return;
  const jsPDF = await loadJsPdf();

  // Preload product images in parallel (with per-image timeout) before
  // we start drawing — keeps the row layout sync and means a single
  // slow CDN response can't hold up the whole PDF. Cached by URL so
  // repeated items don't re-fetch.
  const imageCache = new Map();
  const loadWithTimeout = (url) => new Promise((resolve) => {
    if (!url) return resolve(null);
    if (imageCache.has(url)) return resolve(imageCache.get(url));
    let settled = false;
    const finish = (img) => { if (settled) return; settled = true; imageCache.set(url, img); resolve(img); };
    loadImageForPdf(url).then(finish).catch(() => finish(null));
    // 6s ceiling — covers slower connections and the fetch-fallback
    // path in loadImageForPdf (which can add a round-trip on edge-
    // case CDN responses) without holding up the whole export.
    setTimeout(() => finish(null), 6000);
  });
  const uniqueImgUrls = [...new Set(flags.map((f) => f.image).filter(Boolean))];
  const logoPromise = loadImageForPdf(`${window.location.origin}/rentals-logo-blue.png`).catch(() => null);
  await Promise.all([logoPromise, ...uniqueImgUrls.map(loadWithTimeout)]);
  const logo = await logoPromise;

  const doc = new jsPDF({ unit: 'mm', format: 'a4', orientation: 'portrait' });
  const PAGE_W = doc.internal.pageSize.getWidth();   // 210
  const PAGE_H = doc.internal.pageSize.getHeight();  // 297
  const M = 18;
  const CONTENT_W = PAGE_W - (M * 2);

  // RGB helpers
  const setText = (rgb) => doc.setTextColor(rgb[0], rgb[1], rgb[2]);
  const setDraw = (rgb) => doc.setDrawColor(rgb[0], rgb[1], rgb[2]);
  const setFill = (rgb) => doc.setFillColor(rgb[0], rgb[1], rgb[2]);

  // Assignee tagging — keyword heuristics on the note text route each
  // flag to one or more of Vince (image work), Pete (in-person checks)
  // and Claude (text/pricing/removal — anything actionable from a
  // browser session). Multiple matches → all relevant tags. Fallback:
  // Claude on any text-shaped task, removes always go to Claude.
  const VINCE_RE = /\b(image|images|photo|photos|picture|pic|render|renders|retouch|retouching|crop|rotate|swap.*(image|photo|pic)|replace.*(image|photo|pic)|product.*shot|background|logo|graphic|thumbnail|thumb)s?\b/i;
  const PETE_RE  = /\b(check|verify|sizing|dimension|dimensions|measure|measuring|fit(?:s|ting)?|inspect(?:ion)?|physical(?:ly)?|in[\s-]?person|view(?:ing)?|condition|test|stock|count|warehouse)\b/i;
  const CLAUDE_RE = /\b(text|copy|description|paragraph|spec|specs|specification|wording|typo|retag|tag|category|categor(?:y|ise|ize)|pricing|price|rate|day.?rate|remove|delete|drop|rewrite|fix.*copy)\b/i;
  const ASSIGNEES = {
    vince:  { label: 'VINCE',  color: [85, 65, 168],  short: 'V' },
    pete:   { label: 'PETE',   color: [16, 132, 138], short: 'P' },
    claude: { label: 'CLAUDE', color: [80, 86, 108],  short: 'C' },
  };
  const tagAssignees = (flag) => {
    const note = (flag.note || '').toLowerCase();
    const tags = new Set();
    if (VINCE_RE.test(note))  tags.add('vince');
    if (PETE_RE.test(note))   tags.add('pete');
    if (CLAUDE_RE.test(note)) tags.add('claude');
    // Type-driven hard rules layered on top of keyword routing:
    //   add    → always Vince (he needs to generate the product photo)
    //   remove → always Claude (Booqable deletion is browser-actionable)
    if (flag.type === 'add')    tags.add('vince');
    if (flag.type === 'remove') tags.add('claude');
    // Final fallback: pure-text flags with no keyword match go to Claude.
    if (tags.size === 0) tags.add('claude');
    return [...tags];
  };

  // Action palette — same colours used in the in-page UI. Glyphs use
  // Latin-1 characters (×) or ASCII so they render in jsPDF's built-in
  // Helvetica without embedding a Unicode font; on-screen we use the
  // richer ✕/✎ glyphs but they don't survive PDF font substitution.
  const ACTIONS = {
    remove: { label: 'REMOVE', verb: 'Remove listings',   glyph: '×',         color: [179, 58, 58],  fade: [251, 232, 232] }, // ×
    edit:   { label: 'EDIT',   verb: 'Edit descriptions', glyph: '',               color: [214, 153, 26], fade: [253, 244, 220] }, // colour-only
    add:    { label: 'ADD',    verb: 'Add to the shelf',  glyph: '+',              color: [27, 156, 95],  fade: [222, 248, 235] },
  };
  // Helper: glyph + label with a space only when glyph is set.
  const glyphLabel = (g, label) => (g ? `${g}  ${label}` : label);

  // Group flags by action; within each action sort by category then name
  // so workers can batch by category as they go.
  const grouped = { remove: [], edit: [], add: [] };
  for (const f of flags) {
    const t = ACTIONS[f.type] ? f.type : 'edit';
    grouped[t].push(f);
  }
  for (const t of Object.keys(grouped)) {
    grouped[t].sort((a, b) => (a.category || '').localeCompare(b.category || '') || (a.name || '').localeCompare(b.name || ''));
  }
  const counts = { remove: grouped.remove.length, edit: grouped.edit.length, add: grouped.add.length };
  const totalCount = flags.length;

  // ─── Cover header ──────────────────────────────────────────────────
  let y = M;
  if (logo) {
    const w = 28;
    const h = (logo.naturalHeight / logo.naturalWidth) * w;
    doc.addImage(logo, 'PNG', M, y, w, h);
  }
  setText(PDF_BRAND.ink3);
  doc.setFont('helvetica', 'normal');
  doc.setFontSize(9);
  const dateStr = new Date().toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
  doc.text(dateStr, PAGE_W - M, y + 6, { align: 'right' });
  doc.text('INTERNAL CHECKLIST', PAGE_W - M, y + 11, { align: 'right' });
  y += 22;

  setText(PDF_BRAND.ink);
  doc.setFont('helvetica', 'bold');
  doc.setFontSize(22);
  doc.text('Booqable issues to action', M, y);
  y += 8;
  setText(PDF_BRAND.ink2);
  doc.setFont('helvetica', 'normal');
  doc.setFontSize(10.5);
  doc.text(`${totalCount} item${totalCount === 1 ? '' : 's'} flagged · tick each row as it's actioned.`, M, y);
  y += 8;

  // Coloured action chips — at-a-glance breakdown of the workload.
  const chipH = 10;
  let cx = M;
  const drawChip = (action) => {
    const a = ACTIONS[action];
    const n = counts[action];
    if (n === 0) return;
    const label = a.glyph ? `${a.glyph}  ${n}  ${a.label}` : `${n}  ${a.label}`;
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(10);
    const textW = doc.getTextWidth(label);
    const padX = 6;
    const w = textW + padX * 2;
    setFill(a.color);
    doc.roundedRect(cx, y, w, chipH, 2.4, 2.4, 'F');
    setText([255, 255, 255]);
    doc.text(label, cx + padX, y + chipH / 2 + 1.6);
    cx += w + 5;
  };
  drawChip('remove'); drawChip('edit'); drawChip('add');
  y += chipH + 6;

  // "How to use" line — small grey hint above the divider.
  setText(PDF_BRAND.ink3);
  doc.setFont('helvetica', 'italic');
  doc.setFontSize(9);
  doc.text('Sections grouped by action so each team can batch their work. Tick the box top-left of each row when done.', M, y);
  y += 5;
  setDraw(PDF_BRAND.line);
  doc.setLineWidth(0.3);
  doc.line(M, y, PAGE_W - M, y);
  y += 8;

  // ─── Per-row drawing ───────────────────────────────────────────────
  // Layout per row (mm):
  //   M ── checkbox (5) ── image (22 wide, 16 tall) ── text (rest)
  //   Below: note wraps to full width past the checkbox indent.
  const CHECKBOX_X = M;
  const CHECKBOX_SIZE = 4.5;
  const IMG_X = M + 7;
  const IMG_W = 22;
  const IMG_H = 16;
  const TEXT_X = IMG_X + IMG_W + 5;
  const TEXT_W = PAGE_W - M - TEXT_X;
  const NOTE_X = M + 7;             // align with image, indent past checkbox
  const NOTE_W = PAGE_W - M - NOTE_X;
  const ROW_BOTTOM_PAD = 4;

  const drawItem = (row) => {
    const t = ACTIONS[row.type] ? row.type : 'edit';
    const a = ACTIONS[t];
    const noteText = row.note || (t === 'remove' ? '(no description — flagged for removal)' : '(no description)');
    const noteLines = doc.splitTextToSize(noteText, NOTE_W);

    // Claude-only rows (no Vince/Pete prerequisite) get a strike-
    // through + light-grey treatment — visual signal that Comet will
    // action it soon and no human needs to touch it first. Rows
    // tagged with Vince or Pete keep full-weight ink because the
    // human-owned step must happen before any Claude-y follow-up.
    const rowAssignees = tagAssignees(row);
    const isClaudeOnly = rowAssignees.length === 1 && rowAssignees[0] === 'claude';

    // Total height: image (16) OR text block, whichever's taller, plus
    // note lines + padding.
    const headerH = Math.max(IMG_H, 14);
    const noteH = noteLines.length * 4.6;
    const rowH = headerH + 3 + noteH + ROW_BOTTOM_PAD;
    if (y + rowH > PAGE_H - 18) { doc.addPage(); y = M; }

    const rowTop = y;

    // Checkbox — empty square outline aligned to image top.
    setDraw(PDF_BRAND.ink2);
    doc.setLineWidth(0.5);
    doc.rect(CHECKBOX_X, rowTop + 1.5, CHECKBOX_SIZE, CHECKBOX_SIZE);

    // Image thumb — real image or grey placeholder. 4:3 aspect, 22×16.
    const img = imageCache.get(row.image);
    setFill([240, 242, 246]);
    doc.roundedRect(IMG_X, rowTop, IMG_W, IMG_H, 1.5, 1.5, 'F');
    if (img) {
      try { doc.addImage(img, 'JPEG', IMG_X, rowTop, IMG_W, IMG_H); }
      catch (e) { /* leave placeholder grey if addImage fails */ }
    } else if (t === 'add') {
      // No image for add-listings — show a small "+" inside the placeholder.
      setText(a.color);
      doc.setFont('helvetica', 'bold');
      doc.setFontSize(18);
      doc.text('+', IMG_X + IMG_W / 2 - 2.4, rowTop + IMG_H / 2 + 3);
    }

    // Pill cluster — action + assignee pills on a single right-aligned
    // row at the top of the item. Previously the assignee pills sat
    // BELOW the action pill at the same Y as the meta line and could
    // collide with long meta text; co-locating them in one row keeps
    // everything in its own vertical band (pills at rowTop, name at
    // rowTop+4, meta at rowTop+9, note below).
    const assignees = rowAssignees;
    const pillH = 5;
    const pillY = rowTop;
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(8);
    // Build the pill list left-to-right: action first, then assignees
    // in the canonical order. This reads as "EDIT · CLAUDE · VINCE"
    // visually, which matches how the in-page UI groups them.
    const pillList = [
      { label: a.label, color: a.color },
      ...assignees.map((id) => ({ label: ASSIGNEES[id].label, color: ASSIGNEES[id].color })),
    ];
    const pillWidths = pillList.map((p) => doc.getTextWidth(p.label) + 7);
    const pillGap = 2;
    const clusterW = pillWidths.reduce((s, w) => s + w, 0) + pillGap * (pillList.length - 1);
    let cursorX = PAGE_W - M - clusterW;
    for (let i = 0; i < pillList.length; i++) {
      const p = pillList[i];
      const w = pillWidths[i];
      setFill(p.color);
      doc.roundedRect(cursorX, pillY, w, pillH, 1.2, 1.2, 'F');
      setText([255, 255, 255]);
      doc.text(p.label, cursorX + 3.5, pillY + 3.7);
      cursorX += w + pillGap;
    }

    // Item name — bold, ink (light-grey for Claude-only rows so they
    // visually recede). nameMaxW reserves space against the full pill
    // cluster so long product names can't run under it.
    setText(isClaudeOnly ? PDF_BRAND.ink3 : PDF_BRAND.ink);
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(12);
    const nameMaxW = TEXT_W - clusterW - 4;
    const nameLines = doc.splitTextToSize(row.name || '', nameMaxW);
    const nameY = rowTop + 4;
    doc.text(nameLines[0] || '', TEXT_X, nameY);
    if (isClaudeOnly && nameLines[0]) {
      // Strikethrough — draw a thin line through the visual midpoint
      // of the rendered text. Approximation since jsPDF doesn't
      // expose text bounding boxes; works well at 12pt helvetica.
      const nw = doc.getTextWidth(nameLines[0]);
      setDraw(PDF_BRAND.ink3);
      doc.setLineWidth(0.45);
      doc.line(TEXT_X, nameY - 1.1, TEXT_X + nw, nameY - 1.1);
    }

    // Meta line — slug · day-rate · ownership · category. Mono small.
    setText(PDF_BRAND.ink3);
    doc.setFont('courier', 'normal');
    doc.setFontSize(8.5);
    const metaBits = [
      row.slug && row.slug !== '(new)' ? row.slug : null,
      t === 'add' ? null : `£${Number(row.dayRate || 0).toFixed(0)}/day`,
      row.category && row.category !== '—' ? row.category.toLowerCase() : null,
      row.ownership === 'consignment' ? 'consignment'
        : row.ownership === 'cross-hire' ? 'cross-hire'
        : row.ownership === 'n/a' ? null
        : 'own kit',
    ].filter(Boolean);
    if (metaBits.length > 0) {
      doc.text(metaBits.join('  ·  '), TEXT_X, rowTop + 9);
    }

    // Note body — wraps under the image. Indent past the checkbox.
    const noteY = rowTop + headerH + 3;
    if (row.note) {
      setText(PDF_BRAND.ink);
      doc.setFont('helvetica', 'normal');
    } else {
      setText(PDF_BRAND.ink3);
      doc.setFont('helvetica', 'italic');
    }
    doc.setFontSize(10);
    for (let i = 0; i < noteLines.length; i++) {
      doc.text(noteLines[i], NOTE_X, noteY + i * 4.6);
    }

    y = rowTop + rowH;

    // Hairline divider below each item.
    setDraw(PDF_BRAND.line);
    doc.setLineWidth(0.15);
    doc.line(M, y - 1, PAGE_W - M, y - 1);
    y += 4;
  };

  // ─── Action sections ───────────────────────────────────────────────
  const ACTION_ORDER = ['remove', 'edit', 'add'];
  const drawActionBanner = (action) => {
    const a = ACTIONS[action];
    const n = counts[action];
    if (y + 16 > PAGE_H - 18) { doc.addPage(); y = M; }
    // Big coloured band across full width.
    setFill(a.fade);
    doc.rect(M, y, CONTENT_W, 11, 'F');
    // Coloured left strip + glyph badge.
    setFill(a.color);
    doc.rect(M, y, 3, 11, 'F');
    setText(a.color);
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(13);
    doc.text(glyphLabel(a.glyph, a.verb.toUpperCase()), M + 8, y + 7.4);
    setText(PDF_BRAND.ink3);
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(10);
    doc.text(`${n} item${n === 1 ? '' : 's'}`, PAGE_W - M - 3, y + 7.4, { align: 'right' });
    y += 14;
  };

  for (const action of ACTION_ORDER) {
    if (counts[action] === 0) continue;
    drawActionBanner(action);
    let lastCat = null;
    for (const row of grouped[action]) {
      const cat = (row.category || '—');
      // Light category subheading when the category changes within
      // a section — helps batch by category visually.
      if (cat !== lastCat) {
        if (y + 8 > PAGE_H - 18) { doc.addPage(); y = M; }
        setText(PDF_BRAND.ink3);
        doc.setFont('courier', 'bold');
        doc.setFontSize(8);
        doc.text(cat.toUpperCase(), M, y);
        y += 4;
        lastCat = cat;
      }
      drawItem(row);
    }
    y += 4;
  }

  // ─── Claude Comet instruction block ───────────────────────────────
  // Selectable plain-text prompt scoped to items tagged Claude, ready
  // to copy-paste into Claude Comet. Pre-tagged with the action so
  // Comet doesn't have to infer intent. Rendered as a monospace block
  // so the structure survives whatever wraps the user pastes into.
  const claudeRemoves = flags.filter((f) => (f.type === 'remove') && tagAssignees(f).includes('claude'));
  const claudeEdits   = flags.filter((f) => (f.type === 'edit')   && tagAssignees(f).includes('claude'));
  const claudeAdds    = flags.filter((f) => (f.type === 'add')    && tagAssignees(f).includes('claude'));
  const hasClaudeWork = claudeRemoves.length + claudeEdits.length + claudeAdds.length > 0;

  if (hasClaudeWork) {
    doc.addPage();
    y = M;

    // Header band.
    setFill([80, 86, 108]);
    doc.rect(M, y, CONTENT_W, 11, 'F');
    setText([255, 255, 255]);
    doc.setFont('helvetica', 'bold');
    doc.setFontSize(13);
    doc.text('FOR CLAUDE COMET', M + 5, y + 7.4);
    setText([220, 222, 228]);
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(9);
    doc.text('Copy the prompt below and paste into Comet.', PAGE_W - M - 3, y + 7.4, { align: 'right' });
    y += 16;

    setText(PDF_BRAND.ink2);
    doc.setFont('helvetica', 'italic');
    doc.setFontSize(9.5);
    const intro = 'Only items auto-tagged Claude are included — anything tagged Vince or Pete is being handled separately and should be left alone.';
    const introLines = doc.splitTextToSize(intro, CONTENT_W);
    for (const line of introLines) { doc.text(line, M, y); y += 4.6; }
    y += 4;

    // Mono prompt block, light-grey background so the user sees where
    // to highlight + copy. Plain text — no PDF form field needed; the
    // text is selectable in any viewer.
    //
    // CRITICAL: switch the active font to courier 9pt BEFORE any
    // splitTextToSize call below — that helper measures with the
    // currently-active font. If we wrap while helvetica-italic is
    // still active (from the intro line above), lines that fit in
    // the italic width overflow the right edge once rendered in
    // courier (which is ~50% wider per glyph). The note wrappers
    // ran into exactly this regression and pushed text past the rect.
    doc.setFont('courier', 'normal');
    doc.setFontSize(9);
    const PROMPT_PAD = 6;
    const promptLines = [];
    promptLines.push('You are the Valley Rentals Booqable maintenance assistant. Max');
    promptLines.push('has already authenticated on https://valley-rentals.booqable.com');
    promptLines.push('admin. Work through the actions below exactly. After each');
    promptLines.push('action, report "Completed: <item name>".');
    promptLines.push('');
    if (claudeRemoves.length > 0) {
      promptLines.push(`REMOVE THESE LISTINGS (delete from Booqable) — ${claudeRemoves.length}:`);
      claudeRemoves.forEach((f, i) => {
        promptLines.push(`  ${i + 1}. ${f.name}  (slug: ${f.slug})`);
        if (f.note) {
          const noteWrapped = doc.splitTextToSize(`     Reason: ${f.note}`, CONTENT_W - PROMPT_PAD * 2 - 4);
          for (const l of noteWrapped) promptLines.push(l);
        }
      });
      promptLines.push('');
    }
    if (claudeEdits.length > 0) {
      promptLines.push(`EDIT THESE DESCRIPTIONS — ${claudeEdits.length}:`);
      claudeEdits.forEach((f, i) => {
        promptLines.push(`  ${i + 1}. ${f.name}  (slug: ${f.slug})`);
        if (f.note) {
          const noteWrapped = doc.splitTextToSize(`     Change: ${f.note}`, CONTENT_W - PROMPT_PAD * 2 - 4);
          for (const l of noteWrapped) promptLines.push(l);
        }
      });
      promptLines.push('');
    }
    if (claudeAdds.length > 0) {
      promptLines.push(`ADD THESE LISTINGS (create new product) — ${claudeAdds.length}:`);
      claudeAdds.forEach((f, i) => {
        promptLines.push(`  ${i + 1}. ${f.name}  (category: ${f.category})`);
        if (f.note) {
          const noteWrapped = doc.splitTextToSize(`     Notes: ${f.note}`, CONTENT_W - PROMPT_PAD * 2 - 4);
          for (const l of noteWrapped) promptLines.push(l);
        }
      });
      promptLines.push('');
    }
    promptLines.push('When all done, report a final summary: counts of items removed,');
    promptLines.push('items edited, items added, and any item you could not complete');
    promptLines.push('plus the reason. Do NOT action items tagged Vince or Pete.');

    // Mono prompt block — paginated by splitting promptLines into
    // per-page chunks first, then rendering each chunk's rect + text
    // in order. The previous attempt drew the rect AFTER the text
    // on the same page which hid all the prompt content under a
    // grey panel; this two-pass flow makes the visual deterministic.
    doc.setFont('courier', 'normal');
    doc.setFontSize(9);
    const lineH = 4.4;
    // Reserve 14mm above the footer (which sits at PAGE_H - 10) so
    // bottom descenders + any final line keep clear.
    const PAGE_FLOOR = PAGE_H - 24;

    // Group lines per page based on available vertical room.
    const chunks = [];
    let curChunk = [];
    let cursor = y + PROMPT_PAD + 3;
    const lineCapacityFirst = Math.floor((PAGE_FLOOR - cursor) / lineH);
    const lineCapacityNew = Math.floor((PAGE_FLOOR - (M + PROMPT_PAD + 3)) / lineH);
    let capacity = lineCapacityFirst;
    for (let i = 0; i < promptLines.length; i++) {
      curChunk.push(promptLines[i]);
      if (curChunk.length >= capacity) {
        chunks.push(curChunk);
        curChunk = [];
        capacity = lineCapacityNew;
      }
    }
    if (curChunk.length > 0) chunks.push(curChunk);

    // Render each chunk: rect first (background), then text on top.
    let chunkStartY = y;
    for (let pageIdx = 0; pageIdx < chunks.length; pageIdx++) {
      const chunk = chunks[pageIdx];
      if (pageIdx > 0) {
        doc.addPage();
        chunkStartY = M;
      }
      const chunkH = chunk.length * lineH + PROMPT_PAD * 2 + 2;
      setFill([248, 249, 251]);
      setDraw(PDF_BRAND.line);
      doc.setLineWidth(0.3);
      doc.roundedRect(M, chunkStartY, CONTENT_W, chunkH, 3, 3, 'FD');
      setText(PDF_BRAND.ink);
      doc.setFont('courier', 'normal');
      doc.setFontSize(9);
      let linePy = chunkStartY + PROMPT_PAD + 3;
      for (const line of chunk) {
        doc.text(line, M + PROMPT_PAD, linePy);
        linePy += lineH;
      }
      y = chunkStartY + chunkH;
    }
  }

  // ─── Footer on every page ─────────────────────────────────────────
  const total = doc.internal.getNumberOfPages();
  for (let p = 1; p <= total; p++) {
    doc.setPage(p);
    setText(PDF_BRAND.ink3);
    doc.setFont('helvetica', 'normal');
    doc.setFontSize(8.5);
    doc.text('Valley Rentals · Internal review checklist · Not for circulation', M, PAGE_H - 10);
    doc.text(`${p} / ${total}`, PAGE_W - M, PAGE_H - 10, { align: 'right' });
  }

  const now = new Date();
  const yyyy = now.getFullYear();
  const mm = String(now.getMonth() + 1).padStart(2, '0');
  const dd = String(now.getDate()).padStart(2, '0');
  doc.save(`valley-rentals-review-${yyyy}${mm}${dd}.pdf`);
}

// Compact deterministic reference based on the current date + a
// small pseudo-random tail. Doesn't have to be globally unique —
// just unique enough to disambiguate two quotes built minutes apart.
function makeQuoteRef() {
  const d = new Date();
  const yy = String(d.getFullYear()).slice(-2);
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const dd = String(d.getDate()).padStart(2, '0');
  const tail = Math.floor(Math.random() * 900 + 100);
  return `VR-${yy}${mm}${dd}${tail}`;
}

// ---------------------------------------------------------------------
// Internal-order accounts — surfaced in the cart's "Internal Order"
// mode (Cmd+/ → Staff Tools → Internal order mode). Each entry seeds
// the Comet prompt's customer-match step. Edit `email` here when you
// need to add a new internal account or rotate addresses.
// ---------------------------------------------------------------------
const INTERNAL_ORDER_ACCOUNTS = [
  { id: 'max',     name: 'Max Paterson',  email: 'max@valley.film'  },
  { id: 'pete',    name: 'Peter Dundas',  email: 'pete@valley.film' },
  { id: 'valley',  name: 'Valley Films',  email: 'info@valley.film' },
];

// Build a single Comet automation prompt. Output is a self-contained
// instruction string the staffer can paste into Claude Comet — Comet
// then drives Booqable's admin UI to (a) match the customer by email,
// (b) create a new order with the right dates, (c) add each line item,
// (d) apply WETHIRE for the 30/15/0 discount split, (e) save as draft.
// Kept verbose so Comet doesn't have to infer steps.
function buildCometPrompt({ account, items, details }) {
  if (!account) return '';
  const lines = [];
  lines.push(`Create a new INTERNAL ORDER in Booqable for ${account.name}.`);
  lines.push('');
  lines.push('Open Booqable in a new tab: https://valley-rentals.booqable.com');
  lines.push('');
  lines.push('STEP 1 — Open a new order');
  lines.push('  Click "+ New booking" in the top-left of the admin sidebar.');
  lines.push('');
  lines.push('STEP 2 — Customer');
  lines.push(`  In the customer search field, type: ${account.email}`);
  lines.push(`  Select the matching existing customer ("${account.name}").`);
  lines.push(`  If no exact match is shown, instead create a new customer with:`);
  lines.push(`    Name:  ${account.name}`);
  lines.push(`    Email: ${account.email}`);
  lines.push('');
  lines.push('STEP 3 — Dates');
  const fmtD = (s) => {
    if (!s) return '<set the date here>';
    return s;
  };
  lines.push(`  Pickup (start):  ${fmtD(details && details.from)}`);
  lines.push(`  Return (end):    ${fmtD(details && details.to)}`);
  lines.push('  Apply these dates to the booking.');
  lines.push('');
  lines.push('STEP 4 — Line items');
  lines.push('  For each item below, search Booqable\'s product picker by name and add it');
  lines.push('  to the booking at the quantity shown. Match the product whose Booqable name');
  lines.push('  is closest to the listed name.');
  lines.push('');
  for (const it of items) {
    const qty = it.qty || 1;
    const name = it.name || it.id;
    lines.push(`    - ${name}  ×  ${qty}`);
  }
  lines.push('');
  lines.push('STEP 5 — Apply discount');
  lines.push('  In the coupon / discount code field, enter: WETHIRE30');
  lines.push('  Apply. Booqable should subtract 30% from VF-owned lines automatically.');
  lines.push('  Consignment lines will receive ~15%, cross-hire lines will not be discounted');
  lines.push('  (per the standard WETHIRE split). Verify the order subtotal looks sensible.');
  lines.push('');
  lines.push('STEP 6 — Save as DRAFT (do not confirm)');
  lines.push('  Click Save. Do NOT click Confirm — leave the booking in Draft so the rentals');
  lines.push('  team can review and finalise.');
  lines.push('');
  lines.push('STEP 7 — Report back');
  lines.push('  When the draft order is saved, reply with the Booqable order number');
  lines.push('  (e.g. "Created #00432") so the team can find it.');
  return lines.join('\n');
}

// Copies a sharable URL for the current cart to the clipboard. Lives in
// the cart drawer header (left of the close X) so it's reachable from
// any cart state. URL form: `/rentals/equipment?cart=<slug:qty,…>` —
// when opened, the EquipmentPage hydrates the cart from the param and
// strips it so refresh doesn't re-clobber.
function ShareCartButton({ items }) {
  const [copied, setCopied] = useStateRentals(false);
  const onClick = async () => {
    const encoded = encodeCartForShareUrl(items);
    if (!encoded) return;
    const origin = typeof window !== 'undefined' ? window.location.origin : 'https://valley.film';
    const shareUrl = `${origin}/rentals/equipment?cart=${encoded}`;
    try {
      await navigator.clipboard.writeText(shareUrl);
      setCopied(true);
      setTimeout(() => setCopied(false), 1800);
    } catch (e) {
      // Fallback for non-secure contexts / older browsers
      try {
        const ta = document.createElement('textarea');
        ta.value = shareUrl;
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
        setCopied(true);
        setTimeout(() => setCopied(false), 1800);
      } catch {}
    }
  };
  return (
    <button
      type="button"
      className={`rcart-share ${copied ? 'is-copied' : ''}`}
      onClick={onClick}
      title="Copy a share link for this cart">
      {copied ? '✓ Link copied' : 'Share'}
    </button>
  );
}

function CartDrawer({ open, onClose }) {
  const {
    items, setQty, remove, clear, subtotal, add,
    customOrder, setCustomOrder, setDiscountOverride,
    setInternalNote, bulkSetDiscount,
  } = useCart();
  const [details, setDetails] = useStateRentals(CART_DEFAULT_DETAILS);
  // Sticky "user deliberately cleared the coupon code" flag. Once set,
  // the email-lookup auto-fill (which seeds Code from the customer's
  // Notion record) won't re-populate. Re-fires only when the user
  // types into the Code input themselves — typing is taken as
  // explicit intent to re-engage with discounts.
  const userClearedCodeRef = React.useRef(false);
  const clearCouponCode = React.useCallback(() => {
    userClearedCodeRef.current = true;
    setDetails((d) => ({ ...d, code: '' }));
  }, []);
  const [sending, setSending] = useStateRentals(false);
  const [downloading, setDownloading] = useStateRentals(false);
  const [sent, setSent] = useStateRentals(false);
  const [error, setError] = useStateRentals('');

  // Quote staff mode — gates the cart's staff-only features (custom
  // item form, drag-to-reorder handles, per-line discount editor,
  // custom-order reset banner). Now decoupled from Equipment
  // Revision Mode (which still controls the per-card flag/edit/add
  // workflow on /rentals/equipment) so staff can use one without
  // the other. Toggled from the shortcuts menu's "Custom quote
  // mode" entry under Staff Tools.
  const [staffMode, setStaffMode] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-quote-staff-mode') === '1'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    // Flip our local mirror — re-reading localStorage on the event
    // races the shortcuts menu's persistence (its localStorage write
    // and our event listener can fire in either order). Pure toggle
    // stays in step with the menu's switch state regardless.
    const onToggle = () => setStaffMode((v) => !v);
    window.addEventListener('vf-rentals-quote-staff-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-quote-staff-toggle', onToggle);
  }, []);

  // PDF Comet mode — when on, the cart's Download-quote PDF appends a
  // page of Claude-Comet-readable instructions for importing the
  // quote into Booqable. Independent of staffMode (a user might want
  // staff UI but a clean PDF, or vice versa) but only meaningful when
  // staffMode is also on (the toggle's only surfaced under Staff
  // Tools). Same race-safe in-process toggle as staffMode above.
  const [pdfCometMode, setPdfCometMode] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-pdf-comet-mode') === '1'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    const onToggle = () => setPdfCometMode((v) => !v);
    window.addEventListener('vf-rentals-pdf-comet-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-pdf-comet-toggle', onToggle);
  }, []);

  // Internal-order mode — when on, the cart becomes an Internal Order
  // builder. Header swaps to "Internal Order", customer fields hide, an
  // account picker appears (Max Paterson / Peter Dundas / Valley Films),
  // and the two CTAs collapse into a single "Generate Comet instruction"
  // button that opens a modal with the prompt + a Copy button. The
  // generated prompt tells Comet to import the order into Booqable with
  // the WETHIRE coupon applied (30% off owned, 15% consignment, 0%
  // cross-hire — matches the existing coupon router).
  const [internalOrderMode, setInternalOrderMode] = useStateRentals(() => {
    if (typeof window === 'undefined') return false;
    try { return localStorage.getItem('vf-rentals-internal-order-mode') === '1'; } catch (e) { return false; }
  });
  React.useEffect(() => {
    const onToggle = () => setInternalOrderMode((v) => !v);
    window.addEventListener('vf-rentals-internal-order-mode-toggle', onToggle);
    return () => window.removeEventListener('vf-rentals-internal-order-mode-toggle', onToggle);
  }, []);
  // Selected internal-order account. Persists to localStorage so a
  // staffer building a series of internal orders doesn't have to
  // re-pick each time.
  const [internalOrderAccountId, setInternalOrderAccountId] = useStateRentals(() => {
    if (typeof window === 'undefined') return 'max';
    try { return localStorage.getItem('vf-rentals-internal-order-account') || 'max'; } catch (e) { return 'max'; }
  });
  React.useEffect(() => {
    try { localStorage.setItem('vf-rentals-internal-order-account', internalOrderAccountId); } catch (e) { /* */ }
  }, [internalOrderAccountId]);
  // Comet-instruction modal — opened by the "Generate Comet instruction"
  // CTA, holds the full prompt + a Copy button.
  const [cometModalText, setCometModalText] = useStateRentals('');
  const [cometCopied, setCometCopied] = useStateRentals(false);

  // Adds a custom (off-catalog) line item to the cart. Public side:
  // dayRate=0 → renders as "Quote on request" and the admin email
  // flags it for manual pricing; ownership defaults to 'custom' so
  // the discount router skips coupon math (the sentinel "we don't
  // know what this is yet"). Staff side: dayRate + ownership are
  // both staff-provided, so the line participates in cart totals,
  // PDF math, AND coupon routing (an owned custom item gets the
  // full %, consignment gets half, cross-hire gets zero — same
  // routing as catalog items).
  const addCustomItem = React.useCallback(({ name, notes, dayRate, qty, ownership }) => {
    const id = `_custom_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
    const validOwnership = ['owned', 'consignment', 'cross-hire'].includes(ownership)
      ? ownership
      : 'custom';
    add({
      id,
      name: String(name || '').trim() || 'Custom item',
      notes: String(notes || '').trim(),
      dayRate: Number(dayRate || 0),
      qty: Math.max(1, parseInt(qty || 1, 10)),
      image: '',
      ownership: validOwnership,
      isCustom: true,
    });
  }, [add]);
  // Account lookup state — populated from /api/rentals/verify-email
  // once the email field becomes a valid format. Null = haven't asked
  // yet; { found: false } = email entered but not in the DB; the
  // shape with verified/name/photo comes back when a match is found.
  const [account, setAccount] = useStateRentals(null);
  const [accountLoading, setAccountLoading] = useStateRentals(false);
  // When the user clicks Send / Download without a verified account
  // we defer the action behind a confirmation popup. Set to 'send' or
  // 'download' to show it; cleared by Continue or Cancel.
  const [verifyPromptAction, setVerifyPromptAction] = useStateRentals(null);

  // Reset sent-state when the drawer is closed so re-opening returns
  // to the form. Persist `details` (incl. email) across opens — a
  // user who closes mid-flow doesn't lose what they typed.
  React.useEffect(() => {
    if (!open) {
      const t = setTimeout(() => { setSent(false); setError(''); }, 320);
      return () => clearTimeout(t);
    }
    return undefined;
  }, [open]);

  // Escape closes; lock body scroll while open.
  React.useEffect(() => {
    if (!open) return undefined;
    const esc = (e) => { if (e.key === 'Escape') onClose(); };
    window.addEventListener('keydown', esc);
    document.body.style.overflow = 'hidden';
    return () => { window.removeEventListener('keydown', esc); document.body.style.overflow = ''; };
  }, [open, onClose]);

  const setField = (k) => (e) => {
    if (k === 'code') {
      // User typing into Code is treated as explicit intent — clear
      // the cleared-flag so the email-lookup auto-fill is welcome on
      // future re-checks (if they then clear AGAIN, the × button
      // re-sets the flag).
      userClearedCodeRef.current = false;
    }
    setDetails({ ...details, [k]: e.target.value });
  };

  const emailValid = /\S+@\S+\.\S+/.test(details.email || '');
  const datesValid = Boolean(details.pickupDate && details.returnDate && details.pickupTime && details.returnTime);

  // Debounced lookup against the Rental Accounts DB. Fires 500ms
  // after the user stops typing a valid-looking email. A request-id
  // guards against out-of-order responses if the user keeps typing
  // mid-flight: only the latest call's result is allowed to update
  // state. On any error we just clear the account (cart still works,
  // user just doesn't see the verified badge).
  const lookupSeq = React.useRef(0);
  React.useEffect(() => {
    if (!emailValid) { setAccount(null); return undefined; }
    const mySeq = ++lookupSeq.current;
    setAccountLoading(true);
    const t = setTimeout(async () => {
      try {
        const r = await fetch('/api/rentals/verify-email', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ email: details.email }),
        });
        const data = await r.json().catch(() => ({ found: false }));
        if (mySeq === lookupSeq.current) {
          setAccount(data);
          // Auto-fill the cart's Code field from the account's stored
          // coupon (set per-customer in the Notion Rental Contacts
          // DB). Two gates: (1) user hasn't typed anything of their
          // own (typing wins), (2) user hasn't deliberately cleared
          // a previously-applied code via the × clear button (that
          // sticks until they type something fresh).
          if (data && data.couponCode && !userClearedCodeRef.current) {
            setDetails((d) => (d.code && d.code.trim() ? d : { ...d, code: data.couponCode }));
          }
        }
      } catch {
        if (mySeq === lookupSeq.current) setAccount({ found: false });
      } finally {
        if (mySeq === lookupSeq.current) setAccountLoading(false);
      }
    }, 500);
    return () => clearTimeout(t);
  }, [details.email, emailValid]);

  // Dates auto-collapse to a single-line summary once all four
  // fields are valid. The user can re-expand via the inline Edit
  // button. While they're actively editing (invalid dates), the
  // picker stays open.
  const [datesExpanded, setDatesExpanded] = useStateRentals(true);
  React.useEffect(() => {
    if (datesValid) setDatesExpanded(false);
    else setDatesExpanded(true);
  }, [datesValid]);
  // Click-away re-collapse: when the user explicitly hits Edit on
  // already-valid dates (datesValid stays true so the useEffect above
  // doesn't fire), we still want the picker to collapse the moment
  // they click anywhere else in the cart. mousedown beats the cart's
  // own click handlers so we collapse first, then whatever they
  // clicked runs normally.
  const datesBlockRef = React.useRef(null);
  React.useEffect(() => {
    if (!datesExpanded) return undefined;
    const handler = (e) => {
      if (!datesBlockRef.current) return;
      if (datesBlockRef.current.contains(e.target)) return;
      if (datesValid) setDatesExpanded(false);
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [datesExpanded, datesValid]);

  // Single canActSubmit predicate covers all three CTAs: items + dates
  // + email are the floor. The Download CTA actually only NEEDS items
  // + dates (the PDF can render without an email), but we still want
  // the email because that's how we capture the lead. So the same
  // predicate gates both buttons.
  const canAct = items.length > 0 && datesValid && emailValid;

  // Items in the order the staff (or auto-sort) has them on screen.
  // The PDF + admin email both render from this so what gets delivered
  // matches what the staff is looking at — important when they've
  // manually reordered or curated the quote.
  const displayItems = React.useMemo(
    () => resolveCartDisplay(items, customOrder).flat,
    [items, customOrder]
  );

  const buildPayload = () => ({
    items: displayItems.map((i) => ({
      id: i.id, sku: i.sku, name: i.name, qty: i.qty, dayRate: i.dayRate,
      // ownership + isCustom let the admin email re-run the coupon
      // routing server-side and render the same discount column the
      // cart UI shows. discountOverride wins over auto-routing on the
      // server just like it does in the client. internalNote is the
      // staff-only annotation that surfaces only in the email's Comet
      // appendix — never customer-facing.
      ownership: i.ownership || 'owned',
      isCustom: !!i.isCustom,
      ...(i.internalNote ? { internalNote: i.internalNote } : {}),
      ...(typeof i.discountOverride === 'number' ? { discountOverride: i.discountOverride } : {}),
    })),
    details,
    subtotalEx: subtotal,
    submittedAt: new Date().toISOString(),
    account: account?.found ? { name: account.name, verified: account.verified, status: account.status } : null,
  });

  const isVerified = Boolean(account && account.found && account.verified);

  const performSend = async () => {
    setSending(true); setError('');
    try {
      const payload = buildPayload();
      const r = await fetch('/api/rentals/request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!r.ok) {
        const data = await r.json().catch(() => ({}));
        throw new Error(data.error || `Request failed (${r.status})`);
      }
      setSent(true);
    } catch (err) {
      setError("Couldn't send right now — try again, or email rentals@valley.film directly.");
    } finally {
      setSending(false);
    }
  };
  const performDownload = async () => {
    setDownloading(true); setError('');
    try {
      const ref = makeQuoteRef();
      await buildQuotePdf({ items: displayItems, subtotal, details, ref, account, cometMode: pdfCometMode });
    } catch (err) {
      setError('Couldn\'t build the PDF — try again, or email rentals@valley.film.');
    } finally {
      setDownloading(false);
    }
  };
  // Public handlers gate on verified-account status. Unverified users
  // see the confirmation popup once; clicking Continue runs the real
  // action via verifyPromptContinue.
  const doSend = () => {
    if (!canAct || sending) return;
    if (!isVerified) { setVerifyPromptAction('send'); return; }
    performSend();
  };
  const doDownload = () => {
    if (!canAct || downloading) return;
    if (!isVerified) { setVerifyPromptAction('download'); return; }
    performDownload();
  };
  const verifyPromptContinue = () => {
    const action = verifyPromptAction;
    setVerifyPromptAction(null);
    if (action === 'send') performSend();
    else if (action === 'download') performDownload();
  };

  if (!open) return null;

  const hint = !emailValid ? 'Add your email above'
             : items.length === 0 ? 'Add an item to your cart'
             : !datesValid ? 'Pick collection & return dates'
             : '';

  return (
    <React.Fragment>
    <div className="rcart-scrim" onClick={onClose}>
      <aside className="rcart-drawer" onClick={(e) => e.stopPropagation()} aria-label="Request cart">
        <header className={`rcart-head ${internalOrderMode ? 'is-internal' : ''}`}>
          <div className="rcart-head-titleblock">
            <div className="rcart-eyebrow">{internalOrderMode ? 'Staff · for Booqable import' : 'Your request'}</div>
            <h2>{sent ? 'On its way.' : internalOrderMode ? 'Internal Order' : 'Cart'}</h2>
          </div>
          {!sent && !internalOrderMode && account?.found && account.verified && (
            <AccountBadge account={account} />)}
          {!sent && items.length > 0 && (
            <ShareCartButton items={items} />)}
          <button type="button" className="rcart-close" onClick={onClose} aria-label="Close">×</button>
        </header>

        <div className="rcart-body">
          {sent ? (
            <div className="rcart-sent">
              <div className="rcart-sent-tick" aria-hidden="true">✓</div>
              <p>Thanks — we've got your request. We'll reply to <strong>{details.email}</strong> within one working day with a firm quote and availability.</p>
              <button type="button" className="rcart-cta" onClick={() => { clear(); onClose(); }}>Done</button>
            </div>) : (
            <React.Fragment>
              {internalOrderMode && (
                <div className="rcart-block rcart-internal-pick">
                  <h4 className="rcart-section-h">Internal account</h4>
                  <div className="rcart-internal-pills" role="radiogroup" aria-label="Internal account">
                    {INTERNAL_ORDER_ACCOUNTS.map((acc) => (
                      <button
                        key={acc.id}
                        type="button"
                        role="radio"
                        aria-checked={internalOrderAccountId === acc.id}
                        className={`rcart-internal-pill ${internalOrderAccountId === acc.id ? 'is-on' : ''}`}
                        onClick={() => setInternalOrderAccountId(acc.id)}>
                        <span className="rcart-internal-pill-name">{acc.name}</span>
                        <span className="rcart-internal-pill-email">{acc.email}</span>
                      </button>))}
                  </div>
                  <p className="rcart-internal-hint">
                    Comet will match this customer in Booqable by email. Dates come from the form
                    below; pricing follows the catalog with <code>WETHIRE30</code> applied.
                  </p>
                </div>)}
              {/* Email — first, so the DB lookup runs ASAP and the
                  verified badge surfaces in the header before the
                  rest of the form is touched. Hidden in internal-order
                  mode — internal orders match by the picker above. */}
              {!internalOrderMode && (
              <div className="rcart-block">
                <h4 className="rcart-section-h">Your email</h4>
                {staffMode ? (
                  // Staff mode swaps the plain email input for a
                  // typeahead — type a name / company / partial email,
                  // pick the matching customer from the dropdown,
                  // their email fills in + triggers the existing
                  // verify-email lookup.
                  <CustomerQuickPick
                    email={details.email}
                    onChange={setField('email')}
                    onSelect={(picked) => setDetails((d) => ({ ...d, email: picked.email }))} />
                ) : (
                  <input
                    type="email"
                    className="rcart-email-input rcart-email-input-block"
                    value={details.email}
                    onChange={setField('email')}
                    placeholder="you@studio.com"
                    autoComplete="email" />
                )}
                <AccountInlineHint
                  email={details.email}
                  emailValid={emailValid}
                  loading={accountLoading}
                  account={account} />
              </div>)}

              {/* Dates — sits at the top of the form fields per the
                  May 2026 cart reshape. Auto-collapses to a single-
                  line summary once all 4 fields (date+time × pickup/
                  return) are valid; "Edit" re-expands the picker.
                  Click-away outside this block also collapses (covers
                  the "Edit > no change > click elsewhere" path the
                  validity-driven useEffect can't see). */}
              <div className="rcart-block" ref={datesBlockRef}>
                <div className="rcart-block-head">
                  <h4 className="rcart-section-h">Hire Period</h4>
                  {datesExpanded ? (
                    <button
                      type="button"
                      className="rcart-mini-edit"
                      onClick={() => setDatesExpanded(false)}
                      disabled={!datesValid}
                      title={!datesValid ? 'Fill all four date & time fields first' : 'Collapse to summary'}>
                      ✓ Done
                    </button>
                  ) : (
                    <button type="button" className="rcart-mini-edit" onClick={() => setDatesExpanded(true)}>
                      Edit
                    </button>)}
                </div>
                {datesExpanded
                  ? <CartStepDates details={details} setField={setField} />
                  : <DatesCollapsedSummary details={details} />}
              </div>

              {/* Location — collection vs delivery, per-leg so mix-and-
                  match is supported. Renders before Order so the user
                  sees how their pick reshapes the line items below.
                  Collapses to a single-line summary like Hire Period
                  once both legs are set. */}
              <CartLocationBlock
                details={details}
                setField={setField}
                setDetails={setDetails}
                items={items} />

              {/* Order — items + synthetic line items (after-hours,
                  delivery legs). "Order" subsumes the earlier "Your kit"
                  label since after-hours + delivery aren't strictly kit.

                  After-hours and delivery fees are derived from `details`
                  (times + leg modes) not from items, so they survive a
                  Clear. The block stays visible whenever items OR any
                  synth fee is present — Clear empties the kit but the
                  surcharges keep showing with a hint that the only way
                  to remove them is to edit Hire Period / Location. */}
              {(() => {
                const ahCount = afterHoursEventCount(details);
                const pickupHrs = roundToBilledHours(details.pickupTravelMin);
                const returnHrs = roundToBilledHours(details.returnTravelMin);
                const pickupFee = deliveryLegPrice(details.pickupMode, details.pickupZone, pickupHrs);
                const returnFee = deliveryLegPrice(details.returnMode, details.returnZone, returnHrs);
                const hasSynth = ahCount > 0 || pickupFee > 0 || returnFee > 0;
                // Staff in Equipment Revision Mode see the Order block
                // even with an empty cart — they need access to the
                // "+ Add custom item" trigger to spin up off-catalog
                // quotes from scratch.
                if (items.length === 0 && !hasSynth && !staffMode) {
                  return (
                    <div className="rcart-empty">
                      <p>Your request is empty.</p>
                      <p className="rcart-fine">Add items from the equipment page — every line is a day-rate × quantity quote.</p>
                    </div>);
                }
                return (
                  <div className="rcart-block">
                    <div className="rcart-block-head">
                      <h4 className="rcart-section-h">Order</h4>
                      {items.length > 0 && (
                        <button
                          type="button"
                          className="rcart-mini-edit rcart-mini-edit-destructive"
                          onClick={clear}
                          aria-label="Clear cart">
                          Clear
                        </button>)}
                    </div>
                    <CartStepItems
                      items={items}
                      setQty={setQty}
                      remove={remove}
                      subtotal={subtotal}
                      details={details}
                      setField={setField}
                      addCustomItem={addCustomItem}
                      staffMode={staffMode}
                      customOrder={customOrder}
                      setCustomOrder={setCustomOrder}
                      setDiscountOverride={setDiscountOverride}
                      setInternalNote={setInternalNote}
                      bulkSetDiscount={bulkSetDiscount} />
                  </div>);
              })()}

              {/* Code + Notes — both optional. Merged into a hover-expand
                  pill pair so they don't dominate the request form;
                  clicking or focusing either expands it inline. */}
              {items.length > 0 && (
                <div className="rcart-block">
                  <CartMetaPills details={details} setField={setField} onClearCode={clearCouponCode} />
                </div>)}
            </React.Fragment>)}
        </div>

        {!sent && (
          <footer className="rcart-foot rcart-foot-actions">
            {hint && !internalOrderMode && (
              <span className="rcart-foot-hint">{hint}</span>)}
            <div className="rcart-foot-spacer" />
            {internalOrderMode ? (
              <button
                type="button"
                className="rcart-cta"
                disabled={items.length === 0}
                onClick={() => {
                  const acc = INTERNAL_ORDER_ACCOUNTS.find((a) => a.id === internalOrderAccountId) || INTERNAL_ORDER_ACCOUNTS[0];
                  setCometModalText(buildCometPrompt({ account: acc, items: displayItems, details }));
                  setCometCopied(false);
                }}>
                ⌘ Generate Comet instruction →
              </button>
            ) : (<>
              <button
                type="button"
                className="rcart-cta rcart-cta-secondary"
                disabled={!canAct || downloading}
                onClick={doDownload}>
                {downloading ? 'Building…' : <>↓ Download quote</>}
              </button>
              <button
                type="button"
                className="rcart-cta"
                disabled={!canAct || sending}
                onClick={doSend}>
                {sending ? 'Sending…' : <>Send request →</>}
              </button>
            </>)}
            {error && <p className="rcart-error">{error}</p>}
          </footer>)}
        {cometModalText && (
          <div className="rcart-comet-modal-scrim" onClick={() => setCometModalText('')}>
            <div className="rcart-comet-modal" onClick={(e) => e.stopPropagation()} role="dialog" aria-label="Comet instruction">
              <header className="rcart-comet-modal-head">
                <div>
                  <div className="rcart-comet-modal-eyebrow">Paste into Claude Comet</div>
                  <h3>Internal order — Comet instruction</h3>
                </div>
                <button type="button" className="rcart-comet-modal-close" onClick={() => setCometModalText('')} aria-label="Close">×</button>
              </header>
              <pre className="rcart-comet-modal-body">{cometModalText}</pre>
              <div className="rcart-comet-modal-foot">
                <button
                  type="button"
                  className={`rcart-cta ${cometCopied ? 'is-copied' : ''}`}
                  onClick={async () => {
                    try {
                      await navigator.clipboard.writeText(cometModalText);
                      setCometCopied(true);
                      setTimeout(() => setCometCopied(false), 1800);
                    } catch (e) {
                      // Fallback for older browsers / non-secure contexts
                      const ta = document.createElement('textarea');
                      ta.value = cometModalText;
                      document.body.appendChild(ta);
                      ta.select();
                      try { document.execCommand('copy'); setCometCopied(true); setTimeout(() => setCometCopied(false), 1800); } catch {}
                      document.body.removeChild(ta);
                    }
                  }}>
                  {cometCopied ? '✓ Copied' : 'Copy to clipboard'}
                </button>
              </div>
            </div>
          </div>)}
      </aside>
    </div>
    {verifyPromptAction && (
      <VerifyAccountPrompt
        actionLabel={verifyPromptAction === 'send' ? 'send the request' : 'download the quote'}
        onContinue={verifyPromptContinue}
        onCancel={() => setVerifyPromptAction(null)} />)}
    </React.Fragment>);
}

// Confirmation popup that fires when an unverified-account user
// hits Send / Download. Informational, not blocking — Continue
// runs the deferred action via verifyPromptContinue. Open Account
// opens /rentals/open-account in a new tab without dismissing the
// cart so the user can finish their request after signing up.
function VerifyAccountPrompt({ actionLabel, onContinue, onCancel }) {
  React.useEffect(() => {
    const esc = (e) => { if (e.key === 'Escape') onCancel(); };
    window.addEventListener('keydown', esc);
    return () => window.removeEventListener('keydown', esc);
  }, [onCancel]);
  return (
    <div className="rcart-verify-scrim" onClick={onCancel} role="dialog" aria-modal="true" aria-labelledby="rcart-verify-h">
      <div className="rcart-verify-card" onClick={(e) => e.stopPropagation()}>
        <h3 id="rcart-verify-h" className="rcart-verify-h">Account not yet verified</h3>
        <p className="rcart-verify-p">
          You don't have a verified account with us yet, so please note we won't
          be able to complete a hire until that's set up. You can still {actionLabel} now
          and we'll come back to you.
        </p>
        <div className="rcart-verify-actions">
          <a
            className="rcart-verify-btn rcart-verify-btn-secondary"
            href="/rentals/open-account"
            target="_blank"
            rel="noopener">
            Open account →
          </a>
          <button
            type="button"
            className="rcart-verify-btn rcart-verify-btn-primary"
            onClick={onContinue}>
            Continue
          </button>
        </div>
      </div>
    </div>);
}

// Verified-account badge — sits in the cart header top-right when the
// /api/rentals/verify-email lookup returns a verified row. Falls back
// to initials when the Notion file URL fails to load (those URLs are
// signed + time-limited; a stale one shouldn't crash the badge).
function AccountBadge({ account }) {
  const [imgFailed, setImgFailed] = useStateRentals(false);
  const initials = (account.name || '?')
    .split(/\s+/)
    .filter(Boolean)
    .map((s) => s[0])
    .join('')
    .slice(0, 2)
    .toUpperCase();
  const firstName = (account.name || '').trim().split(/\s+/)[0] || 'You';
  return (
    <div className="rcart-account" title={`Verified account · ${account.name || ''}`} aria-label="Verified account">
      <div className="rcart-account-avatar">
        {/* Inner div is the circular crop. The tick sits as a sibling
            outside it, so the avatar's overflow:hidden (needed for the
            photo crop) doesn't clip the tick at its bottom-right
            corner where it extends past the avatar bounds. */}
        <div className="rcart-account-avatar-inner">
          {account.photo && !imgFailed
            ? <img src={account.photo} alt="" onError={() => setImgFailed(true)} />
            : <span className="rcart-account-initials">{initials}</span>}
        </div>
        <span className="rcart-account-tick" aria-hidden="true">✓</span>
      </div>
      <span className="rcart-account-name">{firstName}</span>
    </div>);
}

// Inline hint under the email field — surfaces lookup status. None
// when the email is invalid (nothing to say yet) or while loading;
// a "verified" callout when the DB matched (redundant with the
// header badge but helpful for screen readers + ground-truth); a
// "verification pending" line for matched-but-not-verified; an
// "open account" link for valid-but-not-matched.
// Staff-mode customer typeahead. Wraps the email input with a
// dropdown of matching Rental Contacts rows. Search runs against
// /api/rentals/search-accounts, debounced ~200ms. Selecting a row
// fills the email field via onSelect (the rest of the cart's email-
// lookup flow takes over from there). Keyboard: ↑ / ↓ to navigate,
// Enter to pick, Esc to dismiss.
function CustomerQuickPick({ email, onChange, onSelect }) {
  const [query, setQuery] = useStateRentals(email || '');
  const [results, setResults] = useStateRentals([]);
  const [open, setOpen] = useStateRentals(false);
  const [activeIdx, setActiveIdx] = useStateRentals(-1);
  const [loading, setLoading] = useStateRentals(false);
  const lastSeqRef = React.useRef(0);
  const wrapRef = React.useRef(null);

  // Keep the local query in step with external email changes (e.g.
  // when the cart restores from localStorage on open).
  React.useEffect(() => {
    setQuery(email || '');
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [email]);

  // Debounced search.
  React.useEffect(() => {
    const q = query.trim();
    if (q.length < 2) {
      setResults([]);
      setOpen(false);
      return;
    }
    const seq = ++lastSeqRef.current;
    const t = setTimeout(async () => {
      setLoading(true);
      try {
        const r = await fetch('/api/rentals/search-accounts', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ q }),
        });
        const data = await r.json().catch(() => ({}));
        if (seq !== lastSeqRef.current) return;  // stale response
        setResults(Array.isArray(data.results) ? data.results : []);
        setOpen(true);
        setActiveIdx(-1);
      } catch (e) {
        if (seq === lastSeqRef.current) setResults([]);
      } finally {
        if (seq === lastSeqRef.current) setLoading(false);
      }
    }, 220);
    return () => clearTimeout(t);
  }, [query]);

  // Click-outside-to-close.
  React.useEffect(() => {
    const onDown = (e) => {
      if (!wrapRef.current) return;
      if (wrapRef.current.contains(e.target)) return;
      setOpen(false);
    };
    document.addEventListener('mousedown', onDown);
    return () => document.removeEventListener('mousedown', onDown);
  }, []);

  const pick = (r) => {
    if (!r) return;
    setQuery(r.email);
    setResults([]);
    setOpen(false);
    setActiveIdx(-1);
    onSelect && onSelect(r);
  };

  const onKey = (e) => {
    if (!open || results.length === 0) return;
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setActiveIdx((i) => Math.min(i + 1, results.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setActiveIdx((i) => Math.max(i - 1, 0));
    } else if (e.key === 'Enter') {
      if (activeIdx >= 0 && activeIdx < results.length) {
        e.preventDefault();
        pick(results[activeIdx]);
      }
    } else if (e.key === 'Escape') {
      setOpen(false);
    }
  };

  return (
    <div className="rcart-customer-pick" ref={wrapRef}>
      <input
        type="email"
        className="rcart-email-input rcart-email-input-block"
        value={query}
        onChange={(e) => {
          setQuery(e.target.value);
          onChange && onChange(e);
        }}
        onFocus={() => { if (results.length > 0) setOpen(true); }}
        onKeyDown={onKey}
        placeholder="Name, email, or company — staff lookup"
        autoComplete="off"
        spellCheck={false} />
      {loading && open && (
        <div className="rcart-customer-pick-status">Searching…</div>
      )}
      {open && results.length > 0 && (
        <ul className="rcart-customer-pick-list" role="listbox">
          {results.map((r, i) => (
            <li
              key={r.id}
              role="option"
              aria-selected={i === activeIdx}
              className={`rcart-customer-pick-row ${i === activeIdx ? 'is-active' : ''}`}
              onMouseEnter={() => setActiveIdx(i)}
              onMouseDown={(e) => { e.preventDefault(); pick(r); }}>
              <div className="rcart-customer-pick-name">{r.name}</div>
              <div className="rcart-customer-pick-meta">
                <span className="rcart-customer-pick-email">{r.email}</span>
                {r.company && <span className="rcart-customer-pick-company"> · {r.company}</span>}
                {r.status && <span className="rcart-customer-pick-status-tag"> · {r.status}</span>}
              </div>
            </li>
          ))}
        </ul>
      )}
      {open && !loading && query.trim().length >= 2 && results.length === 0 && (
        <div className="rcart-customer-pick-empty">No matches — keep typing or use the full email.</div>
      )}
    </div>);
}

function AccountInlineHint({ email, emailValid, loading, account }) {
  if (!emailValid) return null;
  if (loading) return <p className="rcart-fine rcart-account-hint">Checking your account…</p>;
  if (!account) return null;
  if (account.found && account.verified) {
    return (
      <p className="rcart-fine rcart-account-hint rcart-account-hint-ok">
        Verified account — quote will be faster.
      </p>);
  }
  if (account.found && !account.verified) {
    return (
      <p className="rcart-fine rcart-account-hint">
        Account found — verification still pending. You can still send a request now.
      </p>);
  }
  // Not in the DB — surface an "Open one" CTA that lands on the OA
  // wizard with the typed email already pre-filled (the OA page reads
  // ?email= on mount). Removes a copy-paste step for the customer.
  const oaUrl = `/rentals/open-account${email ? `?email=${encodeURIComponent(email)}` : ''}`;
  return (
    <p className="rcart-fine rcart-account-hint">
      Not seeing your account.{' '}
      <a href={oaUrl} target="_blank" rel="noopener">Open one in 2 mins →</a>
      {' '}— verified accounts get a faster quote.
    </p>);
}

// Notes + Code merged into a two-pill row. Both pills sit side-by-side
// at equal width; hovering or focusing one expands it (grid-template-
// columns transition) and shrinks the other. Empty placeholder text
// truncates naturally when narrow — the moment of expansion reveals
// the full placeholder + room to type. Pills that already hold a value
// stay slightly wider when idle so the value isn't cut off.
function CartMetaPills({ details, setField, onClearCode }) {
  const hasNotes = Boolean((details.notes || '').trim());
  const hasCode = Boolean((details.code || '').trim());
  return (
    <div
      className={`rcart-meta-pills ${hasNotes ? 'has-notes' : ''} ${hasCode ? 'has-code' : ''}`.trim()}>
      <div className="rcart-pill-wrap">
        <span className="rcart-pill-label">Notes</span>
        <label className="rcart-pill">
          <input
            type="text"
            value={details.notes}
            onChange={setField('notes')}
            placeholder="Shoot details, delivery, anything weird…"
            maxLength={500}
            autoComplete="off" />
        </label>
      </div>
      <div className="rcart-pill-wrap">
        <span className="rcart-pill-label">Code</span>
        <label className="rcart-pill">
          <input
            type="text"
            value={details.code}
            onChange={setField('code')}
            placeholder="Discount or partner code"
            maxLength={32}
            autoComplete="off"
            spellCheck={false} />
          {hasCode && onClearCode && (
            // Inline × clear button — only visible when there's a code
            // applied. Click sets details.code = '' AND latches the
            // user-cleared flag so the email-lookup auto-fill won't
            // immediately re-populate.
            <button
              type="button"
              className="rcart-pill-clear"
              onClick={(e) => { e.preventDefault(); e.stopPropagation(); onClearCode(); }}
              aria-label="Remove discount code">
              ×
            </button>
          )}
        </label>
      </div>
    </div>);
}

// Collapsed-state summary for the dates block. Single line showing
// the human-readable pickup → return + day count + any after-hours
// flag, with the "Edit" button (rendered by the parent) re-opening
// the full picker.
function DatesCollapsedSummary({ details }) {
  const ahCount = afterHoursEventCount(details);
  const days = calcRentalDays(details.pickupDate, details.returnDate);
  return (
    <div className="rcart-dates-summary">
      <span className="rcart-dates-summary-text">
        {fmtDateShort(details.pickupDate)} · {fmtTimeForDisplay(details.pickupTime)}
        {' → '}
        {fmtDateShort(details.returnDate)} · {fmtTimeForDisplay(details.returnTime)}
      </span>
      <span className="rcart-dates-summary-meta">
        Billed as {days} day{days === 1 ? '' : 's'}
        {ahCount > 0 && ` · After-hours × ${ahCount}`}
      </span>
    </div>);
}

// CartStepRequest + CartStepReview removed in the May 2026 cart
// reshape — the wizard is gone, all sections render on one screen
// inside CartDrawer above. CartStepItems / CartStepDates remain as
// reusable section components.

// Location block — Collection ↔ Delivery per-leg, with the zone +
// per-hour price + travel time AUTO-CLASSIFIED from the entered
// postcode (postcodes.io coords → CCZ bbox / M25 radius checks → cart
// small-drop qualifier). No zone chips: the eyebrows are computed
// and displayed, not picked. Matches the Hire Period block's
// collapse/expand pattern — collapses to a single-line summary
// once both legs are configured; click "Edit" or click anywhere
// inside to re-expand.
function CartLocationBlock({ details, setField, setDetails, items }) {
  // Default-collapsed because the default values (Collection both
  // ways) are valid out of the gate; user only expands to swap
  // legs to Delivery.
  const [expanded, setExpanded] = useStateRentals(false);
  const blockRef = React.useRef(null);

  // Click-away collapses, same pattern as Hire Period. Only fires
  // when expanded; the validity check (locationValid) gates the
  // collapse so a half-filled delivery row stays open.
  React.useEffect(() => {
    if (!expanded) return undefined;
    const handler = (e) => {
      if (!blockRef.current || blockRef.current.contains(e.target)) return;
      if (locationValid) setExpanded(false);
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [expanded, details]);

  // ── Postcode → coords → zone + travel time auto-classification ──
  // Debounced lookup per leg. On any postcode change the timer
  // resets; once it fires we call postcodes.io, derive coords +
  // zone + minutes, then patch details with the result.
  const runLookup = React.useCallback(async (legKey, postcode) => {
    const coords = postcode ? await lookupPostcodeCoords(postcode) : null;
    setDetails((d) => {
      const zone = classifyDeliveryZone(coords, items);
      const dist = coords ? haversineMiles(VF_HQ_COORDS.lat, VF_HQ_COORDS.lng, coords.lat, coords.lng) : 0;
      const travelMin = coords && zone ? estimateTravelMinutes(dist, zone) : 0;
      return {
        ...d,
        [`${legKey}Coords`]: coords,
        [`${legKey}Zone`]: zone,
        [`${legKey}TravelMin`]: travelMin,
      };
    });
  }, [setDetails, items]);

  // One debounced effect per leg keyed on postcode + items length
  // (re-classify if the cart contents change and small-drop status
  // flips).
  useEffectRentals(() => {
    if (details.pickupMode !== 'delivery') return undefined;
    const t = setTimeout(() => runLookup('pickup', details.pickupPostcode), 450);
    return () => clearTimeout(t);
  }, [details.pickupPostcode, details.pickupMode, items.length]);
  useEffectRentals(() => {
    if (details.returnMode !== 'delivery') return undefined;
    const t = setTimeout(() => runLookup('return', details.returnPostcode), 450);
    return () => clearTimeout(t);
  }, [details.returnPostcode, details.returnMode, items.length]);

  // Total travel time across delivery legs — sum of the rounded
  // billed hours for each leg that's set to delivery.
  const pickupHrs = roundToBilledHours(details.pickupTravelMin);
  const returnHrs = roundToBilledHours(details.returnTravelMin);
  const totalTravelMin =
      (details.pickupMode === 'delivery' ? details.pickupTravelMin : 0)
    + (details.returnMode === 'delivery' ? details.returnTravelMin : 0);
  const anyDelivery = details.pickupMode === 'delivery' || details.returnMode === 'delivery';

  // Validity = each delivery leg has BOTH a non-empty address AND
  // a zone (i.e. postcode lookup succeeded). Collection legs are
  // always valid.
  const legValid = (mode, address, zone) => {
    if (mode !== 'delivery') return true;
    return Boolean((address || '').trim() && zone);
  };
  const locationValid =
    legValid(details.pickupMode, details.pickupAddress, details.pickupZone)
    && legValid(details.returnMode, details.returnAddress, details.returnZone);

  return (
    <div className="rcart-block" ref={blockRef}>
      <div className="rcart-block-head">
        <h4 className="rcart-section-h">Location</h4>
        {expanded ? (
          <button
            type="button"
            className="rcart-mini-edit"
            onClick={() => setExpanded(false)}
            disabled={!locationValid}
            title={!locationValid ? 'Fill the delivery address & postcode first' : 'Collapse to summary'}>
            ✓ Done
          </button>
        ) : (
          <button type="button" className="rcart-mini-edit" onClick={() => setExpanded(true)}>
            Edit
          </button>)}
      </div>
      {expanded ? (
        <div className="rcart-logistics">
          <CartLocationLeg
            legKey="pickup"
            legLabel="Out"
            details={details}
            setField={setField}
            setDetails={setDetails} />
          <CartLocationLeg
            legKey="return"
            legLabel="Back"
            details={details}
            setField={setField}
            setDetails={setDetails} />
          {anyDelivery && totalTravelMin > 0 && (
            <p className="rcart-fine rcart-loc-travel-total">
              Est total driver time:
              {' '}
              <strong>{pickupHrs + returnHrs} hr{pickupHrs + returnHrs > 1 ? 's' : ''}</strong>
              {' '}({fmtTravelHours(totalTravelMin)} estimated, rounded to whole hours per booking).
            </p>)}
        </div>
      ) : (
        <LocationCollapsedSummary details={details} pickupHrs={pickupHrs} returnHrs={returnHrs} />
      )}
    </div>);
}

// Collapsed-state summary — single line showing both legs at a
// glance. Matches the look + feel of DatesCollapsedSummary so the
// two sections read as a coherent block when both are collapsed.
function LocationCollapsedSummary({ details, pickupHrs, returnHrs }) {
  // Wording mirrors the toggle: OUT shows Collection vs Courier,
  // BACK shows Return vs Courier. legKey disambiguates the
  // collection-mode verb.
  const legText = (legKey, mode, postcode, zone, hrs) => {
    if (mode !== 'delivery') {
      const verb = legKey === 'return' ? 'Return' : 'Collection';
      return `${verb} · ${VF_COLLECTION_ADDRESS.postcode}`;
    }
    if (!postcode) return 'Courier · address pending';
    const zoneLabel = zone ? DELIVERY_ZONE_LABELS[zone] : 'classifying…';
    if (zone === 'outside') return `Courier to ${postcode} · ${zoneLabel}`;
    if (zone === 'small-drop') return `Courier to ${postcode} · Small drop (£${DELIVERY_SMALL_DROP_FLAT})`;
    if (!zone) return `Courier to ${postcode}`;
    const rate = DELIVERY_RATES_PER_HOUR[zone];
    return `Courier to ${postcode} · ${zoneLabel} (£${rate * hrs})`;
  };
  return (
    <div className="rcart-loc-summary">
      <div className="rcart-loc-summary-leg">
        <span className="rcart-loc-summary-label">Out</span>
        <span className="rcart-loc-summary-text">{legText('pickup', details.pickupMode, details.pickupPostcode, details.pickupZone, pickupHrs)}</span>
      </div>
      <div className="rcart-loc-summary-leg">
        <span className="rcart-loc-summary-label">Back</span>
        <span className="rcart-loc-summary-text">{legText('return', details.returnMode, details.returnPostcode, details.returnZone, returnHrs)}</span>
      </div>
    </div>);
}

// One leg of the Location block — Collection / Delivery toggle plus
// (when delivery) address + postcode inputs and the auto-classified
// zone label / travel-time hint. No manual zone chips — the
// classification runs in CartLocationBlock's useEffect on postcode
// change and lands as details.<leg>Zone / TravelMin.
function CartLocationLeg({ legKey, legLabel, details, setField, setDetails }) {
  const mode = details[`${legKey}Mode`];
  const address = details[`${legKey}Address`];
  const postcode = details[`${legKey}Postcode`];
  const zone = details[`${legKey}Zone`];
  const travelMin = details[`${legKey}TravelMin`];
  const isDelivery = mode === 'delivery';
  const collectionHint = `${VF_COLLECTION_ADDRESS.line1} · ${VF_COLLECTION_ADDRESS.line2} · ${VF_COLLECTION_ADDRESS.city} · ${VF_COLLECTION_ADDRESS.postcode}`;
  // Toggle handler — also auto-mirrors the OUT address onto the
  // RETURN leg when both are delivery (saves the user typing the
  // same address twice for the common case where we deliver +
  // collect from the same site). Only mirrors when the return
  // address is currently empty so we don't overwrite a deliberate
  // mismatch the user already typed.
  const setMode = (m) => setDetails((d) => {
    const next = { ...d, [`${legKey}Mode`]: m };
    if (legKey === 'return' && m === 'delivery'
        && d.pickupMode === 'delivery'
        && (d.pickupAddress || d.pickupPostcode)
        && !d.returnAddress && !d.returnPostcode) {
      next.returnAddress  = d.pickupAddress || '';
      next.returnPostcode = d.pickupPostcode || '';
    }
    return next;
  });
  // Classification status text — surfaces the auto-derived delivery
  // type (Small drop / Van delivery / Outside M25) + billed hours +
  // price so the user sees the result of typing a postcode without
  // needing to read the line item below.
  let zoneStatus = null;
  if (isDelivery) {
    const hasPostcode = (postcode || '').trim().length > 0;
    if (!hasPostcode) {
      zoneStatus = { label: 'Enter postcode to estimate', tone: 'pending' };
    } else if (!zone) {
      zoneStatus = { label: 'Looking up postcode…', tone: 'pending' };
    } else if (zone === 'outside') {
      zoneStatus = { label: 'Outside M25 — quote on request', tone: 'quote' };
    } else if (zone === 'small-drop') {
      zoneStatus = { label: `Small drop · £${DELIVERY_SMALL_DROP_FLAT} flat`, tone: 'ok' };
    } else {
      const billedHours = roundToBilledHours(travelMin);
      const rate = DELIVERY_RATES_PER_HOUR[zone];
      const price = rate * billedHours;
      const zoneNote = DELIVERY_ZONE_NOTES[zone] ? ` (${DELIVERY_ZONE_NOTES[zone]})` : '';
      zoneStatus = {
        label: `Van delivery · ${billedHours} hr × £${rate} = £${price}${zoneNote}`,
        tone: 'ok',
      };
    }
  }
  return (
    <div className={`rcart-leg ${isDelivery ? 'is-delivery' : 'is-collection'}`}>
      {/* Toggle labels are leg-dependent: OUT shows Collection vs
          Courier (customer collects from VF, or VF couriers to
          them); BACK shows Return vs Courier (customer drops back,
          or VF couriers to collect). Underlying mode values stay
          'collection' / 'delivery' so payload, PDF and zone-pricing
          code is unchanged. */}
      <div className="rcart-leg-head">
        <span className="rcart-leg-label">{legLabel}</span>
        <div className="rcart-leg-toggle" role="radiogroup" aria-label={`${legLabel} mode`}>
          <button
            type="button"
            role="radio"
            aria-checked={!isDelivery}
            className={`rcart-leg-toggle-btn ${!isDelivery ? 'is-on' : ''}`}
            onClick={() => setMode('collection')}
            title={!isDelivery ? collectionHint : (legKey === 'return' ? 'Switch to return' : 'Switch to collection')}>
            {legKey === 'return' ? 'Return' : 'Collection'}
          </button>
          <button
            type="button"
            role="radio"
            aria-checked={isDelivery}
            className={`rcart-leg-toggle-btn ${isDelivery ? 'is-on' : ''}`}
            onClick={() => setMode('delivery')}>
            Courier
          </button>
        </div>
      </div>
      {isDelivery ? (
        <div className="rcart-leg-delivery">
          <div className="rcart-leg-row">
            <input
              type="text"
              className="rcart-leg-input rcart-leg-input-address"
              value={address}
              onChange={setField(`${legKey}Address`)}
              placeholder="Address (street + building)"
              autoComplete="street-address" />
            <input
              type="text"
              className="rcart-leg-input rcart-leg-input-postcode"
              value={postcode}
              onChange={setField(`${legKey}Postcode`)}
              placeholder="Postcode"
              autoComplete="postal-code"
              spellCheck={false}
              maxLength={10} />
          </div>
          {zoneStatus && (
            <div className={`rcart-leg-zone-status is-${zoneStatus.tone}`} role="status">
              {zoneStatus.label}
              {zone && travelMin > 0 && zone !== 'small-drop' && (
                <span className="rcart-leg-travel"> · est {fmtTravelHours(travelMin)} driver time</span>)}
            </div>)}
        </div>
      ) : (
        <div className="rcart-leg-collection">
          <span className="rcart-leg-collection-pin" aria-hidden="true"><VfAddressIcon /></span>
          <span className="rcart-leg-collection-text">
            From <strong>{VF_COLLECTION_ADDRESS.name}</strong> · {VF_COLLECTION_ADDRESS.postcode}
          </span>
          <span className="rcart-leg-collection-full" role="tooltip">{collectionHint}</span>
        </div>)}
    </div>);
}

// Admin-only ownership code for a cart line — same C-XX / X-XX scheme
// used in the equipment drawer. Returns null for own-kit lines so the
// majority of rows stay uncluttered.
function lineAdminCode(item) {
  if (!item || !item.ownerInitials) return null;
  if (item.ownership === 'consignment') return `C-${item.ownerInitials}`;
  if (item.ownership === 'cross-hire')  return `X-${item.ownerInitials}`;
  return null;
}

// Inline "Add custom item" widget. Public mode collects name + notes
// only (no price → renders as "Quote on request"). Staff mode (set
// when Equipment Revision Mode is on) also collects day-rate + qty
// so the line participates in cart totals + PDF math — used for
// building hand-priced quotes.
// Staff-only — the parent gates this so public visitors never even
// mount the component. Earlier iteration showed a "Quote on request"
// version to everyone, but Max flagged that as inviting fishing
// requests for kit we likely already have on the shelf. Now it's
// scoped to the Equipment Revision Mode flow.
function CustomItemRow({ onAdd, staffMode }) {
  const [open, setOpen] = useStateRentals(false);
  const [name, setName] = useStateRentals('');
  const [notes, setNotes] = useStateRentals('');
  const [dayRate, setDayRate] = useStateRentals('');
  const [qty, setQty] = useStateRentals('1');
  // Staff-only ownership selector. Default 'owned' (the common case
  // for an ad-hoc additional item from VF's shelf). 'consignment'
  // applies half the coupon discount; 'cross-hire' applies zero.
  const [ownership, setOwnership] = useStateRentals('owned');
  const canSave = name.trim().length > 0;
  const reset = () => {
    setName(''); setNotes(''); setDayRate(''); setQty('1');
    setOwnership('owned');
  };
  const save = () => {
    if (!canSave) return;
    onAdd({
      name: name.trim(),
      notes: notes.trim(),
      dayRate: staffMode ? Number(dayRate || 0) : 0,
      qty: Math.max(1, parseInt(qty || '1', 10)),
      // Pass ownership only in staff mode — public-side custom items
      // stay 'custom' (sentinel) since the customer can't pick.
      ownership: staffMode ? ownership : undefined,
    });
    reset();
    setOpen(false);
  };
  if (!open) {
    return (
      <button type="button" className={`rcart-custom-trigger ${staffMode ? 'is-staff' : ''}`} onClick={() => setOpen(true)}>
        + Add custom item
        {staffMode && <span className="rcart-custom-trigger-staff">staff</span>}
      </button>);
  }
  return (
    <div className={`rcart-custom-form ${staffMode ? 'is-staff' : ''}`}>
      <div className="rcart-custom-form-head">
        <span>Custom item</span>
        {staffMode && <span className="rcart-custom-form-staff">Staff — pricing enabled</span>}
      </div>
      <label className="rcart-custom-field">
        <span>Item name</span>
        <input
          type="text"
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="e.g. Cooke S5/i 32mm"
          maxLength={120}
          autoFocus />
      </label>
      {staffMode && (
        <div className="rcart-custom-row">
          <label className="rcart-custom-field">
            <span>Day rate (£)</span>
            <input
              type="number"
              min="0"
              step="1"
              value={dayRate}
              onChange={(e) => setDayRate(e.target.value)}
              placeholder="0" />
          </label>
          <label className="rcart-custom-field rcart-custom-field-qty">
            <span>Qty</span>
            <input
              type="number"
              min="1"
              step="1"
              value={qty}
              onChange={(e) => setQty(e.target.value)} />
          </label>
        </div>)}
      {staffMode && (
        <div className="rcart-custom-field rcart-custom-field-ownership">
          <span>Ownership · drives coupon routing</span>
          <div
            className="rcart-custom-ownership"
            role="radiogroup"
            aria-label="Ownership for this custom item">
            {[
              { value: 'owned',       label: 'Owned',       hint: 'Full coupon discount' },
              { value: 'consignment', label: 'Consignment', hint: 'Half coupon discount' },
              { value: 'cross-hire',  label: 'Cross-hire',  hint: 'No coupon discount' },
            ].map((opt) => (
              <button
                key={opt.value}
                type="button"
                role="radio"
                aria-checked={ownership === opt.value}
                className={`rcart-custom-ownership-btn ${ownership === opt.value ? 'is-on' : ''}`}
                title={opt.hint}
                onClick={() => setOwnership(opt.value)}>
                {opt.label}
              </button>
            ))}
          </div>
        </div>)}
      <label className="rcart-custom-field">
        <span>Notes (optional)</span>
        <input
          type="text"
          value={notes}
          onChange={(e) => setNotes(e.target.value)}
          placeholder="Source, model, anything else…"
          maxLength={300} />
      </label>
      <div className="rcart-custom-actions">
        <button type="button" className="rcart-custom-cancel" onClick={() => { reset(); setOpen(false); }}>
          Cancel
        </button>
        <button type="button" className="rcart-custom-save" disabled={!canSave} onClick={save}>
          Add to order
        </button>
      </div>
    </div>);
}

function CartStepItems({
  items, setQty, remove, subtotal, details, setField, addCustomItem, staffMode,
  customOrder, setCustomOrder, setDiscountOverride,
  setInternalNote, bulkSetDiscount,
}) {
  // Per-line internal-note expander (staff mode only). Stores which
  // line id is currently being edited so the textarea only renders
  // for one line at a time — keeps the cart tight.
  const [noteEditingId, setNoteEditingId] = React.useState(null);
  // Bulk-discount popover state — open/close + current % input.
  const [bulkOpen, setBulkOpen] = React.useState(false);
  const [bulkPct, setBulkPct] = React.useState('');
  // Booqable admin URL for a given catalog line. We don't capture the
  // product UUID during the sync (Booqable's public shop doesn't
  // expose it), so we deep-link to the admin inventory search filtered
  // by the item name — one extra click vs direct, but no sync work.
  const booqableAdminSearchUrl = (item) => {
    if (!item || !item.name || item.isCustom) return null;
    return `https://valley-rentals.booqable.com/inventory?search=${encodeURIComponent(item.name)}`;
  };
  // CartStepItems is only mounted when items OR a synth fee is present
  // (see the IIFE wrapping it in CartDrawer). So if items is empty here
  // it means surcharges from Hire Period / Location are the only thing
  // populating the order — render them, and add a hint that the only
  // way to clear them is via those sections, not a Clear button.
  const itemsEmpty = items.length === 0;

  // Drag-and-drop state. dragIdRef holds the id of the item being
  // dragged; dropOverId triggers a drop-indicator class on the line
  // the cursor is over. Both reset on dragend. We start tracking on
  // dragstart and finalise on drop via setCustomOrder.
  const [dropOverId, setDropOverId] = React.useState(null);
  const dragIdRef = React.useRef(null);
  // Derive the supplemental fees that render as synthetic line items
  // at the bottom of the list. After-hours sits between the kit lines
  // and the delivery legs — pre/post the kit's "use window" so the
  // chronological feel of the order matches reality (out → use → back).
  const ahCount   = details ? afterHoursEventCount(details) : 0;
  const ahFee     = ahCount * AFTER_HOURS_CHARGE;
  // Specific slot names for the synth line item ("Early" / "Late" or
  // "Early + Late" when both legs are after-hours), built from the
  // actual pickup/return times rather than the count.
  const ahSlotNames = details
    ? [
        isAfterHoursTime(details.pickupTime) ? (details.pickupTime === TIME_SLOT_EARLY ? 'Early' : 'Late') : null,
        isAfterHoursTime(details.returnTime) ? (details.returnTime === TIME_SLOT_EARLY ? 'Early' : 'Late') : null,
      ].filter(Boolean).join(' + ')
    : '';
  const pickupHrs = details ? roundToBilledHours(details.pickupTravelMin) : 1;
  const returnHrs = details ? roundToBilledHours(details.returnTravelMin) : 1;
  const pickupFee = details ? deliveryLegPrice(details.pickupMode, details.pickupZone, pickupHrs) : 0;
  const returnFee = details ? deliveryLegPrice(details.returnMode, details.returnZone, returnHrs) : 0;
  const days      = details ? calcRentalDays(details.pickupDate, details.returnDate) : 1;

  // Coupon — strip leading/trailing whitespace, parse the number out
  // of the code, route by ownership (see parseCoupon / discountPercentFor).
  // The discount is applied per-line (so the user sees which kit it
  // hit) and rolled into the day-rate subtotal AND grand total. The
  // synth fees (after-hours, delivery) are never discounted.
  const coupon = parseCoupon(details && details.code);
  const lineDiscountAmount = (i) => (i.dayRate || 0) * i.qty * (discountPercentFor(i, coupon.percent) / 100);
  const totalLineDiscount = items.reduce((s, i) => s + lineDiscountAmount(i), 0);
  const discountedDailySubtotal = subtotal - totalLineDiscount;

  // Multi-day grand total = (day-rate × days) + after-hours + delivery
  // (after-hours and delivery are one-off fees, not per-day). Subtotal
  // is the discounted figure when a coupon is active.
  const grandTotal = (discountedDailySubtotal * Math.max(1, days)) + ahFee + pickupFee + returnFee;
  const showGrand  = days > 1 || ahFee > 0 || pickupFee > 0 || returnFee > 0 || totalLineDiscount > 0;

  // Resolve display order: custom flat list when staff has dragged, or
  // category groups when in default mode. Headers only render when
  // there are 2+ category groups (single-category orders look noisier
  // with one orphan header above them).
  const display = resolveCartDisplay(items, customOrder);
  const showCategoryHeaders = !display.custom && display.groups.length >= 2;

  // Drag handlers. Only active in staff mode (the line itself only
  // sets draggable=true when staff mode is on, so these never fire
  // for end users). Note onDragOver MUST preventDefault to enable
  // drop on that element.
  const onDragStart = (id) => (e) => {
    if (!staffMode) return;
    dragIdRef.current = id;
    try { e.dataTransfer.effectAllowed = 'move'; } catch (_) {}
  };
  const onDragOver = (id) => (e) => {
    if (!staffMode || !dragIdRef.current) return;
    e.preventDefault();
    if (dropOverId !== id) setDropOverId(id);
  };
  const onDragLeave = () => setDropOverId(null);
  // Belt-and-braces reset — fires on drop-outside-any-line, Esc-to-cancel,
  // or any other dragend that bypasses onDrop. Without it dragIdRef can
  // stay set across an aborted drag, leaving subsequent dragOver calls
  // operating against a stale id.
  const onDragEnd = () => {
    dragIdRef.current = null;
    setDropOverId(null);
  };
  const onDrop = (overId) => (e) => {
    if (!staffMode) return;
    e.preventDefault();
    const draggedId = dragIdRef.current;
    dragIdRef.current = null;
    setDropOverId(null);
    if (!draggedId || draggedId === overId) return;
    // Build the next order. If we're not yet in custom-order mode,
    // seed it from the current resolved-display order so the drag
    // produces a sensible result rather than starting from an empty
    // array.
    const baseline = (Array.isArray(customOrder) && customOrder.length)
      ? customOrder.slice()
      : display.flat.map((it) => it.id);
    const withoutDragged = baseline.filter((id) => id !== draggedId);
    const overIdx = withoutDragged.indexOf(overId);
    if (overIdx < 0) {
      setCustomOrder([...withoutDragged, draggedId]);
      return;
    }
    withoutDragged.splice(overIdx, 0, draggedId);
    setCustomOrder(withoutDragged);
  };

  // Single-line renderer. Pulled out so the grouped + flat passes
  // share the same markup. `groupLabel` is the category string we use
  // for the row's data-attribute (helps CSS rules + screen-reader
  // context when scanning a flat custom-ordered list).
  const renderLine = (i, groupLabel) => {
    const code = lineAdminCode(i);
    const linePct = discountPercentFor(i, coupon.percent);
    // Whole vs half-percent display — half values like 15.5 round to
    // one decimal so consigned items at 31% / 2 = 15.5% don't
    // mis-display as "15%".
    const linePctLabel = linePct % 1 === 0 ? `${linePct}` : linePct.toFixed(1);
    const isCustom = !!i.isCustom;
    const rateDisplay = (isCustom && !(i.dayRate > 0)) ? 'Quote on request' : `£${i.dayRate}/day`;
    const autoPct = autoDiscountPercentFor(i, coupon.percent);
    const hasOverride = typeof i.discountOverride === 'number';
    const tip = hasOverride
      ? `Manual discount · staff override`
      : (i.ownership === 'consignment')
        ? `Partial discount · Consignment kit (${coupon.raw})`
        : `Full discount · Valley Rentals kit (${coupon.raw})`;
    const liClass = [
      'rcart-line',
      linePct > 0 ? 'has-discount' : '',
      isCustom ? 'rcart-line-custom' : '',
      hasOverride ? 'rcart-line-override' : '',
      staffMode ? 'rcart-line-staff' : '',
      dropOverId === i.id ? 'rcart-line-drop-target' : '',
    ].filter(Boolean).join(' ');
    return (
      <li
        key={i.id}
        className={liClass}
        draggable={staffMode}
        onDragStart={onDragStart(i.id)}
        onDragOver={onDragOver(i.id)}
        onDragLeave={onDragLeave}
        onDragEnd={onDragEnd}
        onDrop={onDrop(i.id)}
        data-group={groupLabel || ''}>
        {staffMode && (
          <span
            className="rcart-line-drag"
            aria-hidden="true"
            title="Drag to reorder">
            ⋮⋮
          </span>
        )}
        {isCustom ? (
          <div className="rcart-thumb rcart-thumb-custom" aria-hidden="true">✎</div>
        ) : i.image
          ? <div className="rcart-thumb" style={{ backgroundImage: `url(${i.image})` }} aria-hidden="true" />
          : <div className="rcart-thumb rcart-thumb-fallback" aria-hidden="true" />}
        <div className="rcart-line-body">
          <div className="rcart-line-name">
            {i.name}
            {isCustom && <span className="rcart-line-custom-badge" aria-hidden="true">Custom</span>}
            {code && <span className="rcart-line-admin-code" aria-hidden="true" title="Internal ownership code">{code}</span>}
          </div>
          <div className="rcart-line-rate">
            {rateDisplay}
            {staffMode ? (
              // Staff-mode discount editor. Always-visible input shows
              // the current effective percent and lets staff type a new
              // value (0–100). The ↺ reset button is only shown when an
              // override is active, and clears it back to auto-routed.
              <span className="rcart-line-discount-edit" title="Staff discount override (0–100)">
                <span className="rcart-line-discount-edit-minus">−</span>
                <input
                  type="number"
                  min={0}
                  max={100}
                  step={0.5}
                  value={hasOverride ? i.discountOverride : (linePct || '')}
                  placeholder={String(autoPct)}
                  onChange={(e) => {
                    const v = e.target.value;
                    if (v === '' || v === null) {
                      setDiscountOverride(i.id, null);
                    } else {
                      setDiscountOverride(i.id, Number(v));
                    }
                  }}
                  aria-label={`Discount percent for ${i.name}`}
                />
                <span className="rcart-line-discount-edit-pct">%</span>
                {hasOverride && (
                  <button
                    type="button"
                    className="rcart-line-discount-reset"
                    onClick={() => setDiscountOverride(i.id, null)}
                    title={`Reset to auto (${autoPct}%)`}
                    aria-label="Reset to auto-routed discount">
                    ↺
                  </button>
                )}
              </span>
            ) : linePct > 0 && (
              <span className="rcart-line-discount" data-tip={tip}>
                −{linePctLabel}%
              </span>
            )}
          </div>
          {isCustom && i.notes && (
            <div className="rcart-line-custom-notes">{i.notes}</div>
          )}
          {/* Staff-only internal note. Three render states: edit
              textarea (when this line's id matches noteEditingId),
              read-only badge (when a note exists but we're not
              editing), or a small "+ Add internal note" prompt (no
              note yet). Never customer-facing — only renders in staff
              mode and only surfaces server-side in the Comet
              appendix of the PDF / admin email. */}
          {staffMode && noteEditingId === i.id && (
            <div className="rcart-line-internal-note-edit">
              <textarea
                value={i.internalNote || ''}
                onChange={(e) => setInternalNote(i.id, e.target.value)}
                onBlur={() => setNoteEditingId(null)}
                placeholder="Staff-only note (back-up unit / Pete OK'd / etc.)"
                maxLength={400}
                rows={2}
                autoFocus />
              <div className="rcart-line-internal-note-actions">
                <button type="button" onClick={() => setNoteEditingId(null)}>Done</button>
                {i.internalNote && (
                  <button
                    type="button"
                    className="rcart-line-internal-note-clear"
                    onClick={() => { setInternalNote(i.id, ''); setNoteEditingId(null); }}>
                    Clear
                  </button>
                )}
              </div>
            </div>
          )}
          {staffMode && noteEditingId !== i.id && i.internalNote && (
            <div className="rcart-line-internal-note" onClick={() => setNoteEditingId(i.id)} title="Click to edit">
              <span className="rcart-line-internal-note-icon" aria-hidden="true">✎</span>
              <span className="rcart-line-internal-note-text">{i.internalNote}</span>
            </div>
          )}
          {staffMode && noteEditingId !== i.id && !i.internalNote && (
            <button
              type="button"
              className="rcart-line-internal-note-add"
              onClick={() => setNoteEditingId(i.id)}
              title="Add a staff-only note for this line (shows in Comet block / PDF appendix)">
              + Add internal note
            </button>
          )}
        </div>
        <div className="rcart-line-qty">
          <button type="button" onClick={() => setQty(i.id, i.qty - 1)} aria-label="Decrease">−</button>
          <input type="number" min={1} value={i.qty} onChange={(e) => setQty(i.id, Math.max(1, parseInt(e.target.value || '1', 10)))} />
          <button type="button" onClick={() => setQty(i.id, i.qty + 1)} aria-label="Increase">+</button>
        </div>
        {staffMode && booqableAdminSearchUrl(i) && (
          // Booqable admin deep-link (staff-mode only). Lands on the
          // inventory list filtered to this item's name — one extra
          // click vs direct, but we don't have UUIDs to link straight
          // to /product_groups/<uuid>.
          <a
            className="rcart-line-bq-link"
            href={booqableAdminSearchUrl(i)}
            target="_blank"
            rel="noopener noreferrer"
            title={`Open "${i.name}" in Booqable admin`}
            onClick={(e) => e.stopPropagation()}>
            ↗
          </a>
        )}
        <button type="button" className="rcart-line-remove" onClick={() => remove(i.id)} aria-label={`Remove ${i.name}`}>×</button>
      </li>
    );
  };

  return (
    <React.Fragment>
      {display.custom && staffMode && (
        // Tiny banner above the list when the cart's in custom-order
        // mode, with a one-click revert to auto-sort. Only staff see
        // this since end users can't get into custom-order mode.
        <div className="rcart-order-meta">
          <span className="rcart-order-meta-label">Custom order · staff</span>
          <button
            type="button"
            className="rcart-order-meta-reset"
            onClick={() => setCustomOrder(null)}
            title="Restore category grouping + price-desc sort">
            Reset to auto-sort
          </button>
        </div>
      )}
      {staffMode && items.length > 0 && (
        // Staff-mode bulk-actions row above the items list. Currently
        // only houses the bulk-discount popover but easy to extend with
        // other multi-line operations as they come up.
        <div className="rcart-bulk-bar">
          <button
            type="button"
            className={`rcart-bulk-trigger ${bulkOpen ? 'is-on' : ''}`}
            onClick={() => setBulkOpen((v) => !v)}
            title="Apply a discount % to multiple lines at once">
            % Bulk discount
          </button>
          {bulkOpen && (
            <div className="rcart-bulk-pop" role="dialog" aria-label="Bulk discount">
              <div className="rcart-bulk-row">
                <label className="rcart-bulk-field">
                  <span>Discount %</span>
                  <input
                    type="number"
                    min={0}
                    max={100}
                    step={0.5}
                    value={bulkPct}
                    onChange={(e) => setBulkPct(e.target.value)}
                    placeholder="e.g. 30"
                    autoFocus />
                </label>
              </div>
              <div className="rcart-bulk-actions">
                {[
                  { key: 'all',         label: 'All lines' },
                  { key: 'owned',       label: 'Owned only' },
                  { key: 'consignment', label: 'Consignment only' },
                  { key: 'cross-hire',  label: 'Cross-hire only' },
                ].map((t) => (
                  <button
                    key={t.key}
                    type="button"
                    className="rcart-bulk-apply"
                    onClick={() => {
                      const v = bulkPct === '' ? null : Number(bulkPct);
                      bulkSetDiscount(t.key, v);
                      setBulkOpen(false);
                      setBulkPct('');
                    }}>
                    Apply to {t.label}
                  </button>
                ))}
                <button
                  type="button"
                  className="rcart-bulk-reset"
                  onClick={() => {
                    bulkSetDiscount('all', null);
                    setBulkOpen(false);
                    setBulkPct('');
                  }}
                  title="Clear discount override on every line">
                  ↺ Clear all overrides
                </button>
              </div>
            </div>
          )}
        </div>
      )}
      <ul className={`rcart-lines${staffMode ? ' is-staff' : ''}`}>
        {display.groups.map((group, gi) => (
          <React.Fragment key={`g-${group.category || 'flat'}-${gi}`}>
            {showCategoryHeaders && group.category && (
              <li className="rcart-cat-header" aria-hidden="true">
                <span className="rcart-cat-header-label">{group.category}</span>
                <span className="rcart-cat-header-count">
                  {group.items.length} item{group.items.length === 1 ? '' : 's'}
                </span>
              </li>
            )}
            {group.items.map((i) => renderLine(i, group.category))}
          </React.Fragment>
        ))}
        {/* Synthetic line items below the kit — same row chrome with a
            muted modifier (.rcart-line-synth) so they read as
            derived-from-other-state rather than user-curated kit. */}
        {ahCount > 0 && (
          <li className="rcart-line rcart-line-synth" aria-label={`After-hours surcharge × ${ahCount}`}>
            <div className="rcart-thumb rcart-thumb-synth" aria-hidden="true">⏱</div>
            <div className="rcart-line-body">
              <div className="rcart-line-name">After hours · {ahSlotNames}</div>
              <div className="rcart-line-rate">£{AFTER_HOURS_CHARGE}</div>
            </div>
            <div className="rcart-line-synth-total">£{ahFee.toFixed(2)}</div>
          </li>)}
        {details && details.pickupMode === 'delivery' && pickupFee > 0 && (
          <li className="rcart-line rcart-line-synth" aria-label={`Out — ${DELIVERY_ZONE_LABELS[details.pickupZone]}`}>
            <div className="rcart-thumb rcart-thumb-synth" aria-hidden="true">→</div>
            <div className="rcart-line-body">
              <div className="rcart-line-name">Out · {DELIVERY_ZONE_LABELS[details.pickupZone]}</div>
              <div className="rcart-line-rate">{details.pickupAddress || details.pickupPostcode || 'address pending'}</div>
            </div>
            <div className="rcart-line-synth-total">£{pickupFee.toFixed(2)}</div>
          </li>)}
        {details && details.pickupMode === 'delivery' && details.pickupZone === 'outside' && (
          <li className="rcart-line rcart-line-synth rcart-line-synth-quote" aria-label="Out — quote on request">
            <div className="rcart-thumb rcart-thumb-synth" aria-hidden="true">→</div>
            <div className="rcart-line-body">
              <div className="rcart-line-name">Out · Outside M25</div>
              <div className="rcart-line-rate">Quote on request — we'll confirm by email</div>
            </div>
            <div className="rcart-line-synth-total">—</div>
          </li>)}
        {details && details.returnMode === 'delivery' && returnFee > 0 && (
          <li className="rcart-line rcart-line-synth" aria-label={`Back — ${DELIVERY_ZONE_LABELS[details.returnZone]}`}>
            <div className="rcart-thumb rcart-thumb-synth" aria-hidden="true">←</div>
            <div className="rcart-line-body">
              <div className="rcart-line-name">Back · {DELIVERY_ZONE_LABELS[details.returnZone]}</div>
              <div className="rcart-line-rate">{details.returnAddress || details.returnPostcode || 'address pending'}</div>
            </div>
            <div className="rcart-line-synth-total">£{returnFee.toFixed(2)}</div>
          </li>)}
        {details && details.returnMode === 'delivery' && details.returnZone === 'outside' && (
          <li className="rcart-line rcart-line-synth rcart-line-synth-quote" aria-label="Back — quote on request">
            <div className="rcart-thumb rcart-thumb-synth" aria-hidden="true">←</div>
            <div className="rcart-line-body">
              <div className="rcart-line-name">Back · Outside M25</div>
              <div className="rcart-line-rate">Quote on request — we'll confirm by email</div>
            </div>
            <div className="rcart-line-synth-total">—</div>
          </li>)}
      </ul>
      {staffMode && <CustomItemRow onAdd={addCustomItem} staffMode={staffMode} />}
      {itemsEmpty ? (
        <p className="rcart-fine rcart-empty-hint">
          {(ahCount + (pickupFee > 0 ? 1 : 0) + (returnFee > 0 ? 1 : 0)) > 0
            ? <>Kit list is empty — to remove the line item{(ahCount + (pickupFee > 0 ? 1 : 0) + (returnFee > 0 ? 1 : 0)) > 1 ? 's' : ''} above, edit Hire Period or Location. Add items from the equipment page to continue.</>
            : staffMode
              ? <>Kit list is empty — add catalog items, or use <strong>+ Add custom item</strong> above to start a hand-priced quote.</>
              : <>Kit list is empty — add items from the equipment page to continue.</>}
        </p>
      ) : (
        <React.Fragment>
          {totalLineDiscount > 0 && (
            <div className="rcart-discount-row">
              <div>
                Discount
                <span className="rcart-discount-code">{coupon.raw}</span>
              </div>
              <div className="rcart-discount-amount">−£{totalLineDiscount.toFixed(2)}</div>
            </div>)}
          <div className="rcart-subtotal">
            <div>Day-rate subtotal{totalLineDiscount > 0 && <span className="rcart-subtotal-note"> · after discount</span>}</div>
            <div className="rcart-subtotal-num">
              £{discountedDailySubtotal.toFixed(2)}
              {!showGrand && <span className="rcart-novat">no VAT</span>}
            </div>
          </div>
        </React.Fragment>)}
      {showGrand && (
        <div className="rcart-grandtotal">
          <div>
            Estimated total
            {!itemsEmpty && days > 1 && <span className="rcart-grandtotal-note"> · {days} days</span>}
            {(pickupFee > 0 || returnFee > 0) && <span className="rcart-grandtotal-note"> · incl. delivery</span>}
            {ahCount > 0 && <span className="rcart-grandtotal-note"> · incl. after-hours</span>}
            {totalLineDiscount > 0 && <span className="rcart-grandtotal-note"> · incl. discount</span>}
          </div>
          <div className="rcart-grandtotal-num">
            £{grandTotal.toFixed(2)}
            <span className="rcart-novat">no VAT</span>
          </div>
        </div>)}
      {!itemsEmpty && (
        <p className="rcart-fine">Indicative only — final pricing depends on hire length, delivery and any cross-hire arrangement, and is confirmed by email.</p>)}
    </React.Fragment>);
}

// "16 May" — current year is assumed throughout (the cart is for
// imminent bookings, not long-range scheduling) so the year is dropped
// from labels for a cleaner read.
function fmtDateShort(iso) {
  if (!iso) return '—';
  const d = new Date(iso + 'T00:00:00');
  return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
}
// Returns 'today', 'tomorrow', 'yesterday', or '' depending on how the
// passed date relates to now. Used to disambiguate the year-hidden
// dates in the cart's Dates step.
function relativeDayLabel(iso) {
  if (!iso) return '';
  const now = new Date(); now.setHours(0,0,0,0);
  const d = new Date(iso + 'T00:00:00'); d.setHours(0,0,0,0);
  const days = Math.round((d - now) / 86400000);
  if (days === 0) return 'today';
  if (days === 1) return 'tomorrow';
  if (days === -1) return 'yesterday';
  return '';
}

// Render a time `<select>` with After-Hours · Early sentinel first,
// then the date-specific whole-hour slots (incl. 08:30 boundary on
// applicable days), then After-Hours · Late sentinel. The current
// value is force-added if it isn't in the rendered list — keeps a
// pre-filled time valid across weekday changes (e.g. 17:30 carried
// over from a Friday to a Sunday where hours are 10:00–16:00).
function CartTimeSelect({ value, slots, onChange, ariaLabel }) {
  const inSlots = slots.includes(value);
  const isSentinel = isAfterHoursTime(value);
  const orphan = value && !inSlots && !isSentinel;
  return (
    <select value={value} onChange={onChange} aria-label={ariaLabel}>
      {orphan && <option value={value}>{value}</option>}
      <option value={TIME_SLOT_EARLY}>After hours · Early</option>
      {slots.map((t) => <option key={t} value={t}>{t}</option>)}
      <option value={TIME_SLOT_LATE}>After hours · Late</option>
    </select>);
}

function CartStepDates({ details, setField }) {
  const pickupRel = relativeDayLabel(details.pickupDate);
  const returnRel = relativeDayLabel(details.returnDate);
  const pickupSlots = timeSlotsForDate(details.pickupDate);
  const returnSlots = timeSlotsForDate(details.returnDate);
  return (
    <div className="rcart-fields">
      <div className="rcart-field-row">
        <label>
          <span>Collection</span>
          <div className="rcart-datetime">
            <input type="date" value={details.pickupDate} onChange={setField('pickupDate')} />
            <div className="rcart-date-display" aria-hidden="true">
              {fmtDateShort(details.pickupDate)}
              {pickupRel && <em className="rcart-date-rel"> · {pickupRel}</em>}
            </div>
            <CartTimeSelect
              value={details.pickupTime}
              slots={pickupSlots}
              onChange={setField('pickupTime')}
              ariaLabel="Collection time" />
          </div>
        </label>
      </div>
      <div className="rcart-field-row">
        <label>
          <span>Return</span>
          <div className="rcart-datetime">
            <input type="date" value={details.returnDate} onChange={setField('returnDate')} />
            <div className="rcart-date-display" aria-hidden="true">
              {fmtDateShort(details.returnDate)}
              {returnRel && <em className="rcart-date-rel"> · {returnRel}</em>}
            </div>
            <CartTimeSelect
              value={details.returnTime}
              slots={returnSlots}
              onChange={setField('returnTime')}
              ariaLabel="Return time" />
          </div>
        </label>
      </div>
    </div>);
}

// CartStepReview removed — see comment near the old CartStepRequest
// stub above. All review content now lives in CartDrawer.

// Combined floating widget: search input + cart button in one
// liquid-glass pill. Hovering the search side reveals 3 mini results
// (recent or suggested). Clicking the cart side opens the CartDrawer.
function FloatingRentalsBar() {
  const [cartOpen, setCartOpen] = useStateRentals(false);
  const cart = useCart();
  return (
    <React.Fragment>
      {/* Floor-fade: soft dark gradient + backdrop blur that fades in
          from the bottom of the equipment page so the floating search
          bar and filter chips have visual separation from the grid
          behind them. Always mounted (so the opacity transition runs
          smoothly in both directions on route change), but only made
          visible when body.is-rentals-equipment is set. */}
      <div className="rcat-floor-fade" aria-hidden="true" />
      <FloatingRentalsSearch
        onCartOpen={() => setCartOpen(true)}
        cartCount={cart.lineCount}
        cart={cart} />
      <CartDrawer open={cartOpen} onClose={() => setCartOpen(false)} />
    </React.Fragment>);
}

function FloatingRentalsSearch({ onCartOpen, cartCount = 0, cart }) {
  // Single source of truth for which popover is showing. Only one is
  // ever active at a time — moving between the bar and the cart
  // swaps directly, with no overlap. Values: null | 'search' | 'cart'.
  const [activePanel, setActivePanel] = useStateRentals(null);

  // Pulse the cart icon briefly when the line count goes UP — gives a
  // visual confirmation on the floating bar after a user hits "Add to
  // request" in the catalog drawer (the drawer auto-closes shortly
  // after, so without this the only feedback would be the badge
  // number change). prevCartCountRef avoids firing on first mount or
  // when items are removed. ~480ms matches the keyframe duration.
  const prevCartCountRef = React.useRef(cartCount);
  const [bump, setBump] = useStateRentals(false);
  React.useEffect(() => {
    if (cartCount > prevCartCountRef.current) {
      setBump(true);
      const t = setTimeout(() => setBump(false), 520);
      prevCartCountRef.current = cartCount;
      return () => clearTimeout(t);
    }
    prevCartCountRef.current = cartCount;
    return undefined;
  }, [cartCount]);
  const [q, setQ] = useStateRentals('');
  const [highlighted, setHighlighted] = useStateRentals(0);
  const [isEquipment, setIsEquipment] = useStateRentals(
    typeof window !== 'undefined' && window.location.pathname === '/rentals/equipment'
  );
  const closeTimerRef = React.useRef(null);
  const inputRef = React.useRef(null);
  // Derived flags for readability further down.
  const open = activePanel === 'search';
  const cartHover = activePanel === 'cart';

  // Track when we're on the Equipment page — popups suppress, and
  // typing live-filters the grid instead. Watching popstate covers
  // SPA navigation; pushstate is dispatched via the router's onClick
  // hijack which fires popstate too.
  useEffectRentals(() => {
    const sync = () => setIsEquipment(window.location.pathname === '/rentals/equipment');
    window.addEventListener('popstate', sync);
    return () => window.removeEventListener('popstate', sync);
  }, []);

  // When on Equipment, broadcast every keystroke to the EquipmentPage
  // so its catalog grid re-filters live. The page listens via
  // 'vf-equipment-search' and updates its query state.
  useEffectRentals(() => {
    if (!isEquipment) return;
    window.dispatchEvent(new CustomEvent('vf-equipment-search', { detail: { query: q, fromBar: true } }));
  }, [q, isEquipment]);

  // Receive query updates from EquipmentPage (e.g. on first mount with
  // a ?q= seed, or when filters reset via the Clear button). Only
  // accept events that weren't dispatched by ourselves.
  useEffectRentals(() => {
    const handler = (e) => {
      if (!e.detail || e.detail.fromBar) return;
      const next = e.detail.query || '';
      if (next !== q) setQ(next);
    };
    window.addEventListener('vf-equipment-search', handler);
    return () => window.removeEventListener('vf-equipment-search', handler);
  }, [q]);

  // Detect when the floating bar visually overlaps the dark page
  // footer and toggle `over-dark` on the cluster so the CSS can
  // invert the text + icon colours and lighten the glass fill.
  // Without this the dark text + dark icons render as a muddy
  // blob over the black footer instead of the crisp liquid-glass
  // pair they should be. A passive scroll listener compares the
  // footer's getBoundingClientRect().top to the bar's resting top
  // (viewport.height - 80) — cheap, no sentinel element required.
  const [overDark, setOverDark] = useStateRentals(false);
  useEffectRentals(() => {
    const check = () => {
      const footer = document.querySelector('.mega-footer');
      if (!footer) { setOverDark(false); return; }
      const fTop = footer.getBoundingClientRect().top;
      // Bar sits at viewport.bottom - 24, ~56px tall (top ≈ vp - 80).
      // Fire the swap when the footer crosses that y, with a small
      // buffer so the swap feels deliberate rather than flicker-prone.
      setOverDark(fTop < window.innerHeight - 80);
    };
    window.addEventListener('scroll', check, { passive: true });
    window.addEventListener('resize', check);
    check();
    return () => {
      window.removeEventListener('scroll', check);
      window.removeEventListener('resize', check);
    };
  }, []);
  // Fuzzy search the catalog while the user types — mirrors the
  // nav-bar Equipment dropdown so the two surfaces feel consistent.
  const catalog = (typeof window !== 'undefined' && Array.isArray(window.RENTALS_CATALOG)) ? window.RENTALS_CATALOG : [];
  // Idle suggestions: surface the headline VF-owned kit (top 6 by
  // day-rate, excludes consignment + cross-hire items so we only
  // showcase what we own outright). Rendered with the same item-row
  // layout as live search results — visually consistent with what
  // a typed search returns, so the panel doesn't feel like two
  // different surfaces.
  const idleSuggestedItems = React.useMemo(() => {
    return catalog
      .filter((it) => it && it.ownership === 'owned')
      .slice() // don't mutate the shared catalog
      .sort((a, b) => (b.dayRate || 0) - (a.dayRate || 0))
      .slice(0, 6);
  }, [catalog.length]);
  const fuse = React.useMemo(() => {
    if (typeof window === 'undefined' || typeof window.Fuse !== 'function') return null;
    return new window.Fuse(catalog, {
      keys: [
        { name: 'name', weight: 0.36 },
        { name: 'tags', weight: 0.20 },
        { name: 'bundleIncludes', weight: 0.14 },
        { name: 'subcategory', weight: 0.12 },
        { name: 'category', weight: 0.08 },
        { name: 'shortDesc', weight: 0.06 },
        { name: 'desc', weight: 0.04 },
      ],
      threshold: 0.38, ignoreLocation: true, minMatchCharLength: 2,
    });
  }, [catalog.length]);

  const trimmed = q.trim();
  const liveResults = React.useMemo(() => {
    if (!trimmed || !fuse) return [];
    return fuse.search(trimmed, { limit: 6 }).map((r) => r.item);
  }, [trimmed, fuse]);

  React.useEffect(() => { setHighlighted(0); }, [trimmed]);

  // Open helpers. Each cancels any pending close and switches the
  // single activePanel value — so hovering one side instantly closes
  // the other. The class-driven CSS animations cross-fade between them.
  const openSearchNow = () => {
    if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; }
    setActivePanel('search');
  };
  const openCartNow = () => {
    if (closeTimerRef.current) { clearTimeout(closeTimerRef.current); closeTimerRef.current = null; }
    setActivePanel('cart');
  };
  // Single shared close-grace so a sweep across the wide cluster
  // doesn't dismiss prematurely. 220ms is enough to traverse the
  // ~12px visual gap between bar and cart without losing the panel.
  const scheduleClose = () => {
    if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
    closeTimerRef.current = setTimeout(() => setActivePanel(null), 220);
  };
  // Compatibility shims for the existing call sites further down
  // (form submit clears + goTo navigation, etc.).
  const openNow = openSearchNow;
  const setOpen = (val) => setActivePanel(val ? 'search' : null);

  const goTo = (intent) => {
    setOpen(false);
    setQ('');
    let path = '/rentals/equipment';
    if (intent && intent.item) {
      path += `?item=${encodeURIComponent(intent.item)}`;
    } else if (intent && intent.query) {
      try {
        const raw = localStorage.getItem('vf-recent-eq-searches');
        const prev = raw ? JSON.parse(raw) : [];
        const dedup = (Array.isArray(prev) ? prev : []).filter((s) => s.toLowerCase() !== intent.query.toLowerCase());
        const next = [intent.query, ...dedup].slice(0, 5);
        localStorage.setItem('vf-recent-eq-searches', JSON.stringify(next));
      } catch (e) { /* */ }
      path += `?q=${encodeURIComponent(intent.query)}`;
    }
    window.history.pushState(null, '', path);
    window.dispatchEvent(new PopStateEvent('popstate'));
  };

  const onSubmit = (e) => {
    e.preventDefault();
    // On Equipment, Enter does nothing — the grid is already
    // live-filtered as you type. Blur the input so the keyboard
    // dismisses on mobile.
    if (isEquipment) {
      try { inputRef.current && inputRef.current.blur(); } catch (err) { /* */ }
      return;
    }
    if (liveResults.length > 0) {
      const pick = liveResults[highlighted];
      goTo({ item: pick.id });
    } else if (trimmed) {
      goTo({ query: trimmed });
    } else {
      goTo(null);
    }
  };

  const onKeyDown = (e) => {
    if (liveResults.length === 0) return;
    if (e.key === 'ArrowDown') { e.preventDefault(); setHighlighted((h) => (h + 1) % liveResults.length); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlighted((h) => (h - 1 + liveResults.length) % liveResults.length); }
  };

  // On the Equipment page, suppress both popups — typing live-filters
  // the catalog grid in place instead.
  // Search-panel internal mode: live results vs idle "Recent/Try"
  // suggestions. We always render the panel (so the cross-fade with
  // the cart preview can animate cleanly), but `isSearchPanelOpen`
  // controls visibility via class.
  const isLive = !isEquipment && liveResults.length > 0;
  const isSearchPanelOpen = !isEquipment && open && (isLive || (!trimmed && idleSuggestedItems.length > 0));

  // `has-query` keeps the bar expanded on compact pages (About /
  // Open Account) when the user has typed something — otherwise the
  // bar would collapse back to icon-only and hide their query the
  // moment focus leaves. Cleared input → class drops → bar collapses.
  const hasQuery = q.length > 0;
  const clusterCls = ['rfs-cluster', hasQuery && 'has-query', overDark && 'over-dark']
    .filter(Boolean).join(' ');
  return (
    <div className={`rentals-floating-search ${open ? 'is-open' : ''}`}>
      <div className={clusterCls}>
       <div className="rfs-lift">
        {/* Mini panels — the search-results panel is anchored to the
            bar specifically, so it's rendered inside .rfs-bar-wrap
            (not the cluster) so its hover behaviour only fires from
            the search-bar side, not the cart. */}
        <div
          className="rfs-bar-wrap"
          onMouseEnter={openSearchNow}
          onMouseLeave={scheduleClose}>
          {/* Search panel — always in the DOM, visibility & content
              swap via classes. Mode `live` shows fuzzy results when
              the user is typing; mode `idle` shows recent / suggested
              starting queries. Suppressed entirely on Equipment (the
              grid filters live in place there). */}
          {!isEquipment && (
            <div
              className={`rfs-mini ${isLive ? 'rfs-mini-live' : ''} ${isSearchPanelOpen ? 'is-open' : ''}`}
              role="menu"
              aria-hidden={!isSearchPanelOpen}>
              {isLive ? (
                <React.Fragment>
                  <ul className="rfs-live-results">
                    {liveResults.map((it, i) => (
                      <li key={it.id} className={i === highlighted ? 'is-on' : ''}>
                        <button type="button" onMouseEnter={() => setHighlighted(i)} onClick={() => goTo({ item: it.id })}>
                          <span className="rfs-live-thumb" style={it.image ? { backgroundImage: `url(${it.image})` } : undefined} aria-hidden="true" />
                          <span className="rfs-live-name">{it.name}</span>
                          <span className="rfs-live-meta">£{it.dayRate}/day</span>
                        </button>
                      </li>))}
                  </ul>
                  <button type="button" className="rfs-live-all" onClick={() => goTo({ query: trimmed })}>
                    See all results for “{trimmed}” →
                  </button>
                </React.Fragment>) : (
                <React.Fragment>
                  <div className="rfs-mini-head">Suggested</div>
                  <ul className="rfs-live-results">
                    {idleSuggestedItems.map((it) => (
                      <li key={it.id}>
                        <button type="button" onClick={() => goTo({ item: it.id })}>
                          <span className="rfs-live-thumb" style={it.image ? { backgroundImage: `url(${it.image})` } : undefined} aria-hidden="true" />
                          <span className="rfs-live-name">{it.name}</span>
                          <span className="rfs-live-meta">£{it.dayRate}/day</span>
                        </button>
                      </li>))}
                  </ul>
                </React.Fragment>)}
            </div>)}
          <form className="rfs-bar" onSubmit={onSubmit} role="search" aria-label="Search equipment">
            <svg className="rfs-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <circle cx="11" cy="11" r="7" />
              <path d="m21 21-4.3-4.3" />
            </svg>
            <input
              ref={inputRef}
              type="search"
              value={q}
              onChange={(e) => setQ(e.target.value)}
              onKeyDown={onKeyDown}
              onFocus={openNow}
              onBlur={scheduleClose}
              placeholder={isEquipment ? 'Filter the shelf — type to narrow…' : 'Search the shelf…'}
              aria-label="Search equipment"
            />
          </form>
        </div>
        <div className="rfs-cart-wrap"
          onMouseEnter={openCartNow}
          onMouseLeave={scheduleClose}>
          <button
            type="button"
            className={`rfs-cart ${bump ? 'is-bump' : ''}`}
            onClick={onCartOpen}
            aria-label={`Open request cart${cartCount > 0 ? ` (${cartCount} ${cartCount === 1 ? 'item' : 'items'})` : ''}`}>
            <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" />
              <line x1="3" y1="6" x2="21" y2="6" />
              <path d="M16 10a4 4 0 0 1-8 0" />
            </svg>
            {/* The label is rendered on every page but only visible on
                /rentals/equipment via CSS (body.is-rentals-equipment).
                aria-hidden because the button already has an aria-label
                that conveys the same — no need to double up. */}
            <span className="rfs-cart-label" aria-hidden="true">Cart</span>
            {cartCount > 0 && <span className="rfs-cart-badge">{cartCount}</span>}
          </button>
          {cart && <CartHoverPreview cart={cart} onOpen={onCartOpen} open={cartHover} />}
        </div>
       </div>
      </div>
    </div>);
}

// Lightweight popover that appears above the cart button when hovered.
// Shows the current cart contents (or an empty state) with a CTA into
// the full drawer. Always rendered in the DOM — visibility toggled via
// `is-open` class so it can cross-fade with the search panel.
function CartHoverPreview({ cart, onOpen, open }) {
  const { items, subtotal, setQty, remove } = cart;
  return (
    <div className={`rfs-cart-preview ${open ? 'is-open' : ''}`} role="dialog" aria-label="Cart preview" aria-hidden={!open}>
      <div className="rfs-cart-preview-head">
        <span>{items.length === 0 ? 'Your request' : `${items.length} item${items.length === 1 ? '' : 's'} in your request`}</span>
        {items.length > 0 && (
          <span className="rfs-cart-preview-total">£{subtotal.toFixed(2)} <em>/ day</em></span>)}
      </div>
      {items.length === 0 ? (
        <div className="rfs-cart-preview-empty">
          <div className="rfs-cart-preview-empty-icon" aria-hidden="true">
            <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
              <path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z" />
              <line x1="3" y1="6" x2="21" y2="6" />
              <path d="M16 10a4 4 0 0 1-8 0" />
            </svg>
          </div>
          <p>Nothing here yet — start adding kit from the shelf.</p>
        </div>
      ) : (
        <React.Fragment>
          <ul className="rfs-cart-preview-list">
            {items.slice(0, 4).map((i) => (
              <li key={i.id}>
                {i.image
                  ? <span className="rfs-cart-preview-thumb" style={{ backgroundImage: `url(${i.image})` }} aria-hidden="true" />
                  : <span className="rfs-cart-preview-thumb rfs-cart-preview-thumb-fallback" aria-hidden="true" />}
                <span className="rfs-cart-preview-text">
                  <span className="rfs-cart-preview-name">{i.name}</span>
                  <span className="rfs-cart-preview-meta">{i.qty} × £{i.dayRate}/day</span>
                </span>
                <button type="button" className="rfs-cart-preview-remove" onClick={() => remove(i.id)} aria-label={`Remove ${i.name}`}>×</button>
              </li>))}
          </ul>
          {items.length > 4 && (
            <p className="rfs-cart-preview-more">+ {items.length - 4} more</p>)}
          <button type="button" className="rfs-cart-preview-cta" onClick={onOpen}>
            Open cart →
          </button>
        </React.Fragment>)}
    </div>);
}

// ===================================================================
// /rentals/account-update?token=... — magic-link customer update page
// ===================================================================
// Lands here from the magic-link email sent by /api/customer?action=
// request. Verifies the token, pre-fills the form with the customer's
// current Notion values, allows them to update phone / company /
// address / billing address (NOT email or name — those require a
// fresh verification). On submit, POSTs the diff back via
// /api/customer?action=update.
function CustomerUpdatePage({ onGoto }) {
  // Token comes from ?token=... in the URL (set by the magic link).
  const token = (() => {
    if (typeof window === 'undefined') return '';
    try { return new URLSearchParams(window.location.search).get('token') || ''; }
    catch { return ''; }
  })();
  // 'verifying' | 'loaded' | 'invalid' | 'saving' | 'saved' | 'save-error'
  const [state, setState] = useStateRentals(token ? 'verifying' : 'invalid');
  const [customer, setCustomer] = useStateRentals(null);
  const [form, setForm] = useStateRentals({ phone: '', company: '', address: '', billingAddress: '' });
  const [saveError, setSaveError] = useStateRentals('');

  useEffectRentals(() => {
    if (!token) return;
    let cancelled = false;
    (async () => {
      try {
        const r = await fetch('/api/customer?action=verify', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ token }),
        });
        const j = await r.json().catch(() => ({}));
        if (cancelled) return;
        if (!r.ok || !j.customer) {
          setState('invalid');
          return;
        }
        setCustomer(j.customer);
        setForm({
          phone:          j.customer.phone || '',
          company:        j.customer.company || '',
          address:        j.customer.address || '',
          billingAddress: j.customer.billingAddress || '',
        });
        setState('loaded');
      } catch (e) {
        if (!cancelled) setState('invalid');
      }
    })();
    return () => { cancelled = true; };
  }, [token]);

  const setField = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));

  const submit = async (e) => {
    e.preventDefault();
    if (state !== 'loaded') return;
    setState('saving'); setSaveError('');
    try {
      const r = await fetch('/api/customer?action=update', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ token, updates: form }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        setSaveError(j.detail || j.error || 'Save failed — try again, or email rentals@valley.film.');
        setState('save-error');
        return;
      }
      setState('saved');
    } catch (err) {
      setSaveError('Network error — try again, or email rentals@valley.film.');
      setState('save-error');
    }
  };

  return (
    <main className="page active rentals-light rentals-open-account" data-screen-label="03b Rentals · Account update">
      <RentalsSubHero
        idx="VR—4.0"
        label="Update your account"
        title="Account <em>update</em>."
        lead="Edit your details below. Saved straight to our records — we'll use the new info on your next booking." />
      <section className="contact rentals-contact">
        <div className="container">
          <div className="form-card">
            {state === 'verifying' && (
              <div className="oa-update-page-status" role="status">
                <span className="oa-spinner-inline" aria-hidden="true" />
                Verifying link…
              </div>)}
            {state === 'invalid' && (
              <div className="oa-update-page-error">
                <h3>This link is invalid or has expired.</h3>
                <p>Magic links are valid for 30 minutes. Pop back to the open-account page and request a fresh one.</p>
                <button type="button" className="btn-pri" onClick={() => onGoto && onGoto('/rentals/open-account')}>
                  Back to Open Account
                </button>
              </div>)}
            {(state === 'loaded' || state === 'saving' || state === 'save-error') && customer && (
              <form className="oa-update-page-form" onSubmit={submit}>
                <h3>Your details</h3>
                <p className="desc">Edit any field that's changed. Email and name aren't editable here — drop us a line at <a href="mailto:rentals@valley.film">rentals@valley.film</a> if either needs updating.</p>
                <div className="oa-update-page-readonly">
                  <div><strong>{customer.firstName} {customer.surname}</strong></div>
                  <div className="oa-update-page-readonly-email">{customer.email}</div>
                  {customer.status && <div className="oa-update-page-readonly-status">{customer.status}</div>}
                </div>
                <label className="oa-update-page-field">
                  <span>Phone</span>
                  <input type="tel" value={form.phone} onChange={setField('phone')} autoComplete="tel" />
                </label>
                <label className="oa-update-page-field">
                  <span>Company / trading name</span>
                  <input type="text" value={form.company} onChange={setField('company')} autoComplete="organization" />
                </label>
                <label className="oa-update-page-field">
                  <span>Address</span>
                  <textarea rows="3" value={form.address} onChange={setField('address')} autoComplete="street-address" />
                </label>
                <label className="oa-update-page-field">
                  <span>Billing address <em>(if different)</em></span>
                  <textarea rows="3" value={form.billingAddress} onChange={setField('billingAddress')} autoComplete="billing street-address" />
                </label>
                {saveError && <p className="oa-update-page-save-error">{saveError}</p>}
                <button type="submit" className="submit" disabled={state === 'saving'}>
                  {state === 'saving' ? 'Saving…' : 'Save changes'}
                </button>
              </form>)}
            {state === 'saved' && (
              <div className="oa-update-page-saved">
                <div className="check"><Icon name="check" /></div>
                <h3>Saved.</h3>
                <p>Your account details are updated. We'll use them on your next booking.</p>
                <button type="button" className="btn-pri" onClick={() => onGoto && onGoto('/rentals/equipment')}>
                  Browse equipment
                </button>
              </div>)}
          </div>
        </div>
      </section>
    </main>);
}

function RentalsPage({ onGoto, rentalsSection }) {
  // Load the Booqable script on every rentals route so date-picker
  // widgets etc. work on the equipment page. The Booqable cart bubble
  // in the bottom-right is hidden via CSS — its slot is taken by our
  // own FloatingRentalsSearch widget below.
  // useBooqable is invoked for its side-effect (mounting the booqable.js
  // script). The returned status isn't consumed by any sub-page anymore.
  useBooqable();

  // /prep-tech (formerly /rentals/kit-tech-portal) is now its own
  // top-level route, handled directly in app.jsx — no longer routed
  // through here. The body-class isPortal flag still keys off that
  // page elsewhere; we don't need to track it inside the rentals
  // sub-page switch anymore.
  const isPortal = false;

  const page = (() => {
    if (rentalsSection === 'equipment')       return <EquipmentPage onGoto={onGoto} />;
    if (rentalsSection === 'about')           return <RentalsAboutPage onGoto={onGoto} />;
    if (rentalsSection === 'open-account')    return <OpenAccountPage onGoto={onGoto} />;
    if (rentalsSection === 'careers')         return <KitPrepTechPage onGoto={onGoto} />;
    if (rentalsSection === 'consignments')    return <ConsignmentsAdminPage onGoto={onGoto} />;
    if (rentalsSection === 'account-update')  return <CustomerUpdatePage onGoto={onGoto} />;
    return <RentalsLanding onGoto={onGoto} />;
  })();

  // The kit-tech portal is a staff tool — the customer-facing floating
  // search + cart bar would be confusing there. The portal renders its
  // own top bar instead.
  return (
    <React.Fragment>
      {page}
      {!isPortal && <FloatingRentalsBar />}
    </React.Fragment>);
}

// ===================================================================
// /reference/:token — referee response form (linked from reference emails)
// ===================================================================
function ReferencePage({ onGoto, referenceToken }) {
  const [context, setContext] = useStateRentals(null);
  const [loadError, setLoadError] = useStateRentals('');
  const [form, setForm] = useStateRentals({
    refereeFullName: '',
    refereeCompany: '',
    refereeRelationship: '',
    refereePhone: '',
    recommend: '',
    priorRentals: '',
    concerns: '',
  });
  const [sending, setSending] = useStateRentals(false);
  const [sent, setSent] = useStateRentals(false);
  const [error, setError] = useStateRentals('');
  const set = (k) => (e) => setForm({ ...form, [k]: e.target.value });

  useEffectRentals(() => {
    if (!referenceToken) { setLoadError('missing'); return; }
    fetch(`/api/reference/${encodeURIComponent(referenceToken)}`)
      .then((r) => r.json().then((j) => ({ status: r.status, body: j })))
      .then(({ status, body }) => {
        if (status !== 200 || !body.ok) {
          setLoadError(body?.error || 'invalid');
        } else {
          setContext(body);
        }
      })
      .catch(() => setLoadError('network'));
  }, [referenceToken]);

  const progress = (() => {
    const parts = [
      form.refereeFullName.trim() ? 1 : 0,
      form.refereeCompany.trim() ? 1 : 0,
      form.refereeRelationship.trim() ? 1 : 0,
      form.recommend ? 1 : 0,
      Math.min(1, form.priorRentals.trim().length / 40),
    ];
    return parts.reduce((a, b) => a + b, 0) / parts.length;
  })();

  const submit = async (e) => {
    e.preventDefault();
    setError('');
    if (!form.refereeFullName.trim()) { setError('Please enter your full name.'); return; }
    if (!form.refereeCompany.trim()) { setError('Please enter your company.'); return; }
    if (!form.refereeRelationship.trim()) { setError('Please describe your relationship to the applicant.'); return; }
    if (!form.recommend) { setError('Please answer the recommendation question.'); return; }
    setSending(true);
    const errorRef = new Date().toISOString().slice(0, 16).replace(/[T:-]/g, '') + 'Z';
    const friendlyFallback = `We've hit a snag on our end (ref ${errorRef}). Please email rentals@valley.film and we'll sort it out.`;
    try {
      const r = await fetch(`/api/reference/${encodeURIComponent(referenceToken)}`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(form)
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok) {
        // Surface validation messages, but hide server-side failure detail
        // behind a friendly message + console.error for debugging.
        if (body?.errors?.length) {
          setError(body.errors.length === 1 ? body.errors[0] : 'Please check the following: ' + body.errors.join('; '));
        } else if (body?.message && r.status >= 400 && r.status < 500) {
          setError(body.message);
        } else {
          console.error('Reference submit failed:', { status: r.status, ref: errorRef, response: body });
          setError(friendlyFallback);
        }
      } else {
        setSent(true);
      }
    } catch (err) {
      console.error('Reference submit network error:', { ref: errorRef, error: err?.message, stack: err?.stack });
      setError(friendlyFallback);
    } finally {
      setSending(false);
    }
  };

  if (loadError) {
    const titleByErr = {
      missing: 'This link is <em>incomplete</em>.',
      network: 'Trouble loading <em>the form</em>.',
    };
    const leadByErr = {
      missing: 'The reference URL is missing its token. If you arrived here from an email, please use the original link.',
      network: "We couldn't reach the server. Please refresh the page in a moment, or contact rentals@valley.film if it keeps happening.",
    };
    return (
      <main className="page active rentals-light">
        <RentalsSubHero
          idx="VR—6.0"
          label="Trade reference"
          title={titleByErr[loadError] || 'This link has <em>expired</em>.'}
          lead={leadByErr[loadError] || "The reference link you followed is no longer valid. If you'd still like to send a reference, reply to the original email or contact rentals@valley.film and we'll send a fresh link."} />
        <MegaFooter onGoto={onGoto} isRentals />
      </main>
    );
  }
  if (!context) {
    return (
      <main className="page active rentals-light">
        <RentalsSubHero idx="VR—6.0" label="Trade reference" title="Loading…" lead="One moment while we load the form." />
        <MegaFooter onGoto={onGoto} isRentals />
      </main>
    );
  }
  if (context?.alreadyResponded) {
    return (
      <main className="page active rentals-light">
        <RentalsSubHero idx="VR—6.0" label="Trade reference" title="Already <em>received</em>." lead={`Thanks ${context.refereeName || ''} — we've already recorded your response for ${context.applicantName}. No need to resubmit.`} />
        <MegaFooter onGoto={onGoto} isRentals />
      </main>
    );
  }

  return (
    <main className="page active rentals-light" data-screen-label="03c Rentals · Reference">
      <RentalsSubHero
        idx="VR—6.0"
        label="Trade reference"
        title={`Reference for<br/><em>${context.applicantName}</em>.`}
        lead={`Hi ${context.refereeName}, thanks for taking a couple of minutes to answer three short questions. Your response goes straight to Valley Rentals and is kept on file as part of the applicant's record.`} />

      <section className="contact rentals-contact rentals-reference">
        <div className="container">
          <div className="ref-info-side">
            <div className="meta-block">
              <div className="meta-row">
                <div className="lbl">Applicant</div>
                <div className="val"><strong>{context.applicantName}</strong>{context.applicantCompany ? ` · ${context.applicantCompany}` : ''}</div>
              </div>
              <div className="meta-row">
                <div className="lbl">Why we ask</div>
                <div className="val" style={{ fontSize: '14px', lineHeight: 1.55 }}>Trade references help us verify that an applicant has a track record of treating rented equipment responsibly. Three quick questions, no follow-up calls.</div>
              </div>
              <div className="meta-row">
                <div className="lbl">Questions?</div>
                <div className="val val-link"><a href="mailto:rentals@valley.film">rentals@valley.film</a></div>
              </div>
            </div>
          </div>

          <div className="form-card">
            {sent ? (
              <div className="contact-sent">
                <div className="check"><Icon name="check" /></div>
                <h3>Thank you</h3>
                <p>Your response has been recorded. We've sent you a copy by email for your records.</p>
              </div>
            ) : (
              <form onSubmit={submit}>
                <h3>Trade reference</h3>
                <p className="desc">Three short questions about your experience renting to {context.applicantName}.</p>

                <div className="step-indicator" aria-hidden="true">
                  {[0, 1, 2].map((i) => {
                    const segStart = i / 3;
                    const segEnd = (i + 1) / 3;
                    const fill = Math.max(0, Math.min(1, (progress - segStart) / (segEnd - segStart)));
                    return (
                      <div key={i} className={`dot ${fill > 0 ? 'filling' : ''}`}>
                        <span className="fill" style={{ transform: `scaleX(${fill})` }} />
                      </div>
                    );
                  })}
                </div>

                <div className="field-row">
                  <div className="field"><label>Your full name</label><input autoComplete="name" value={form.refereeFullName} onChange={set('refereeFullName')} placeholder="So we know it was you" required /></div>
                  <div className="field"><label>Your company</label><input autoComplete="organization" value={form.refereeCompany} onChange={set('refereeCompany')} placeholder="Where you work" required /></div>
                </div>

                <div className="field-row">
                  <div className="field"><label>Your relationship to {context.applicantName.split(' ')[0] || 'them'}</label><input value={form.refereeRelationship} onChange={set('refereeRelationship')} placeholder="e.g. rental house contact, producer, line manager" required /></div>
                  <div className="field"><label>Your phone <span className="oa-opt">(optional)</span></label><input type="tel" autoComplete="tel" inputMode="tel" value={form.refereePhone} onChange={set('refereePhone')} placeholder="+44 7…" /></div>
                </div>

                <div className="field-row">
                  <div className="field full">
                    <label>Would you recommend {context.applicantName.split(' ')[0] || 'them'} as a reliable and trustworthy renter?</label>
                    <div className="ref-radio-row">
                      {['yes', 'no', 'unsure'].map((v) => (
                        <label key={v} className={`ref-radio ${form.recommend === v ? 'active' : ''}`}>
                          <input type="radio" name="recommend" value={v} checked={form.recommend === v} onChange={set('recommend')} />
                          <span>{v.charAt(0).toUpperCase() + v.slice(1)}</span>
                        </label>
                      ))}
                    </div>
                  </div>
                </div>

                <div className="field-row">
                  <div className="field full"><label>Have you previously rented to them? A brief note on your experience.</label><textarea rows="4" value={form.priorRentals} onChange={set('priorRentals')} placeholder="What you rented, how it went, anything notable." /></div>
                </div>

                <div className="field-row">
                  <div className="field full"><label>Any concerns or reasons to suggest they might not be suitable?</label><textarea rows="3" value={form.concerns} onChange={set('concerns')} placeholder="Leave blank if you have none." /></div>
                </div>

                {error && <div className="form-error" role="alert">{error}</div>}

                <button type="submit" className="submit" disabled={sending}>
                  {sending ? 'Sending…' : <>Send response <Icon name="arrow-right" /></>}
                </button>
              </form>
            )}
          </div>
        </div>
      </section>

      <MegaFooter onGoto={onGoto} isRentals />
    </main>
  );
}

Object.assign(window, { RentalsPage, ReferencePage });
