/* ============== NOM NOM TAIWAN — interactions ============== */

// ---------- Dynamic content (menu / schedule) ----------
async function loadContent() {
  let content = null;
  try {
    const r = await fetch("/api/content", { cache: "no-store" });
    if (r.ok) content = await r.json();
  } catch {}
  if (!content || !Array.isArray(content.menu)) {
    try {
      const r = await fetch("content.json", { cache: "no-cache" });
      if (r.ok) content = await r.json();
    } catch {}
  }
  return content || { menu: [], schedule: [] };
}

function parseEm(text) {
  return String(text).split(/(\*[^*]+\*)/g).map((p, i) =>
    p.startsWith("*") && p.endsWith("*") && p.length > 2
      ? <em key={i}>{p.slice(1, -1)}</em>
      : <React.Fragment key={i}>{p}</React.Fragment>
  );
}
function renderName(name) {
  return String(name).split(/\\n|\n/).map((line, i, arr) => (
    <React.Fragment key={i}>
      {parseEm(line)}
      {i < arr.length - 1 && <br/>}
    </React.Fragment>
  ));
}

function MenuItem({ item }) {
  const stars = Math.max(0, Math.min(5, Number(item.stars) || 0));
  const lines = item.imageLines || [];
  return (
    <article className={"menu-item " + (item.size || "col-4") + " reveal"}>
      <div className="number">
        <span>№ {item.num} — {item.category}</span>
        <span>{"★".repeat(stars)}{"☆".repeat(5 - stars)}</span>
      </div>
      <div className="img-wrap" style={{ transform: `rotate(${item.rotation || 0}deg)` }}>
        <div className="polaroid p-lg" style={{ width: "100%" }}>
          <span className="price">{item.price}</span>
          <div className="photo">
            {item.photoUrl ? (
              <img src={item.photoUrl} alt={item.name.replace(/\*/g, '')} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
            ) : (
              <div className={"placeholder ph-" + (item.image || "chicken")}>
                <span>{lines.map((l, i) => (
                  <React.Fragment key={i}>{i > 0 && <br/>}{l}</React.Fragment>
                ))}</span>
              </div>
            )}
          </div>
          <div className="caption">
            <span>{item.imageCaption || ""}</span>
            <span>{item.price}</span>
          </div>
        </div>
      </div>
      <h3>{renderName(item.name)}</h3>
      <div className="tc-name">{item.nameTC}</div>
      <p className="desc">{item.description}</p>
    </article>
  );
}

function ScheduleRow({ row }) {
  return (
    <div className={"schedule-row" + (row.isNext ? " next" : "") + (row.dim ? " closed" : "")}
         onClick={() => { if (!row.dim && window.__truckShow) window.__truckShow(row.date); }}>
      <span className="day">{row.day}</span>
      <span className="place">{row.place}</span>
      <span className="addr">{row.addr}</span>
      <span className="time">{row.dim ? "" : row.time}</span>
      <span className="status">{row.status}</span>
    </div>
  );
}

// ---------- Lenis smooth scroll (adam-ui 預設啟用) ----------
// Damped inertial scroll, synced to GSAP's ticker so ScrollTrigger stays
// frame-accurate. Skipped under reduced-motion. Exposed for anchor links.
function initLenis() {
  const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduce || !window.Lenis) return null;

  // Native CSS smooth-scroll would fight Lenis — hand control to JS.
  document.documentElement.style.scrollBehavior = "auto";

  const lenis = new Lenis({
    duration: 1.15,
    easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // expo.out
    smoothWheel: true,
  });

  if (window.gsap && window.ScrollTrigger) {
    lenis.on("scroll", ScrollTrigger.update);
    gsap.ticker.add((time) => lenis.raf(time * 1000));
    gsap.ticker.lagSmoothing(0);
  } else {
    const raf = (t) => { lenis.raf(t); requestAnimationFrame(raf); };
    requestAnimationFrame(raf);
  }
  window.__lenis = lenis;
  lenis.scrollTo(0, { immediate: true }); // start-at-top also under Lenis control

  // Hold scroll while the opening scene plays; release on handoff.
  if (window.__loaderDone === false) {
    lenis.stop();
    window.addEventListener("nom:loader-done", () => lenis.start(), { once: true });
  }
  return lenis;
}

// ---------- GSAP scroll animations ----------
function initGSAP() {
  if (!window.gsap) return;
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; // elements stay visible, no entrances
  gsap.registerPlugin(ScrollTrigger);

  // Signature damped ease — strong start, long settle (adam-ui物理感).
  let EASE = "expo.out";
  if (window.CustomEase) {
    gsap.registerPlugin(CustomEase);
    CustomEase.create("nom", "0.16, 1, 0.3, 1");
    EASE = "nom";
  }

  initLenis();

  // Wrap each hero word's text in a clip mask so it rises from a hard edge.
  document.querySelectorAll(".hero-title .word").forEach((w) => {
    if (w.querySelector(".word-inner")) return;
    const inner = document.createElement("span");
    inner.className = "word-inner";
    inner.innerHTML = w.innerHTML;
    w.innerHTML = "";
    w.appendChild(inner);
    w.classList.add("is-clip");
  });

  // Hero entrance — cinema timeline (damped, no bounce). Plays only after
  // the loading ritual exits so the two never overlap.
  const heroTl = gsap.timeline({ defaults: { ease: EASE }, paused: true });
  heroTl
    .from(".hero-eyebrow > div", { opacity: 0, y: -24, duration: 0.7, stagger: 0.1 })
    .from(".hero-title .word-inner", { yPercent: 120, duration: 1.1, stagger: 0.09 }, "-=0.45")
    .from(".hero-tc", { scale: 0.25, opacity: 0, duration: 1.1, stagger: 0.12 }, "-=0.95")
    .from(".hero-poly, .hero-poly-mobile .polaroid", { y: 46, scale: 0.9, opacity: 0, duration: 1.0, stagger: 0.1, clearProps: "transform" }, "<0.18")
    .from(".hero-bottom > *", { opacity: 0, y: 40, duration: 0.8, stagger: 0.1 }, "-=0.95");

  const playHero = () => heroTl.play();
  if (window.__loaderDone) playHero();
  else window.addEventListener("nom:loader-done", playHero, { once: true });

  // Section heads
  gsap.utils.toArray(".section-head").forEach((head) => {
    gsap.from(head.querySelectorAll(".num, h2, .tc-mark"), {
      scrollTrigger: { trigger: head, start: "top 80%" },
      y: 60, opacity: 0, duration: 1.2, stagger: 0.12, ease: EASE,
      clearProps: "transform" // never leave residue that CSS can latch onto
    });
  });

  // Story polaroids
  gsap.utils.toArray(".story-poly-stack .polaroid").forEach((p, i) => {
    gsap.from(p, {
      scrollTrigger: { trigger: ".story-poly-stack", start: "top 80%" },
      y: 100, opacity: 0, scale: 0.88,
      duration: 1.4, delay: i * 0.18, ease: EASE,
      clearProps: "scale"
    });
  });

  // Stats
  gsap.utils.toArray(".story-stats .stat").forEach((s, i) => {
    gsap.from(s, {
      scrollTrigger: { trigger: ".story-stats", start: "top 85%" },
      y: 40, opacity: 0, duration: 0.9, delay: i * 0.1, ease: EASE
    });
  });

  // Find / map
  gsap.from(".next-stop", {
    scrollTrigger: { trigger: ".find-grid", start: "top 80%" },
    x: -80, opacity: 0, duration: 1.2, ease: EASE
  });
  gsap.from(".map-card", {
    scrollTrigger: { trigger: ".find-grid", start: "top 80%" },
    x: 80, opacity: 0, duration: 1.2, ease: EASE
  });

}

function animateContent() {
  if (!window.gsap) return;
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
  gsap.utils.toArray(".menu-item").forEach((item) => {
    gsap.from(item, {
      scrollTrigger: { trigger: item, start: "top 85%" },
      y: 80, opacity: 0, duration: 1.0, ease: "power3.out",
    });
  });
  gsap.from(".schedule-row", {
    scrollTrigger: { trigger: ".schedule", start: "top 80%" },
    x: -30, opacity: 0, duration: 0.6, stagger: 0.06,
  });
}

// Static-DOM animations fire as soon as the script runs (independent of /api/content fetch)
if (document.readyState === "complete" || document.readyState === "interactive") {
  initGSAP();
} else {
  document.addEventListener("DOMContentLoaded", initGSAP);
}

// Admins write plain text; we re-apply the site's markup here.
// Legacy values that already contain HTML ("<") pass through untouched.
function esc(s) {
  return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function richCopy(key, val) {
  if (val.includes("<")) return val; // legacy HTML
  let h = esc(val);
  if (key === "marquee") {
    h = h.replace(/·/g, '<span class="sep">·</span>');
  } else {
    h = h.replace(/\*([^*]+)\*/g, "<em>$1</em>");                       // *italic accent*
    h = h.replace(/([\u3000-\u30ff\u4e00-\u9fff，。、！？；：「」]+)/g, '<span class="tc">$1</span>'); // CJK runs
  }
  return h;
}

function applyCopy(copy) {
  if (!copy) return;
  // Photo replacements: replace placeholder with actual image
  ['hero_img1', 'hero_img2', 'hero_img3', 'story_img1', 'story_img2', 'story_img3'].forEach(key => {
    if (!copy[key]) return;
    document.querySelectorAll(`[data-copy="${key}"]`).forEach(el => {
      el.innerHTML = '';
      const img = document.createElement('img');
      img.src = copy[key];
      img.alt = '';
      img.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;';
      el.appendChild(img);
    });
  });
  // innerHTML fields (support <em>, <strong>, <span class="tc"> etc.)
  ['hero_tagline', 'marquee', 'story_lede', 'story_body1', 'story_body2',
   'footer_cta_title', 'footer_cta_body', 'footer_follow_title', 'footer_follow_body',
   'footer_copyright', 'footer_bao'
  ].forEach(key => {
    if (!copy[key]) return;
    const html = richCopy(key, copy[key]);
    document.querySelectorAll(`[data-copy="${key}"]`).forEach(el => { el.innerHTML = html; });
  });
  // Polaroid captions: plain text, two tags (left/right) per photo
  ['hero_cap1L','hero_cap1R','hero_cap2L','hero_cap2R','hero_cap3L','hero_cap3R',
   'story_cap1L','story_cap1R','story_cap2L','story_cap2R','story_cap3L','story_cap3R'
  ].forEach(key => {
    if (!copy[key]) return;
    document.querySelectorAll(`[data-copy="${key}"]`).forEach(el => { el.textContent = copy[key]; });
  });
  // Chinese paragraph: newline → <br/>
  if (copy.story_tc) {
    document.querySelectorAll('[data-copy="story_tc"]').forEach(el => {
      el.innerHTML = copy.story_tc.replace(/\n/g, '<br/>');
    });
  }
  // Link lists: each line "Display name | https://url" → one anchor.
  // A bare URL line gets its hostname as the display name.
  ['footer_cta_links', 'footer_follow'].forEach(key => {
    if (!copy[key]) return;
    const items = copy[key].split('\n').map(l => l.trim()).filter(Boolean).map(line => {
      const i = line.indexOf('|');
      let name = i >= 0 ? line.slice(0, i).trim() : '';
      let url = (i >= 0 ? line.slice(i + 1) : line).trim();
      if (!/^https?:\/\//i.test(url) && url) url = 'https://' + url;
      if (!name) { try { name = new URL(url).hostname.replace(/^www\./, ''); } catch { name = url; } }
      return { name, url };
    }).filter(x => x.url);
    document.querySelectorAll(`[data-links="${key}"]`).forEach(ul => {
      ul.innerHTML = '';
      items.forEach(({ name, url }) => {
        const li = document.createElement('li');
        const a = document.createElement('a');
        a.href = url; a.target = '_blank'; a.rel = 'noopener';
        a.textContent = name;
        li.appendChild(a); ul.appendChild(li);
      });
    });
  });
}

function applySeo(seo) {
  if (!seo) return;
  function setMeta(sel, val) {
    if (!val) return;
    let el = document.querySelector(sel);
    if (!el) { el = document.createElement('meta'); document.head.appendChild(el); }
    el.setAttribute(sel.includes('property') ? 'property' : 'name', sel.match(/["']([^"']+)["']/)?.[1] || '');
    el.content = val;
  }
  if (seo.title)         { document.title = seo.title; document.querySelector('meta[property="og:title"]')?.setAttribute('content', seo.ogTitle || seo.title); }
  if (seo.ogTitle)       document.querySelector('meta[property="og:title"]')?.setAttribute('content', seo.ogTitle);
  if (seo.description)   { setMeta('meta[name="description"]', seo.description); }
  if (seo.ogDescription) document.querySelector('meta[property="og:description"]')?.setAttribute('content', seo.ogDescription);
  if (seo.favicon) {
    const tag = document.getElementById('favicon-tag');
    if (tag) { const p = tag.parentNode; p.removeChild(tag); tag.setAttribute('href', seo.favicon); p.appendChild(tag); }
  }
  if (seo.appleTouchIcon) {
    const tag = document.getElementById('apple-icon-tag');
    if (tag) { const p = tag.parentNode; p.removeChild(tag); tag.setAttribute('href', seo.appleTouchIcon); p.appendChild(tag); }
  }
  // og:image always points to /api/og-image (server serves it from DB) — no client override needed
}

// ---------- Truck state — computed live from Calgary clock ----------
const CAL_TZ = "America/Edmonton";
const WD = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];

function calNowParts() {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone: CAL_TZ, hourCycle: "h23",
    year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
  }).formatToParts(new Date());
  const g = (t) => parts.find((p) => p.type === t)?.value || "00";
  return { date: `${g("year")}-${g("month")}-${g("day")}`, minutes: Number(g("hour")) * 60 + Number(g("minute")) };
}
function calToday() { return calNowParts().date; }

function dayLabel(dateStr) {
  const [y, m, d] = dateStr.split("-").map(Number);
  return `${WD[new Date(y, m - 1, d).getDay()]} ${String(d).padStart(2, "0")}`;
}
function dateEpoch(dateStr) {
  const [y, m, d] = dateStr.split("-").map(Number);
  return new Date(y, m - 1, d).getTime();
}

// "11:00 — 21:00" → minutes-of-day {start, end}; custom text (SOLD OUT…) → null
function parseHours(time) {
  const m = String(time || "").match(/(\d{1,2}):(\d{2})\s*[—–\-~]+\s*(\d{1,2}):(\d{2})/);
  if (!m) return null;
  return { start: Number(m[1]) * 60 + Number(m[2]), end: Number(m[3]) * 60 + Number(m[4]) };
}

// Stylized-map projection (SVG fallback only) — keep in sync with admin.jsx
const MAP_BOUNDS = { latMin: 50.99, latMax: 51.09, lngMin: -114.18, lngMax: -113.97 };
function projectPin(lat, lng) {
  const x = ((lng - MAP_BOUNDS.lngMin) / (MAP_BOUNDS.lngMax - MAP_BOUNDS.lngMin)) * 100;
  const y = ((MAP_BOUNDS.latMax - lat) / (MAP_BOUNDS.latMax - MAP_BOUNDS.latMin)) * 100;
  return { x: Math.max(6, Math.min(94, x)), y: Math.max(8, Math.min(92, y)) };
}

// The single source of truth for "what is the truck doing right now".
// Recomputed every tick so the page can never show stale day data.
//   mode: open | soldout | resting | private | next
//   focus: entry shown big (today if open, otherwise next upcoming)
//   targetMs: countdown — to closing while open, else to next opening
function getTruckState(schedule) {
  const dated = (schedule || []).filter((r) => r && r.date).sort((a, b) => (a.date < b.date ? -1 : 1));
  if (!dated.length) return null;

  const now = calNowParts();
  const st = (e) => String(e?.status || "").toUpperCase();
  const todayE = dated.find((e) => e.date === now.date) || null;
  const hrs = todayE ? parseHours(todayE.time) : null;
  const tba = !!todayE && /TBA/i.test(todayE.time || "");
  const isOpen = !!todayE && st(todayE) === "CONFIRMED" && !tba && (!hrs || (now.minutes >= hrs.start && now.minutes < hrs.end));

  const next = dated.find((e) => {
    const s = st(e);
    if (s !== "UPCOMING" && s !== "CONFIRMED") return false;
    if (e.date > now.date) return true;
    if (e.date === now.date && !isOpen) {
      const h = parseHours(e.time);
      return h ? now.minutes < h.start : false;
    }
    return false;
  }) || null;

  let mode = "next";
  if (isOpen) mode = "open";
  else if (todayE && st(todayE) === "SOLD OUT") mode = "soldout";
  else if (todayE && st(todayE) === "CLOSED") mode = "resting";
  else if (todayE && st(todayE) === "PRIVATE EVENT") mode = "private";
  else if (tba) mode = "tba"; // confirmed/upcoming today but opening time unknown

  const focus = isOpen || mode === "tba" ? todayE : (next || todayE || dated[dated.length - 1]);

  const msUntil = (dateStr, minutes) =>
    (dateEpoch(dateStr) - dateEpoch(now.date)) + (minutes - now.minutes) * 60000;
  let targetMs = null;
  if (isOpen && hrs) targetMs = msUntil(todayE.date, hrs.end);
  else if (next) {
    const h = parseHours(next.time);
    if (h) targetMs = msUntil(next.date, h.start);
  }

  return { mode, todayE, next, focus, targetMs, now };
}

// Paint hero banner + hero meta + find-section card from the current state.
function applyTruckStatus(state) {
  const banner = document.getElementById("hero-truck-live");
  const set = (attr, key, val) =>
    document.querySelectorAll(`[${attr}="${key}"]`).forEach((el) => { el.textContent = val ?? ""; });

  if (!state || !state.focus) { if (banner) banner.classList.remove("ready"); return; }
  const f = state.focus;
  const futureLabel = (e) => `${dayLabel(e.date)} · ${e.time}`;

  // Hero banner — display logic per status
  let label, place, time;
  if (state.mode === "open") {
    label = "OPEN NOW"; place = f.place; time = f.time;
  } else if (state.mode === "tba") {
    label = "TBA"; place = f.place; time = "CHECK INSTAGRAM FOR UPDATE";
  } else if (state.mode === "soldout") {
    label = "SOLD OUT TODAY";
    place = state.next ? state.next.place : "See schedule";
    time = state.next ? futureLabel(state.next) : "";
  } else {
    label = "NEXT STOP"; place = f.place; time = futureLabel(f);
  }
  set("data-truck", "banner-label", label);
  set("data-truck", "banner-place", place);
  set("data-truck", "banner-time", time);
  const headerPill = document.querySelector(".header-truck-live");
  const markReady = () => {
    if (banner) {
      banner.classList.add("ready");
      banner.classList.toggle("is-open", state.mode === "open");
    }
    if (headerPill) headerPill.classList.add("ready"); // centre-expand keyframe
  };
  if (window.__loaderDone === false) {
    window.addEventListener("nom:loader-done", markReady, { once: true });
  } else {
    markReady();
  }

  // Find-section card status label
  const findLabel = {
    open: "LIVE · OPEN NOW",
    tba: "TBA · CHECK INSTAGRAM",
    soldout: "SOLD OUT TODAY",
    resting: "RESTING · NEXT STOP",
    private: "PRIVATE EVENT · NEXT STOP",
    next: "NEXT STOP",
  }[state.mode];
  set("data-truck", "live-label", findLabel);

  // Find card + hero meta place fields (focus entry)
  set("data-next", "place", f.place);
  set("data-next", "time", state.mode === "open" ? f.time : state.mode === "tba" ? "TBA" : futureLabel(f));
  set("data-next", "addr", f.addr);
  set("data-next", "place-short", String(f.place).split(/[&·,]/)[0].trim());
  document.querySelectorAll('[data-next="place-multi"]').forEach((el) => {
    const raw = String(f.place);
    const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;");
    if (raw.includes("&")) {
      const parts = raw.split("&");
      el.innerHTML = `${esc(parts[0].trim())}<br/>&amp; ${esc(parts.slice(1).join("&").trim())}`;
    } else if (/\s-\s/.test(raw)) {
      const parts = raw.split(/\s-\s/);
      el.innerHTML = `${esc(parts[0].trim())}<br/>${esc(parts.slice(1).join(" - ").trim())}`;
    } else {
      el.textContent = raw;
    }
  });

  // Hero meta "Today"
  const tSt = String(state.todayE?.status || "").toUpperCase();
  const todayVal = !state.todayE ? "—"
    : /TBA/i.test(state.todayE.time || "") ? "TBA"
    : tSt === "SOLD OUT" ? "SOLD OUT"
    : tSt === "CLOSED" ? "CLOSED"
    : tSt === "PRIVATE EVENT" ? "PRIVATE"
    : state.todayE.time || "—";
  set("data-next", "today", todayVal);

  // Fryer meta — hot when in service, cool on off days
  const fryerHot = state.mode === "open" || state.mode === "tba" ||
    (state.todayE && (tSt === "CONFIRMED" || tSt === "UPCOMING"));
  set("data-truck", "fryer", fryerHot ? "180°C · 現炸" : "Cool down");
}

// Display rows for the schedule list: weekday label, auto NEXT = focus day.
function prepareSchedule(schedule, state) {
  const dated = (schedule || []).filter((r) => r && r.date).sort((a, b) => (a.date < b.date ? -1 : 1));
  if (!dated.length) return schedule || [];

  const today = state?.now?.date || calToday();
  let view = dated.filter((e) => e.date >= today);
  if (!view.length) view = dated.slice(-7);
  view = view.slice(0, 7);

  return view.map((e) => ({
    ...e,
    day: dayLabel(e.date),
    isNext: e.date === state?.focus?.date,
    dim: String(e.status).toUpperCase() === "CLOSED",
  }));
}

// Clock + countdown every second; full status repaint every minute and on
// tab refocus — the user can never see yesterday's state from cache.
function startTruckTicker(schedule) {
  function tick() {
    const state = getTruckState(schedule);

    const parts = new Intl.DateTimeFormat("en-US", {
      timeZone: CAL_TZ, hourCycle: "h23", hour: "2-digit", minute: "2-digit", timeZoneName: "short",
    }).formatToParts(new Date());
    const g = (t) => parts.find((p) => p.type === t)?.value || "";
    document.querySelectorAll('[data-truck="clock"]').forEach((el) => {
      el.textContent = `${g("hour")}:${g("minute")} ${g("timeZoneName")}`;
    });

    const cd = document.getElementById("countdown");
    if (state && state.targetMs != null && state.targetMs > 0) {
      let diff = state.targetMs;
      const h = Math.floor(diff / 3600000); diff -= h * 3600000;
      const m = Math.floor(diff / 60000); diff -= m * 60000;
      const s = Math.floor(diff / 1000);
      const pad = (n) => String(n).padStart(2, "0");
      document.querySelectorAll('[data-ct="h"]').forEach((el) => (el.textContent = pad(h)));
      document.querySelectorAll('[data-ct="m"]').forEach((el) => (el.textContent = pad(m)));
      document.querySelectorAll('[data-ct="s"]').forEach((el) => (el.textContent = pad(s)));
      if (cd) cd.style.display = "";
    } else if (cd) {
      cd.style.display = "none";
    }
  }
  tick();
  setInterval(tick, 1000);
  setInterval(() => applyTruckStatus(getTruckState(schedule)), 60000);
  document.addEventListener("visibilitychange", () => {
    if (!document.hidden) applyTruckStatus(getTruckState(schedule));
  });
}

async function bootSite() {
  const content = await loadContent();

  applyCopy(content.copy);
  applySeo(content.seo);

  const state = getTruckState(content.schedule);
  const sched = prepareSchedule(content.schedule, state);
  applyTruckStatus(state);
  renderUpcomingMini(sched);
  startTruckTicker(content.schedule);
  initTruckMap(content.schedule, state);

  const menuRoot = document.getElementById("menu-grid-root");
  if (menuRoot) {
    ReactDOM.createRoot(menuRoot).render(
      <>{(content.menu || []).map((m, i) => <MenuItem key={m.id || `m-${i}`} item={m} />)}</>
    );
  }

  const schedRoot = document.getElementById("schedule-root");
  if (schedRoot) {
    ReactDOM.createRoot(schedRoot).render(
      <>{sched.map((r, i) => <ScheduleRow key={r.id || `s-${i}`} row={r} />)}</>
    );
  }

  // Wait two frames for React to commit + paint, then animate dynamic content
  requestAnimationFrame(() => requestAnimationFrame(animateContent));
}
bootSite();



// ---------- Map pins — data-driven from admin schedule ----------
function directionsUrl(row) {
  if (row.gmaps) return row.gmaps;
  const q = [row.place, row.addr, "Calgary AB"].filter(Boolean).join(" ");
  return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(q)}`;
}

function renderMapPins(schedule) {
  const root = document.getElementById("map-pins-root");
  if (!root) return;
  root.innerHTML = "";

  const dirBtn = document.getElementById("directions-btn");
  const rows = (schedule || [])
    .map((r) => {
      // lat/lng (new schema) → projected %; legacy mapX/mapY still honoured
      if (r.lat != null && r.lng != null) {
        const p = projectPin(r.lat, r.lng);
        return { ...r, mapX: p.x, mapY: p.y };
      }
      return r;
    })
    .filter((r) => r.mapX != null && r.mapY != null && !r.dim);

  // Default directions target = NEXT stop (or first pinned row)
  const next = rows.find((r) => r.isNext) || rows[0];
  if (dirBtn && next) dirBtn.href = directionsUrl(next);

  rows.forEach((r) => {
    const pin = document.createElement("div");
    pin.className = "map-pin" + (r.isNext ? " active" : "");
    pin.style.left = r.mapX + "%";
    pin.style.top = r.mapY + "%";
    if (r.mapX > 65) pin.classList.add("label-left");

    const label = `${r.day} · ${r.place}`.toUpperCase();
    pin.innerHTML = `<div class="head"></div><div class="label"></div>`;
    pin.querySelector(".label").textContent = label;

    pin.addEventListener("click", () => {
      root.querySelectorAll(".map-pin").forEach((p) => p.classList.remove("active"));
      pin.classList.add("active");
      if (dirBtn) dirBtn.href = directionsUrl(r);
    });

    // Keyboard accessibility
    pin.setAttribute("role", "button");
    pin.setAttribute("tabindex", "0");
    pin.setAttribute("aria-label", `Show directions target: ${label}`);
    pin.addEventListener("keydown", (e) => {
      if (e.key === "Enter" || e.key === " ") { e.preventDefault(); pin.click(); }
    });

    root.appendChild(pin);
  });

  // Pins pop in when map scrolls into view (damped, no bounce)
  if (window.gsap && window.ScrollTrigger && rows.length) {
    gsap.from(root.querySelectorAll(".map-pin"), {
      scrollTrigger: { trigger: "#map-card", start: "top 80%" },
      scale: 0, opacity: 0, transformOrigin: "bottom center",
      duration: 0.9, stagger: 0.08, ease: "expo.out", clearProps: "all"
    });
  }
}

// ---------- Header: scroll-aware bg + dark-section detection ----------
(function () {
  const header = document.querySelector(".site-header");
  if (!header) return;
  const darkSelectors = ".story, .footer, .marquee";

  function update() {
    header.classList.toggle("is-scrolled", window.scrollY > 24);

    // Sample point just below header midline
    const probeY = header.getBoundingClientRect().bottom - 6;
    let onDark = false;
    document.querySelectorAll(darkSelectors).forEach((s) => {
      const r = s.getBoundingClientRect();
      if (r.top <= probeY && r.bottom >= probeY) onDark = true;
    });
    header.classList.toggle("is-on-dark", onDark);
  }

  let ticking = false;
  window.addEventListener("scroll", () => {
    if (!ticking) {
      requestAnimationFrame(() => { update(); ticking = false; });
      ticking = true;
    }
  }, { passive: true });
  update();
})();

// ---------- In-page anchor smooth scroll (respects reduced motion) ----------
document.querySelectorAll('a[href^="#"]').forEach((a) => {
  a.addEventListener("click", (e) => {
    const id = a.getAttribute("href");
    if (!id || id === "#") return;
    const target = document.querySelector(id);
    if (!target) return;
    e.preventDefault();
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    if (window.__lenis && !reduce) {
      window.__lenis.scrollTo(target, { offset: -80 });
    } else {
      target.scrollIntoView({ behavior: reduce ? "auto" : "smooth", block: "start" });
    }
    history.replaceState(null, "", id);
  });
});

// ---------- Real map — OpenStreetMap via Leaflet, site-tinted ----------
// Stops at the same spot are grouped into ONE pin; clicking it opens a
// popup listing that location's days + hours with a GO → directions button.
function renderUpcomingMini(sched) {
  const el = document.getElementById("upcoming-mini");
  if (!el) return;
  const items = (sched || []).filter((r) => r.date && !r.dim).slice(0, 4);
  el.innerHTML = "";
  items.forEach((r) => {
    const row = document.createElement("div");
    row.className = "um-row" + (r.isNext ? " on" : "");
    const mk = (cls, txt) => { const s = document.createElement("span"); if (cls) s.className = cls; s.textContent = txt; row.appendChild(s); };
    mk("", r.day); mk("um-place", r.place); mk("", r.time || "");
    row.addEventListener("click", () => window.__truckShow && window.__truckShow(r.date));
    el.appendChild(row);
  });
}

// Placeholder vector food truck — same art as admin; swap once the photo is traced.
const TRUCK_SVG = '<svg width="48" height="36" viewBox="0 0 48 36" xmlns="http://www.w3.org/2000/svg"><!-- chrome exhaust stack + round sign on the roof --><rect x="29" y="1.5" width="4.6" height="5" rx="0.8" fill="#9aa0a6" stroke="#0E0A0A" stroke-width="0.8"/><circle cx="13.5" cy="4.8" r="3" fill="#0E0A0A"/><circle cx="13.5" cy="4.8" r="1.1" fill="#DA1884"/><!-- white step-van body --><path d="M5.5 9.5 Q5.5 6.5 8.5 6.5 H42 Q45 6.5 45 9.5 V26 H5.5 Z" fill="#FFFFFF" stroke="#0E0A0A" stroke-width="1.6"/><!-- cab door + windows --><path d="M5.8 13 Q8 8.2 12.5 8.2 V26 H5.8 Z" fill="#FFFFFF" stroke="#0E0A0A" stroke-width="1"/><rect x="7.2" y="9.6" width="4.2" height="5.6" rx="1" fill="#BFD2DE" stroke="#0E0A0A" stroke-width="0.9"/><!-- serving window with open flap --><rect x="30.5" y="10.5" width="9.5" height="7" rx="0.8" fill="#2A1F22" stroke="#0E0A0A" stroke-width="1"/><path d="M29.8 10.5 L41 8.4" stroke="#0E0A0A" stroke-width="1.2" stroke-linecap="round"/><!-- NOM NOM TAIWAN livery --><text x="21.5" y="14.2" text-anchor="middle" textLength="14.5" lengthAdjust="spacingAndGlyphs" font-family="Arial Black, Arial, sans-serif" font-weight="900" font-size="3.5" fill="#DA1884">NOM NOM</text><text x="21.5" y="17.8" text-anchor="middle" textLength="9.5" lengthAdjust="spacingAndGlyphs" font-family="Arial, sans-serif" font-weight="700" font-size="2.3" fill="#DA1884">TAIWAN</text><!-- diamond-plate panel --><rect x="33" y="20" width="9" height="6" fill="#D7D9DB" stroke="#0E0A0A" stroke-width="0.8"/><!-- wheels --><circle cx="12" cy="27.5" r="4.6" fill="#0E0A0A" stroke="#FFFFFF" stroke-width="1.4"/><circle cx="12" cy="27.5" r="1.5" fill="#6b625c"/><circle cx="36" cy="27.5" r="4.6" fill="#0E0A0A" stroke="#FFFFFF" stroke-width="1.4"/><circle cx="36" cy="27.5" r="1.5" fill="#6b625c"/></svg>';

function initTruckMap(schedule, state) {
  const card = document.getElementById("map-card");
  const el = document.getElementById("osm-map");
  const today = state?.now?.date || calToday();
  const dated = (schedule || []).filter((r) => r && r.date && r.lat != null && r.lng != null);
  let rows = dated.filter((e) => e.date >= today && String(e.status).toUpperCase() !== "CLOSED").slice(0, 14);
  if (!rows.length) rows = dated.slice(-7);

  if (!window.L || !el || !card || !rows.length) {
    renderMapPins(prepareSchedule(schedule, state));
    return;
  }

  card.classList.add("has-osm");
  const map = L.map(el, { scrollWheelZoom: false });
  L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
    attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>',
    subdomains: "abcd",
    maxZoom: 19,
  }).addTo(map);

  // Desktop: wheel zoom arms on first interaction with the map, disarms on
  // leave — no accidental page-scroll hijack, no zoom-button fatigue.
  el.addEventListener("click", () => map.scrollWheelZoom.enable());
  el.addEventListener("mouseenter", () => map.scrollWheelZoom.enable());
  el.addEventListener("mouseleave", () => map.scrollWheelZoom.disable());

  const dirBtn = document.getElementById("directions-btn");
  if (dirBtn && state?.focus) dirBtn.href = directionsUrl(state.focus);

  // Group same-location stops into one pin
  const groups = new Map();
  rows.forEach((r) => {
    const key = `${Number(r.lat).toFixed(4)},${Number(r.lng).toFixed(4)}`;
    if (!groups.has(key)) groups.set(key, []);
    groups.get(key).push(r);
  });

  const focusDate = state?.focus?.date;
  const markersByDate = {};
  const markers = [];

  // Side mode (desktop): popup opens to the RIGHT of the pin, and the map
  // pans so the pin sits in the left third — more room for the info card.
  const sideMode = el.clientWidth > 520;

  // Today's CONFIRMED stop gets the truck icon; everything else keeps the pin
  const tSt = String(state?.todayE?.status || "").toUpperCase();
  const truckDate = state?.todayE && (state.mode === "open" || tSt === "CONFIRMED") ? state.todayE.date : null;

  groups.forEach((list) => {
    const first = list[0];
    const isActive = list.some((r) => r.date === focusDate);
    const isTruck = truckDate && list.some((r) => r.date === truckDate);
    const icon = L.divIcon({
      className: isTruck ? "truck-pin-map" : "map-pin" + (isActive ? " active" : ""),
      html: isTruck ? TRUCK_SVG : '<div class="head"></div>',
      iconSize: isTruck ? [48, 36] : [44, 44],
      iconAnchor: isTruck ? [24, 32] : [22, 36],
      popupAnchor: sideMode ? [150, 60] : [0, -34],
    });
    const m = L.marker([first.lat, first.lng], { icon, title: first.place, keyboard: true }).addTo(map);

    const pop = document.createElement("div");
    pop.className = "truck-popup";
    const title = document.createElement("div");
    title.className = "tp-place";
    title.textContent = first.place;
    pop.appendChild(title);
    const rowsBox = document.createElement("div");
    rowsBox.className = "tp-rows";
    list.forEach((r) => {
      const rowEl = document.createElement("div");
      rowEl.className = "tp-row";
      const d = document.createElement("span"); d.textContent = dayLabel(r.date);
      const t = document.createElement("span"); t.textContent = r.time || "";
      rowEl.appendChild(d); rowEl.appendChild(t);
      rowsBox.appendChild(rowEl);
    });
    pop.appendChild(rowsBox);
    const go = document.createElement("a");
    go.className = "tp-go";
    go.href = directionsUrl(first);
    go.target = "_blank";
    go.rel = "noopener";
    go.textContent = "GO →";
    pop.appendChild(go);
    m.bindPopup(pop, { closeButton: false, maxWidth: 280, className: sideMode ? "side-pop" : "", autoPan: !sideMode });

    m.on("popupopen", () => {
      if (!sideMode) return;
      // Pan so the pin lands in the left third of the frame
      const z = map.getZoom();
      const c = map.unproject(map.project(m.getLatLng(), z).add([map.getSize().x * 0.22, 0]), z);
      map.panTo(c, { animate: true, duration: 0.5 });
    });

    m.on("click", () => {
      markers.forEach((x) => x.getElement()?.classList.remove("active"));
      m.getElement()?.classList.add("active");
      if (dirBtn) dirBtn.href = directionsUrl(first);
    });

    list.forEach((r) => { markersByDate[r.date] = m; });
    markers.push(m);
  });

  if (markers.length === 1) {
    map.setView(markers[0].getLatLng(), 14);
  } else {
    map.fitBounds(L.featureGroup(markers).getBounds().pad(0.35), { maxZoom: 14 });
  }

  // Schedule rows / upcoming list → scroll to the map, open that pin
  window.__truckShow = (date) => {
    const m = markersByDate[date];
    if (!m) return;
    card.scrollIntoView({ behavior: "smooth", block: "center" });
    setTimeout(() => {
      if (map.getZoom() < 13) map.setZoom(13);
      m.openPopup(); // popupopen handler pans the pin into the left third
      markers.forEach((x) => x.getElement()?.classList.remove("active"));
      m.getElement()?.classList.add("active");
    }, 350);
  };
}

// ---------- Notify Me — calendar subscription (ICS feed) ----------
// iOS/macOS: webcal:// opens Calendar's subscribe dialog.
// Android: hand the feed to Google Calendar's "add by URL".
// Subscribed calendars re-fetch, so SOLD OUT / cancellations propagate.
(function () {
  const btn = document.getElementById("notify-btn");
  if (!btn) return;
  const feed = `${location.host}/api/calendar`;
  btn.href = /android/i.test(navigator.userAgent)
    ? `https://calendar.google.com/calendar/r?cid=${encodeURIComponent("webcal://" + feed)}`
    : `webcal://${feed}`;
})();
