/* global React, Link, Marker, Arrow, Ph, useMedia, Newsletter */
// react hooks accessed via React.*

// ============================================================
// CALENDAR PAGE — replaces the previous CalendarRedirect stub.
//
// Aggregates events across THREE sources (all hardcoded for now):
//   1. Box Office (ticketed Playhouse + dining experiences)
//   2. Retreats   (multi-day, rendered as a span bar)
//   3. Live Music on the Terrace (free, no ticket)
//
// Future: Live Music will likely sync from Google Sheets.
// Keep the EVENTS array shape stable so a swap to async fetch
// is mostly mechanical — the component reads from a single
// list filtered/grouped by date and category.
//
// CTAs are context-sensitive:
//   • Box Office → "Get Tickets" → tickets.allenberry.com
//   • Retreats   → "Reserve your spot" → /retreat-<slug>
//   • Live Music → no CTA (drop-in event; popover is informational only)
//
// No external libraries. Plain Date math, plain React state.
// ============================================================

// ---- CATEGORY META ----
const CAL_CATS = {
  "box-office":  { label: "Box Office",   token: "var(--accent)",
                   pill: "var(--accent)", pillFg: "#fbf8f2" },
  "retreats":    { label: "Retreats",     token: "var(--accent-2)",
                   pill: "var(--accent-2)", pillFg: "#fbf8f2" },
  "live-music":  { label: "Music on the Terrace", token: "color-mix(in oklab, var(--accent), var(--cream) 55%)",
                   pill: "color-mix(in oklab, var(--accent), var(--cream) 55%)",
                   pillFg: "color-mix(in oklab, var(--accent), var(--ink) 55%)" },
};

// ---- EVENT DATA ----
// Stable shape:
//   { id, title, dateStart (YYYY-MM-DD), dateEnd? (YYYY-MM-DD, inclusive),
//     time?, category, description, ctaText?, ctaUrl?, isExternal? }
// Lifted from the archived components/CalendarData.jsx.
const TICKETS_URL = "https://tickets.allenberry.com";

const CAL_EVENTS = [
  // ---- BOX OFFICE (ticketed) ----
  { id: "bo-meghan-cary-2026-05-15", title: "Meghan Cary with Erica Everest",
    dateStart: "2026-05-15", time: "Doors 6:15 · Show 7:00",
    category: "box-office",
    description: "Opening night of the Allenberry Concert Series. Folk, Americana, acoustic pop.",
    ctaText: "Get Tickets", ctaUrl: "https://tickets.allenberry.com/events/allenberryresortspa/2191985", isExternal: true },

  { id: "bo-mothers-day-2026-05-10", title: "Mother's Day Brunch",
    dateStart: "2026-05-10", time: "10am – 2pm",
    category: "box-office",
    description: "Three seatings at The Barn. Reservations required.",
    soldOut: true },

  { id: "bo-jeffrey-gaines-2026-06-27", title: "Jeffrey Gaines with Sarah Fiore",
    dateStart: "2026-06-27", time: "Doors 6:15 · Show 7:00",
    category: "box-office",
    description: "Introspective lyrics, soulful voice — in a room that rewards listening.",
    ctaText: "Get Tickets", ctaUrl: "https://tickets.allenberry.com/events/allenberryresortspa/2194106", isExternal: true },

  { id: "bo-pa-wine-dinner-2026-07-12", title: "PA Wine Dinner",
    dateStart: "2026-07-12", time: "6:30pm",
    category: "box-office",
    description: "A tasting menu paired with Pennsylvania vintners.",
    ctaText: "Get Tickets", ctaUrl: TICKETS_URL, isExternal: true },

  // ---- RETREATS (multi-day) ----
  { id: "ret-ripples-of-joy-2026-06-06", title: "Ripples of Joy",
    dateStart: "2026-06-06", dateEnd: "2026-06-08",
    time: "June 6–8 · arrivals 3pm",
    category: "retreats",
    description: "Three days away with Dr. Jenn Olivetti. Guided mindfulness, mindful movement, and activities designed to cultivate genuine joy.",
    ctaText: "Reserve your spot", ctaUrl: "retreat-ripples-of-joy", isExternal: false },

  // ---- LIVE MUSIC ON THE TERRACE (drop-in, no cover) ----
  // Lifted from components/CalendarData.jsx (archived). Free events.
  { id: "lm-2026-05-01", title: "Hopeless Semantics", dateStart: "2026-05-01", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover, tavern menu all evening." },
  { id: "lm-2026-05-02", title: "Brad Bell",          dateStart: "2026-05-02", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-08", title: "Ben Simcox",         dateStart: "2026-05-08", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-09", title: "Hemlock Hollow",     dateStart: "2026-05-09", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-16", title: "Shine Delphi",       dateStart: "2026-05-16", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-22", title: "Brad Bell",          dateStart: "2026-05-22", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-23", title: "Dave McCullough",    dateStart: "2026-05-23", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-29", title: "Hopeless Semantics", dateStart: "2026-05-29", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-05-30", title: "Hemlock Hollow",     dateStart: "2026-05-30", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-05", title: "Justin Murphy",      dateStart: "2026-06-05", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-06", title: "Hemlock Hollow",     dateStart: "2026-06-06", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-12", title: "Brad Bell",          dateStart: "2026-06-12", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-19", title: "Ben Simcox",         dateStart: "2026-06-19", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-20", title: "Shine Delphi",       dateStart: "2026-06-20", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-06-26", title: "Hemlock Hollow",     dateStart: "2026-06-26", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-03", title: "Shine Delphi",       dateStart: "2026-07-03", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-04", title: "Hemlock Hollow",     dateStart: "2026-07-04", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-10", title: "Brad Bell",          dateStart: "2026-07-10", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-11", title: "Dale Stipe",         dateStart: "2026-07-11", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-12", title: "The LeBlancs",       dateStart: "2026-07-12", time: "6–9pm", category: "live-music", description: "A featured Sunday evening with The LeBlancs. Drop in — no cover." },
  { id: "lm-2026-07-17", title: "Justin Murphy",      dateStart: "2026-07-17", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-18", title: "Shine Delphi",       dateStart: "2026-07-18", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-24", title: "Brad Bell",          dateStart: "2026-07-24", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-25", title: "Thom Lewis",         dateStart: "2026-07-25", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-07-31", title: "Hemlock Hollow",     dateStart: "2026-07-31", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-01", title: "Shine Delphi",       dateStart: "2026-08-01", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-07", title: "Brad Bell",          dateStart: "2026-08-07", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-08", title: "Hemlock Hollow",     dateStart: "2026-08-08", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-09", title: "The LeBlancs",       dateStart: "2026-08-09", time: "6–9pm", category: "live-music", description: "A featured Sunday evening with The LeBlancs. Drop in — no cover." },
  { id: "lm-2026-08-14", title: "Dale Stipe",         dateStart: "2026-08-14", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-15", title: "Connor Petula & Connor Landis · Jazz", dateStart: "2026-08-15", time: "6–9pm", category: "live-music", description: "A jazz evening on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-21", title: "Brad Bell",          dateStart: "2026-08-21", time: "6–9pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
  { id: "lm-2026-08-22", title: "Shine Delphi",       dateStart: "2026-08-22", time: "6–8pm", category: "live-music", description: "Live music on The Terrace. Drop in — no cover." },
];

// ---- DATE HELPERS (no library) ----
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
const DOW    = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];

// Parse YYYY-MM-DD as a LOCAL Date (avoid UTC drift).
function parseISO(s) {
  const [y, m, d] = s.split("-").map(Number);
  return new Date(y, m - 1, d);
}
function isoOf(d) {
  const y = d.getFullYear(), m = String(d.getMonth() + 1).padStart(2, "0"), day = String(d.getDate()).padStart(2, "0");
  return `${y}-${m}-${day}`;
}
function sameDay(a, b) {
  return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }

// Build a 6-row × 7-col matrix of Date objects for a given (year, monthIdx).
// First cell = the Sunday on or before the 1st.
function monthMatrix(year, monthIdx) {
  const first = new Date(year, monthIdx, 1);
  const start = addDays(first, -first.getDay());
  const cells = [];
  for (let i = 0; i < 42; i++) cells.push(addDays(start, i));
  return cells;
}

// Case-insensitive substring match on event title only. Empty/whitespace
// query is "no filter" — caller checks query.trim().length before this
// runs. Title-only matching is intentional; description and category
// are not searched (per spec).
function matchesQuery(event, normalizedQuery) {
  return event.title.toLowerCase().includes(normalizedQuery);
}

// Format a single event's date+time for the search-result row meta line.
// Single-day: "May 1 · 6–9pm". Span: "Jun 6 – 8" or "Jun 30 – Jul 2"
// when crossing months. Time appended only when present.
const SHORT_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
function formatEventDateRange(event) {
  const start = parseISO(event.dateStart);
  if (!event.dateEnd) {
    const base = `${SHORT_MONTHS[start.getMonth()]} ${start.getDate()}`;
    return event.time ? `${base} · ${event.time}` : base;
  }
  const end = parseISO(event.dateEnd);
  const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
  if (sameMonth) {
    return `${SHORT_MONTHS[start.getMonth()]} ${start.getDate()} – ${end.getDate()}`;
  }
  return `${SHORT_MONTHS[start.getMonth()]} ${start.getDate()} – ${SHORT_MONTHS[end.getMonth()]} ${end.getDate()}`;
}

// For a given date, return the ordered list of events that touch it.
// Multi-day retreats appear on every covered day.
function eventsOnDay(date) {
  const iso = isoOf(date);
  return CAL_EVENTS.filter((e) => {
    if (e.dateEnd) return iso >= e.dateStart && iso <= e.dateEnd;
    return e.dateStart === iso;
  });
}

// Earliest available month in the data — used to keep the user
// from paging back into empty void.
const FIRST_MONTH = (() => {
  const earliest = CAL_EVENTS.reduce((acc, e) => (e.dateStart < acc ? e.dateStart : acc), CAL_EVENTS[0].dateStart);
  const d = parseISO(earliest);
  return new Date(d.getFullYear(), d.getMonth(), 1);
})();
const LAST_MONTH = (() => {
  const latest = CAL_EVENTS.reduce((acc, e) => {
    const end = e.dateEnd || e.dateStart;
    return end > acc ? end : acc;
  }, CAL_EVENTS[0].dateStart);
  const d = parseISO(latest);
  return new Date(d.getFullYear(), d.getMonth(), 1);
})();

// ============================================================
// COMPONENT
// ============================================================
function CalendarPage() {
  const isMobile = useMedia("(max-width: 760px)");
  const today = React.useMemo(() => new Date(), []);

  // Default month: today's month if it has events, else first event month.
  const initial = React.useMemo(() => {
    const m = new Date(today.getFullYear(), today.getMonth(), 1);
    if (m < FIRST_MONTH) return FIRST_MONTH;
    if (m > LAST_MONTH) return LAST_MONTH;
    return m;
  }, [today]);

  const [cursor, setCursor] = React.useState(initial); // first-of-month Date
  const [selected, setSelected] = React.useState(null); // event id (modal)
  const [query, setQuery] = React.useState("");

  // Normalized query — lowercased, trimmed. Empty string means "no
  // search active" and the results list isn't rendered at all.
  const normalizedQuery = query.trim().toLowerCase();
  const hasQuery = normalizedQuery.length > 0;

  const canPrev = cursor > FIRST_MONTH;
  const canNext = cursor < LAST_MONTH;

  const goPrev = () => canPrev && setCursor(new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1));
  const goNext = () => canNext && setCursor(new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1));

  const matrix = React.useMemo(
    () => monthMatrix(cursor.getFullYear(), cursor.getMonth()),
    [cursor]
  );

  const selectedEvent = selected ? CAL_EVENTS.find((e) => e.id === selected) : null;

  // Search results. Filter on title, sort by dateStart, cap at 10 with
  // an overflow caption. Calendar grid is unaffected — search and grid
  // are independent surfaces.
  const RESULTS_CAP = 10;
  const allMatches = hasQuery
    ? CAL_EVENTS
        .filter((e) => matchesQuery(e, normalizedQuery))
        .slice()
        .sort((a, b) => a.dateStart.localeCompare(b.dateStart))
    : [];
  const visibleResults = allMatches.slice(0, RESULTS_CAP);
  const overflowCount = Math.max(0, allMatches.length - RESULTS_CAP);

  // Click handler for a search result. Events with a ctaUrl (Box
  // Office, Retreats) render their own anchor/Link elements — the row
  // IS the navigation — and share this onClick only to clear the
  // search input afterward. Events without a ctaUrl (Live Music
  // drop-ins; sold-out Box Office events) have no external destination,
  // so the row is a <button>: advance the calendar's cursor to the
  // event's month and open EventModal in place to show the details.
  const handleResultClick = (event) => {
    if (!event.ctaUrl) {
      const eventDate = parseISO(event.dateStart);
      const eventMonth = new Date(eventDate.getFullYear(), eventDate.getMonth(), 1);
      if (eventMonth.getTime() !== cursor.getTime()) setCursor(eventMonth);
      setSelected(event.id);
    }
    // Clear input after click — search is "done", user got what they
    // wanted. Re-clicking the input from scratch is a fresh search.
    setQuery("");
  };

  return (
    <div data-screen-label="Calendar">
      {/* ---- INTRO ---- */}
      <section style={{ paddingTop: 140, paddingBottom: 24 }}>
        <div className="container">
          <Marker n={1} label="AROUND THE RESORT · BOILING SPRINGS, PA" />
          <h1 style={{ marginTop: 24, marginBottom: 24, maxWidth: 1100 }}>
            Everything <em style={{ fontStyle: "italic", color: "var(--accent)" }}>happening.</em>
          </h1>
          <p style={{ fontSize: 20, maxWidth: 720, color: "var(--ink-soft)", marginTop: 8 }}>
            Concerts at the Playhouse, multi-day retreats, free live music outdoors through summer, and the occasional reason to stay longer.
          </p>
        </div>
      </section>

      {/* ---- LEGEND ---- */}
      <section style={{ padding: "16px 0 24px" }}>
        <div className="container" style={{ display: "flex", gap: 28, flexWrap: "wrap", alignItems: "center" }}>
          {Object.entries(CAL_CATS).map(([key, c]) => (
            <div key={key} style={{ display: "flex", alignItems: "center", gap: 10 }}>
              <span style={{ width: 16, height: 16, background: c.token, display: "inline-block", borderRadius: 2 }} />
              <span style={{ fontSize: 12, letterSpacing: "0.14em", textTransform: "uppercase", color: "var(--ink-soft)" }}>
                {c.label}
              </span>
            </div>
          ))}
        </div>
      </section>

      {/* ---- MONTH GRID ---- */}
      <section style={{ paddingBottom: 96 }}>
        <div className="container">
          {/* month header — desktop: month label LEFT, search input + nav
              arrows CLUSTERED RIGHT. Mobile: month + arrows on top
              (declarative content stays primary), search input below
              as a full-width row (secondary tool, stacks below). The
              month-h2 and arrow-button styling are unchanged from the
              pre-search calendar; the only structural change is that
              the search input now lives between them on desktop. */}
          {isMobile ? (
            <>
              <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginBottom: 16, gap: 16 }}>
                <h2 style={{ fontSize: 44, margin: 0, letterSpacing: "-0.01em" }}>
                  {MONTHS[cursor.getMonth()]} <em style={{ fontStyle: "italic", color: "var(--accent)" }}>{cursor.getFullYear()}</em>
                </h2>
                <div style={{ display: "flex", gap: 8 }}>
                  <button onClick={goPrev} disabled={!canPrev} aria-label="Previous month" style={navBtnStyle(!canPrev)}>
                    <svg width="14" height="14" viewBox="0 0 14 14"><path d="M9 2L4 7l5 5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
                  </button>
                  <button onClick={goNext} disabled={!canNext} aria-label="Next month" style={navBtnStyle(!canNext)}>
                    <svg width="14" height="14" viewBox="0 0 14 14"><path d="M5 2l5 5-5 5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
                  </button>
                </div>
              </div>
              <div style={{ marginBottom: 24 }}>
                <CalendarSearchInput query={query} setQuery={setQuery} hasQuery={hasQuery} fullWidth />
              </div>
            </>
          ) : (
            <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 24, gap: 16 }}>
              <h2 style={{ fontSize: 44, margin: 0, letterSpacing: "-0.01em" }}>
                {MONTHS[cursor.getMonth()]} <em style={{ fontStyle: "italic", color: "var(--accent)" }}>{cursor.getFullYear()}</em>
              </h2>
              <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
                <CalendarSearchInput query={query} setQuery={setQuery} hasQuery={hasQuery} />
                <div style={{ display: "flex", gap: 8 }}>
                  <button onClick={goPrev} disabled={!canPrev} aria-label="Previous month" style={navBtnStyle(!canPrev)}>
                    <svg width="14" height="14" viewBox="0 0 14 14"><path d="M9 2L4 7l5 5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
                  </button>
                  <button onClick={goNext} disabled={!canNext} aria-label="Next month" style={navBtnStyle(!canNext)}>
                    <svg width="14" height="14" viewBox="0 0 14 14"><path d="M5 2l5 5-5 5" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round"/></svg>
                  </button>
                </div>
              </div>
            </div>
          )}

          {/* search results list — pushes the grid down when query has
              text. Hidden entirely when query is empty: zero vertical
              footprint, the page reads identically to "no search". */}
          {hasQuery && (
            <CalendarSearchResults
              query={query}
              results={visibleResults}
              overflowCount={overflowCount}
              hasAnyMatch={allMatches.length > 0}
              onResultClick={handleResultClick}
            />
          )}

          {isMobile ? (
            /* Mobile: events for the current month rendered as a
               vertical list. Replaces the 7-column grid which became
               unusable at narrow widths (mostly empty cells, single-
               letter DOW headers, truncated titles). The list shows
               full titles, no truncation, and the existing mobile
               pill-tap behavior — tap row → modal opens with details
               — generalizes naturally to "tap event in the list". */
            <MobileEventList cursor={cursor} onRowClick={setSelected} />
          ) : (
            <>
              {/* day-of-week header */}
              <div style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", borderTop: "1px solid var(--rule)", borderLeft: "1px solid var(--rule)" }}>
                {DOW.map((d) => (
                  <div key={d} style={{
                    padding: "12px 14px",
                    fontSize: 11, letterSpacing: "0.14em", textTransform: "uppercase",
                    color: "var(--ink-mute)",
                    borderRight: "1px solid var(--rule)",
                    borderBottom: "1px solid var(--rule)",
                    background: "var(--bg-elev)",
                  }}>{d}</div>
                ))}
              </div>

              {/* day cells */}
              <div style={{ display: "grid", gridTemplateColumns: "repeat(7, minmax(0, 1fr))", borderLeft: "1px solid var(--rule)" }}>
                {matrix.map((d, i) => {
                  const inMonth = d.getMonth() === cursor.getMonth();
                  const isToday = sameDay(d, today);
                  const evs = eventsOnDay(d);
                  return (
                    <DayCell
                      key={i}
                      date={d}
                      inMonth={inMonth}
                      isToday={isToday}
                      events={evs}
                      cursor={cursor}
                      onSelect={setSelected}
                      isMobile={isMobile}
                    />
                  );
                })}
              </div>
            </>
          )}
        </div>
      </section>

      {/* ---- CATEGORY CARDS ---- */}
      {/* Three image-led cards, each pointing somewhere actionable:
            1. Concert Series  → Ticket Tailor box office
            2. Live Music on the Terrace → OpenTable widget on the Barn page
            3. Ripples of Joy retreat → /retreat-ripples-of-joy
          The "Box Office" text-only card was retired — its CTA is now
          merged into the Concert Series card for a single, clearer entry
          point to ticketed events. */}
      <section style={{ padding: "0 0 96px" }}>
        <div className="container">
          <div className="cal-cards">
            {/* CARD 1 — Concert Series */}
            <a href={TICKETS_URL} target="_blank" rel="noopener noreferrer" className="cal-card cal-card--image" style={{ textDecoration: "none", color: "inherit" }}>
              <div className="cal-card-image">
                <img src="images/calendar-card-concert-series.jpg" alt="Jeffrey Gaines performing in The Playhouse" style={{ objectPosition: "50% 30%" }} />
              </div>
              <div className="cal-card-overlay">
                <div className="cal-card-eyebrow" style={{ color: "rgba(255,255,255,.85)" }}>The Playhouse</div>
                <h3 className="cal-card-title" style={{ color: "#fbf8f2" }}>Concert Series</h3>
                <p className="cal-card-body" style={{ color: "rgba(255,255,255,.85)" }}>
                  Local, regional, and national acts in an intimate room that rewards listening.
                </p>
                <span className="btn primary" style={{ background: "#fbf8f2", color: "var(--ink)", alignSelf: "flex-start" }}>
                  View Box Office <Arrow size={12} />
                </span>
              </div>
            </a>

            {/* CARD 2 — Live Music on the Terrace
                  Links to /dine-terrace; the Terrace doesn't take
                  reservations (walk-in only). */}
            <Link to="dine-terrace" className="cal-card cal-card--image cal-card--live-music" style={{ textDecoration: "none", color: "inherit" }}>
              <div className="cal-card-image">
                <img src="images/dine-terrace-feature-live-acoustic-music.jpg" alt="Live music on The Terrace" />
              </div>
              <div className="cal-card-overlay">
                <div className="cal-card-eyebrow" style={{ color: "rgba(255,255,255,.85)" }}>Weekends all Summer</div>
                <h3 className="cal-card-title" style={{ color: "#fbf8f2" }}>Live Music on the Terrace</h3>
                <p className="cal-card-body" style={{ color: "rgba(255,255,255,.85)" }}>
                  Free music on the Terrace through spring and summer. Terrace menu all evening — drop in any time.
                </p>
                <span className="btn primary" style={{ background: "#fbf8f2", color: "var(--ink)", alignSelf: "flex-start" }}>
                  View the Terrace <Arrow size={12} />
                </span>
              </div>
            </Link>

            {/* CARD 3 — Ripples of Joy retreat promo */}
            <Link to="retreat-ripples-of-joy" className="cal-card cal-card--image" style={{ textDecoration: "none", color: "inherit" }}>
              <div className="cal-card-image">
                <img src="images/calendar-card-ripples.jpg" alt="Dr. Jenn Olivetti by the Yellow Breeches" style={{ objectPosition: "100% 35%" }} />
              </div>
              <div className="cal-card-overlay">
                <div className="cal-card-eyebrow" style={{ color: "rgba(255,255,255,.85)" }}>June 6–8</div>
                <h3 className="cal-card-title" style={{ color: "#fbf8f2" }}>Ripples of Joy</h3>
                <p className="cal-card-body" style={{ color: "rgba(255,255,255,.85)" }}>
                  Three days with Dr. Jenn Olivetti — guided mindfulness, mindful movement, and the practice of cultivating genuine joy.
                </p>
                <span className="btn primary" style={{ background: "#fbf8f2", color: "var(--ink)", alignSelf: "flex-start" }}>
                  Reserve your spot <Arrow size={12} />
                </span>
              </div>
            </Link>
          </div>
        </div>
      </section>

      <Newsletter />

      {/* Mobile modal — same content as desktop hover popover */}
      {/* EventModal is the universal "event details" surface when an
          event is selected — used by mobile pill taps (existing path)
          AND by Live Music search-result clicks on both platforms.
          Desktop pill clicks remain a no-op (hover popover handles
          desktop pill interaction); only the search-click path opens
          the modal on desktop. */}
      {selectedEvent && (
        <EventModal event={selectedEvent} onClose={() => setSelected(null)} />
      )}

      <style>{calendarCSS}</style>
    </div>
  );
}

// ---- DAY CELL ----
function DayCell({ date, inMonth, isToday, events, cursor, onSelect, isMobile }) {
  const num = date.getDate();
  const muted = !inMonth;

  // Split: spanning retreats render as a bar across the top
  // of the cell; single-day events stack as pills below.
  const spans  = events.filter((e) => e.dateEnd);
  const single = events.filter((e) => !e.dateEnd);

  return (
    <div style={{
      minHeight: 110,
      borderRight: "1px solid var(--rule)",
      borderBottom: "1px solid var(--rule)",
      padding: "8px 8px 6px",
      background: muted ? "transparent" : "var(--bg-elev)",
      opacity: muted ? 0.35 : 1,
      display: "flex", flexDirection: "column", gap: 4,
      position: "relative",
    }}>
      <div style={{
        display: "flex", justifyContent: "space-between", alignItems: "center",
        marginBottom: 2,
      }}>
        <span style={{
          fontFamily: "var(--mono)",
          fontSize: 12,
          color: isToday ? "#fbf8f2" : "var(--ink-soft)",
          background: isToday ? "var(--accent)" : "transparent",
          width: isToday ? 22 : "auto",
          height: isToday ? 22 : "auto",
          borderRadius: isToday ? "50%" : 0,
          display: "inline-flex", alignItems: "center", justifyContent: "center",
          fontWeight: isToday ? 600 : 400,
        }}>{num}</span>
      </div>

      {spans.map((e) => {
        // Show the bar on every covered day; only label it on dateStart
        const isStart = isoOf(date) === e.dateStart;
        const isEnd   = isoOf(date) === e.dateEnd;
        return (
          <EventPill
            key={e.id} event={e}
            isMobile={isMobile} onSelect={onSelect}
            spanStart={isStart} spanEnd={isEnd}
            label={isStart ? e.title : "\u00A0"}
          />
        );
      })}

      {single.map((e) => (
        <EventPill key={e.id} event={e} isMobile={isMobile} onSelect={onSelect} label={e.title} />
      ))}
    </div>
  );
}

// ---- EVENT PILL ----
// Desktop: hover anywhere on the pill → popover anchored to it. The
// pill + popover share one wrapper so moving the cursor from the pill
// down into the popover (to click a CTA) doesn't trigger mouseleave.
// A 100ms close delay covers the tiny gap between pill and popover
// edges while the cursor is in transit.
// Mobile: tap → triggers the centered modal at the page level.
function EventPill({ event, label, isMobile, onSelect, spanStart, spanEnd }) {
  const [hover, setHover] = React.useState(false);
  const closeTimer = React.useRef(null);
  const cat = CAL_CATS[event.category];
  const isSpan = !!event.dateEnd;

  const radii = isSpan
    ? `${spanStart ? 3 : 0}px ${spanEnd ? 3 : 0}px ${spanEnd ? 3 : 0}px ${spanStart ? 3 : 0}px`
    : "3px";

  const open = () => {
    if (isMobile) return;
    if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
    setHover(true);
  };
  const scheduleClose = () => {
    if (isMobile) return;
    if (closeTimer.current) clearTimeout(closeTimer.current);
    closeTimer.current = setTimeout(() => setHover(false), 120);
  };

  React.useEffect(() => () => { if (closeTimer.current) clearTimeout(closeTimer.current); }, []);

  return (
    <div
      style={{ position: "relative" }}
      onMouseEnter={open}
      onMouseLeave={scheduleClose}
    >
      <button
        type="button"
        onClick={() => isMobile && onSelect(event.id)}
        style={{
          display: "block",
          width: "100%",
          textAlign: "left",
          background: cat.pill,
          color: cat.pillFg,
          fontSize: 11,
          fontFamily: "var(--body)",
          fontWeight: 500,
          padding: "3px 6px",
          borderRadius: radii,
          marginLeft: isSpan && !spanStart ? -8 : 0,
          marginRight: isSpan && !spanEnd ? -8 : 0,
          whiteSpace: "nowrap",
          overflow: "hidden",
          textOverflow: "ellipsis",
          lineHeight: 1.3,
          cursor: "pointer",
          letterSpacing: "0.01em",
        }}
      >
        {label || event.title}
      </button>
      {hover && !isMobile && <EventPopover event={event} />}
    </div>
  );
}

// ---- DESKTOP POPOVER ----
// Sits flush below the pill (top: 100%) so there's no dead-pixel gap
// for the cursor to fall through. Inherits its parent's hover handlers
// because it lives inside the same wrapper.
function EventPopover({ event }) {
  return (
    <div
      role="tooltip"
      style={{
        position: "absolute",
        top: "100%",
        left: 0,
        marginTop: 6,
        zIndex: 50,
        width: 280,
        background: "var(--bg-elev)",
        border: "1px solid var(--rule)",
        boxShadow: "var(--shadow)",
        padding: "16px 18px",
        pointerEvents: "auto",
      }}
    >
      <EventCardContent event={event} />
    </div>
  );
}

// ---- MOBILE MODAL ----
function EventModal({ event, onClose }) {
  React.useEffect(() => {
    const onKey = (e) => e.key === "Escape" && onClose();
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [onClose]);

  return (
    <div
      onClick={onClose}
      style={{
        position: "fixed", inset: 0, zIndex: 300,
        background: "rgba(26, 37, 48, 0.55)",
        display: "flex", alignItems: "center", justifyContent: "center",
        padding: 24,
      }}
    >
      <div
        onClick={(e) => e.stopPropagation()}
        style={{
          width: "100%", maxWidth: 420,
          background: "var(--bg-elev)",
          border: "1px solid var(--rule)",
          padding: "24px 24px 20px",
          position: "relative",
        }}
      >
        <button
          onClick={onClose} aria-label="Close"
          style={{
            position: "absolute", top: 10, right: 10,
            padding: 8, color: "var(--ink-soft)",
          }}
        >
          <svg width="18" height="18" viewBox="0 0 18 18"><path d="M3 3l12 12M15 3L3 15" stroke="currentColor" strokeWidth="1.4" /></svg>
        </button>
        <EventCardContent event={event} />
      </div>
    </div>
  );
}

// ---- SHARED CONTENT (popover + modal) ----
function EventCardContent({ event }) {
  const cat = CAL_CATS[event.category];
  const start = parseISO(event.dateStart);
  const end   = event.dateEnd ? parseISO(event.dateEnd) : null;
  const dateStr = end
    ? `${MONTHS[start.getMonth()]} ${start.getDate()}–${end.getDate()}, ${start.getFullYear()}`
    : `${MONTHS[start.getMonth()]} ${start.getDate()}, ${start.getFullYear()}`;

  return (
    <>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
        <span style={{ width: 8, height: 8, background: cat.token, borderRadius: 2 }} />
        <span style={{ fontSize: 10, letterSpacing: "0.16em", textTransform: "uppercase", color: "var(--ink-mute)" }}>
          {cat.label}
        </span>
      </div>
      <h4 style={{ fontFamily: "var(--display)", fontSize: 22, margin: 0, lineHeight: 1.2, marginBottom: 8 }}>
        {event.title}
      </h4>
      <div style={{ fontFamily: "var(--mono)", fontSize: 11, color: "var(--ink-soft)", marginBottom: 10, letterSpacing: "0.04em" }}>
        {dateStr}{event.time ? ` · ${event.time}` : ""}
      </div>
      <p style={{ fontSize: 13, lineHeight: 1.55, color: "var(--ink-soft)", margin: 0 }}>
        {event.description}
      </p>
      {event.soldOut ? (
        <div style={{ marginTop: 16 }}>
          {/* Non-actionable Sold Out indicator. Same footprint as the
              button it replaces, but visually muted (outlined, no fill,
              ink-mute color) so it reads as "this isn't a CTA." */}
          <span style={{
            display: "inline-flex", alignItems: "center",
            fontFamily: "var(--mono)", fontSize: 12, fontWeight: 500,
            letterSpacing: "0.14em", textTransform: "uppercase",
            padding: "10px 16px", border: "1px solid var(--rule)",
            color: "var(--ink-mute)",
          }}>
            Sold Out
          </span>
        </div>
      ) : event.ctaText && event.ctaUrl && (
        <div style={{ marginTop: 16 }}>
          {event.isExternal ? (
            <a href={event.ctaUrl} target="_blank" rel="noopener noreferrer" className="btn primary" style={{ textDecoration: "none", fontSize: 12, padding: "10px 16px" }}>
              {event.ctaText} <Arrow size={11} />
            </a>
          ) : (
            <Link to={event.ctaUrl} className="btn primary" style={{ fontSize: 12, padding: "10px 16px" }}>
              {event.ctaText} <Arrow size={11} />
            </Link>
          )}
        </div>
      )}
    </>
  );
}

// ---- SEARCH INPUT ----
// Borrows the inquiry-form input styling convention (1px rule border,
// 8px radius, 12×14 padding, --bg background, body font, no native
// appearance). Hidden <label> + redundant aria-label for SR users.
// Magnifying-glass icon is a fixed left adornment (pointerEvents:none
// so clicks pass through to the input). Clear-x button on the right
// only renders when there's text.
//   • desktop: ~240px wide (secondary tool, not a primary control).
//   • mobile (fullWidth=true): width:100% so the input fills the
//     stacked row below the month-nav row.
function CalendarSearchInput({ query, setQuery, hasQuery, fullWidth = false }) {
  return (
    <div style={{ position: "relative", width: fullWidth ? "100%" : 240 }}>
      <label htmlFor="cal-search" className="cal-search-label-sr">Search events</label>
      <span
        aria-hidden="true"
        style={{
          position: "absolute", left: 12, top: "50%", transform: "translateY(-50%)",
          color: "var(--ink-mute)", pointerEvents: "none", display: "inline-flex",
        }}
      >
        <svg width="15" height="15" viewBox="0 0 16 16" fill="none">
          <circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
          <path d="M11 11l3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
        </svg>
      </span>
      <input
        id="cal-search"
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search events"
        aria-label="Search events"
        autoComplete="off"
        className="cal-search-input"
        style={{
          width: "100%",
          padding: "9px 12px",
          paddingLeft: 34,
          paddingRight: hasQuery ? 32 : 12,
          border: "1px solid var(--rule)",
          borderRadius: 8,
          fontSize: 14,
          fontFamily: "var(--body)",
          background: "var(--bg)",
          color: "var(--ink)",
          outline: "none",
          WebkitAppearance: "none",
          appearance: "none",
          transition: "border-color 120ms ease, box-shadow 120ms ease, background 120ms ease",
        }}
      />
      {hasQuery && (
        <button
          type="button"
          onClick={() => setQuery("")}
          aria-label="Clear search"
          className="cal-search-clear"
          style={{
            position: "absolute", right: 6, top: "50%", transform: "translateY(-50%)",
            border: "none", background: "transparent",
            padding: 6, cursor: "pointer",
            color: "var(--ink-mute)",
            display: "inline-flex", alignItems: "center", justifyContent: "center",
            transition: "color 120ms ease",
          }}
        >
          <svg width="12" height="12" viewBox="0 0 22 22" fill="none">
            <path d="M4 4l14 14M18 4L4 18" stroke="currentColor" strokeWidth="1.6" />
          </svg>
        </button>
      )}
      <style>{`
        .cal-search-label-sr {
          position: absolute; width: 1px; height: 1px;
          padding: 0; margin: -1px; overflow: hidden;
          clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
        }
        .cal-search-input:focus {
          border-color: var(--accent);
          box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent), transparent 80%);
          background: var(--bg-elev);
        }
        .cal-search-clear:hover { color: var(--ink); }
      `}</style>
    </div>
  );
}

// ---- SEARCH RESULTS LIST ----
// Renders directly between the month-header row and the day-of-week
// row, in document flow (no absolute positioning) so the calendar
// grid is naturally pushed down. Three visual states:
//   • zero matches: a single role=status caption — "No events match …"
//   • 1–10 matches: capped list of result rows.
//   • 11+ matches: 10 rows + a non-clickable "+ N more matches" caption
//     prompting the user to refine.
//
// Per-category click semantics live in the parent's onResultClick;
// per-category visible *element type* (anchor / Link / button) is
// chosen here so the click target is the entire row.
function CalendarSearchResults({ query, results, overflowCount, hasAnyMatch, onResultClick }) {
  const empty = !hasAnyMatch;

  return (
    <div
      role="status"
      aria-live="polite"
      style={{
        marginBottom: 24,
        border: "1px solid var(--rule)",
        background: "var(--bg-elev)",
      }}
    >
      {empty ? (
        <p style={{
          margin: 0, padding: "16px 18px",
          fontSize: 15, color: "var(--ink-soft)",
        }}>
          No events match &ldquo;{query}&rdquo;.
        </p>
      ) : (
        <>
          <ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
            {results.map((event, i) => (
              <li
                key={event.id}
                style={{
                  borderTop: i === 0 ? "none" : "1px solid var(--rule)",
                }}
              >
                <SearchResultRow event={event} onResultClick={onResultClick} />
              </li>
            ))}
          </ul>
          {overflowCount > 0 && (
            <p style={{
              margin: 0, padding: "12px 18px",
              borderTop: "1px solid var(--rule)",
              fontFamily: "var(--mono)", fontSize: 11,
              letterSpacing: "0.14em", textTransform: "uppercase",
              color: "var(--ink-mute)",
            }}>
              + {overflowCount} more match{overflowCount === 1 ? "" : "es"} — refine your search
            </p>
          )}
        </>
      )}
    </div>
  );
}

// ---- SEARCH RESULT ROW ----
// Whole row is the click target. Per-category element type:
//   • Retreats   → <Link to> (SPA nav, same tab)
//   • Box Office → <a target=_blank> (external new tab)
//   • Live Music → <button> (no destination; parent handler advances
//                  the calendar's month and opens EventModal)
// Per-category CTA suffix on the right:
//   • Retreats   → "Reserve your spot →"
//   • Box Office → "Select tickets →"
//   • Live Music → bare "→" chevron
// The asymmetry is intentional — explicit CTA text signals "this
// navigates somewhere", bare chevron signals "this opens something
// here on the page".
// ---- SHARED ROW INNER ----
// Title + meta line + chevron, with optional per-category CTA suffix
// to the left of the chevron. Used by both the search-results rows
// and the mobile event-list rows.
//
// Caller controls:
//   • meta — the secondary line under the title. Each context formats
//     it differently (search includes the date in meta; mobile list
//     has the date in its own column and just shows category + time
//     for non-Retreats events).
//   • ctaText — the optional text label next to the chevron. Search
//     rows pass the per-event CTA (Get Tickets / Reserve your spot /
//     Sold Out — mirrors the event-card CTA exactly so search reads
//     as a confirmation of what the click does). Mobile rows pass
//     empty string — the chevron alone is enough; in browse mode the
//     CTA labels would just add visual noise to a long list, and the
//     two-tap modal flow surfaces the actual CTA in the modal anyway.
//
// The chevron always renders — it's the universal clickability
// affordance, independent of whether there's CTA text labeling it.
function EventRowInner({ event, meta, ctaText }) {
  return (
    <>
      <div style={{ flex: "1 1 auto", minWidth: 0 }}>
        <div style={{ fontSize: 16, fontWeight: 500, color: "var(--ink)", lineHeight: 1.3 }}>
          {event.title}
        </div>
        <div style={{ fontSize: 13, color: "var(--ink-mute)", marginTop: 2, lineHeight: 1.3 }}>
          {meta}
        </div>
      </div>
      <div style={{
        flex: "0 0 auto", display: "flex", alignItems: "center", gap: 8,
        fontFamily: "var(--mono)", fontSize: 12,
        letterSpacing: "0.14em", textTransform: "uppercase",
        color: "var(--ink-soft)",
      }} className="cal-search-row__cta">
        {ctaText && <span>{ctaText}</span>}
        <svg width="13" height="13" viewBox="0 0 14 14" aria-hidden="true">
          <path d="M1 7h12M7 1l6 6-6 6" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </div>
    </>
  );
}

// Shared row outer style — applied identically to search-result rows
// and mobile event-list rows so they look like the same component
// regardless of which surface they sit on.
const ROW_OUTER_STYLE = {
  display: "flex", alignItems: "center", gap: 16,
  padding: "14px 18px",
  textDecoration: "none",
  color: "inherit",
  background: "transparent",
  border: "none",
  width: "100%",
  textAlign: "left",
  cursor: "pointer",
  transition: "background 120ms ease",
  fontFamily: "inherit",
};

function SearchResultRow({ event, onResultClick }) {
  const cat = CAL_CATS[event.category];
  const meta = `${cat.label} · ${formatEventDateRange(event)}`;
  // Search rows mirror the event-card CTA exactly so the user can
  // confirm what the click will do before tapping.
  const ctaText = event.soldOut ? "Sold Out" : (event.ctaText || "");
  const inner = <EventRowInner event={event} meta={meta} ctaText={ctaText} />;

  // Element type is data-driven, not category-driven:
  //   • event.ctaUrl + isExternal → external <a> (Box Office Ticket
  //     Tailor links). New tab.
  //   • event.ctaUrl, no isExternal → SPA <Link> (Retreats internal
  //     navigation). Same tab.
  //   • no ctaUrl → <button>. Covers Live Music (drop-in info, no
  //     destination) AND sold-out events (no tickets to buy). The
  //     parent's handleResultClick opens EventModal in-page after
  //     advancing the calendar's cursor to the event's month.
  if (event.ctaUrl && event.isExternal) {
    return (
      <a
        href={event.ctaUrl}
        target="_blank"
        rel="noopener noreferrer"
        className="cal-search-row"
        style={ROW_OUTER_STYLE}
        onClick={() => onResultClick(event)}
      >
        {inner}
      </a>
    );
  }
  if (event.ctaUrl) {
    return (
      <Link
        to={event.ctaUrl}
        className="cal-search-row"
        style={ROW_OUTER_STYLE}
        onClick={() => onResultClick(event)}
      >
        {inner}
      </Link>
    );
  }
  return (
    <button
      type="button"
      className="cal-search-row"
      style={ROW_OUTER_STYLE}
      onClick={() => onResultClick(event)}
    >
      {inner}
    </button>
  );
}

// ---- MOBILE EVENT LIST ----
// Replaces the 7-column calendar grid at <=760px. Renders all events
// whose start date falls within the current month (cursor) as a
// vertical list. Multi-day events appear once on their start date
// with the full date range in the date column.
//
// Tap behavior: every row opens the EventModal (consistent with the
// existing mobile pill-tap pattern — see line ~590, button onClick
// gated on isMobile). Two-tap flow on mobile (row → modal → CTA)
// gives the user a chance to read the event description before
// navigating. Direct-navigation per category (the search-results
// pattern) would skip the description for Box Office and Retreats.
//
// Empty-month fallback uses the same wrapper styling as the
// search-empty-state caption so they read as one design family.
function MobileEventList({ cursor, onRowClick }) {
  const monthEvents = CAL_EVENTS
    .filter((e) => {
      const start = parseISO(e.dateStart);
      return start.getFullYear() === cursor.getFullYear() && start.getMonth() === cursor.getMonth();
    })
    .slice()
    .sort((a, b) => a.dateStart.localeCompare(b.dateStart));

  if (monthEvents.length === 0) {
    return (
      <div
        role="status"
        style={{
          marginBottom: 24,
          border: "1px solid var(--rule)",
          background: "var(--bg-elev)",
        }}
      >
        <p style={{ margin: 0, padding: "16px 18px", fontSize: 15, color: "var(--ink-soft)" }}>
          No events in {MONTHS[cursor.getMonth()]}.
        </p>
      </div>
    );
  }

  return (
    <ul style={{
      listStyle: "none", padding: 0, margin: 0,
      border: "1px solid var(--rule)",
      background: "var(--bg-elev)",
    }}>
      {monthEvents.map((event, i) => (
        <li key={event.id} style={{ borderTop: i === 0 ? "none" : "1px solid var(--rule)" }}>
          <MobileEventListRow event={event} onClick={onRowClick} />
        </li>
      ))}
    </ul>
  );
}

// ---- MOBILE EVENT LIST ROW ----
// Date column on the left + shared EventRowInner on the right.
// Always renders as a <button> — tap opens the EventModal regardless
// of event category.
//
// Date column format:
//   • Single-day:    "Fri 1"           (DOW abbrev + day number)
//   • Same-month span: "Jun 6 – 8"     (month abbrev + day range)
//   • Cross-month span: "Jun 30 – Jul 2"
//
// Meta line format:
//   • With time:     "{Category} · 6–9pm"
//   • Without time:  "{Category}"
function MobileEventListRow({ event, onClick }) {
  const cat = CAL_CATS[event.category];
  const start = parseISO(event.dateStart);
  const isSpan = !!event.dateEnd;
  let dateLabel;
  if (isSpan) {
    const end = parseISO(event.dateEnd);
    const sameMonth = start.getMonth() === end.getMonth() && start.getFullYear() === end.getFullYear();
    dateLabel = sameMonth
      ? `${SHORT_MONTHS[start.getMonth()]} ${start.getDate()} – ${end.getDate()}`
      : `${SHORT_MONTHS[start.getMonth()]} ${start.getDate()} – ${SHORT_MONTHS[end.getMonth()]} ${end.getDate()}`;
  } else {
    dateLabel = `${DOW[start.getDay()]} ${start.getDate()}`;
  }
  // Meta line. Retreats keep just the category label — the date column
  // already carries the range ("Jun 6 – 8") and the event.time field
  // for retreats is verbose ("June 6–8 · arrivals 3pm") and would
  // duplicate the date. Box Office and Live Music keep their time in
  // the meta — show times and start/door times are useful info for
  // ticketed and drop-in events.
  const meta = event.category === "retreats"
    ? cat.label
    : event.time ? `${cat.label} · ${event.time}` : cat.label;

  // Restructured row: 4px category-color stripe on the left (full
  // row height via alignSelf:stretch), then a padded inner content
  // wrapper holding the date column + EventRowInner. The stripe
  // sits outside the inner padding so it runs edge-to-edge top to
  // bottom of the row, restoring the legend ↔ rows visual link.
  // Same color value as the legend's swatch (cat.token), so the
  // legend's promise holds.
  return (
    <button
      type="button"
      className="cal-search-row"
      style={{
        ...ROW_OUTER_STYLE,
        padding: 0,
        gap: 0,
      }}
      onClick={() => onClick(event.id)}
    >
      <div
        aria-hidden="true"
        style={{
          flex: "0 0 4px",
          alignSelf: "stretch",
          background: cat.token,
        }}
      />
      <div style={{
        flex: "1 1 auto", minWidth: 0,
        display: "flex", alignItems: "center", gap: 16,
        padding: "14px 18px",
      }}>
        <div style={{
          flex: "0 0 auto",
          minWidth: 76,
          fontFamily: "var(--mono)",
          fontSize: 12,
          letterSpacing: "0.06em",
          textTransform: "uppercase",
          color: "var(--ink-mute)",
          lineHeight: 1.3,
        }}>
          {dateLabel}
        </div>
        {/* Mobile rows pass empty ctaText — bare chevron only. The
            per-category CTA labels would add noise to a browse list,
            and the two-tap modal flow surfaces the actual CTA in the
            modal that opens on tap. */}
        <EventRowInner event={event} meta={meta} ctaText="" />
      </div>
    </button>
  );
}

function navBtnStyle(disabled) {
  return {
    width: 40, height: 40,
    border: "1px solid var(--rule)",
    background: "var(--bg-elev)",
    color: disabled ? "var(--ink-mute)" : "var(--ink)",
    opacity: disabled ? 0.4 : 1,
    cursor: disabled ? "not-allowed" : "pointer",
    display: "inline-flex", alignItems: "center", justifyContent: "center",
    transition: "all .15s ease",
  };
}

const calendarCSS = `
.cal-cards {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 24px;
}
.cal-card {
  display: flex;
  flex-direction: column;
  position: relative;
  min-height: 360px;
  overflow: hidden;
  background: var(--bg-elev);
  border: 1px solid var(--rule);
  transition: transform .25s ease, box-shadow .25s ease;
}
.cal-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); }

/* Search result row — hover lifts background + nudges chevron color
   to accent. Whole row is the click target, so the entire row picks
   up the hover affordance. */
.cal-search-row { background: transparent; }
.cal-search-row:hover { background: var(--bg); }
.cal-search-row:hover .cal-search-row__cta { color: var(--accent); }
.cal-search-row:focus-visible {
  outline: none;
  background: var(--bg);
  box-shadow: inset 0 0 0 2px var(--accent);
}
.cal-card--text { padding: 32px 28px; gap: 12px; }
.cal-card--image { color: inherit; }
.cal-card-image { position: absolute; inset: 0; }
.cal-card-image img {
  width: 100%; height: 100%; object-fit: cover; display: block;
}
.cal-card-overlay {
  position: relative;
  z-index: 1;
  padding: 32px 28px;
  display: flex; flex-direction: column; gap: 12px;
  flex: 1;
  /* Anchor all content to the BOTTOM of the card so eyebrow / title /
     body / CTA cluster together over the strongest part of the
     gradient. The first child gets margin-top: auto to push the
     stack down. */
  justify-content: flex-end;
  /* Two-layer treatment so white type stays legible regardless of the
     photo's underlying luminance:
       1. A LEFT-anchored linear gradient covers the whole left column
          where the eyebrow / title / body / CTA stack lives — sized
          to span full card height so it works in both the 3-up grid
          (taller cards) and the stacked 1-col layout (wider cards
          where the type column extends top to bottom on the left).
       2. A bottom linear adds a soft floor for the CTA area on
          taller crops.
     Tested against the lightest of the three (Ripples) — Dr. Jenn's
     hair/forehead falls on the right two-thirds of the card, so the
     left scrim never has to fight her face for contrast. */
  background:
    linear-gradient(90deg, rgba(26,37,48,.78) 0%, rgba(26,37,48,.55) 35%, rgba(26,37,48,.05) 70%, rgba(26,37,48,0) 100%),
    linear-gradient(180deg, rgba(26,37,48,0) 0%, rgba(26,37,48,.15) 60%, rgba(26,37,48,.55) 100%);
}

/* Per-card scrim override: the Live Music photo is mid-tone across
   most of the frame (warm wood, instruments, performer fabric) and
   needs a darker base than Concert Series or Ripples. Bumps both the
   left scrim peak and the bottom floor up. */
.cal-card.cal-card--live-music .cal-card-overlay {
  background:
    linear-gradient(90deg, rgba(26,37,48,.88) 0%, rgba(26,37,48,.7) 35%, rgba(26,37,48,.2) 70%, rgba(26,37,48,.05) 100%),
    linear-gradient(180deg, rgba(26,37,48,.1) 0%, rgba(26,37,48,.35) 50%, rgba(26,37,48,.78) 100%);
}
.cal-card-eyebrow {
  font-family: var(--body);
  font-size: 11px; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--ink-mute);
}
.cal-card-title {
  font-family: var(--display);
  font-size: 32px; font-weight: 400;
  letter-spacing: -0.015em;
  margin: 0; line-height: 1.05;
}
.cal-card-body {
  font-size: 14px; line-height: 1.55;
  color: var(--ink-soft);
  margin: 0;
}
.cal-card-overlay > * {
  /* Keep the text column inside the left scrim — even when the card
     itself stretches wide on the stacked single-column layout. The
     gradient above dims the left ~70% of the card; constraining text
     to ~480px keeps it well inside that zone. The CTA pill is also
     align-self: flex-start (set inline) so it doesn't try to span. */
  max-width: 480px;
}
@media (max-width: 1000px) {
  .cal-cards { grid-template-columns: 1fr; }
  .cal-card { min-height: 320px; }
}
`;

Object.assign(window, { CalendarPage });
